diff --git a/src/main/java/com/featureprobe/api/base/enums/ApprovalStatusEnum.java b/src/main/java/com/featureprobe/api/base/enums/ApprovalStatusEnum.java new file mode 100644 index 0000000..4b1d8c8 --- /dev/null +++ b/src/main/java/com/featureprobe/api/base/enums/ApprovalStatusEnum.java @@ -0,0 +1,22 @@ +package com.featureprobe.api.base.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public enum ApprovalStatusEnum { + PENDING, + PASS, + REJECT, + JUMP, + REVOKE; + + private static final Map namesMap = Arrays.stream(ApprovalStatusEnum.values()) + .collect(Collectors.toMap(pt -> pt.name(), pt -> pt)); + + @JsonCreator + public static ApprovalStatusEnum forValue(String value) { + return namesMap.get(value); + } +} diff --git a/src/main/java/com/featureprobe/api/base/enums/ApprovalTypeEnum.java b/src/main/java/com/featureprobe/api/base/enums/ApprovalTypeEnum.java new file mode 100644 index 0000000..974e650 --- /dev/null +++ b/src/main/java/com/featureprobe/api/base/enums/ApprovalTypeEnum.java @@ -0,0 +1,5 @@ +package com.featureprobe.api.base.enums; + +public enum ApprovalTypeEnum { + APPROVAL,APPLY +} diff --git a/src/main/java/com/featureprobe/api/base/enums/ResourceType.java b/src/main/java/com/featureprobe/api/base/enums/ResourceType.java index 7fffced..962a583 100644 --- a/src/main/java/com/featureprobe/api/base/enums/ResourceType.java +++ b/src/main/java/com/featureprobe/api/base/enums/ResourceType.java @@ -6,7 +6,8 @@ public enum ResourceType { PROJECT("projectKey"), TOGGLE("toggleKey"), ENVIRONMENT("environmentKey"), - MEMBER("account"),SEGMENT("segment"),DICTIONARY("dictionary"), + TARGETING("projectKey_environmentKey_toggleKey"), MEMBER("account"), + SEGMENT("segment"), DICTIONARY("dictionary"), ORGANIZATION_MEMBER("organization_member"); private String paramName; diff --git a/src/main/java/com/featureprobe/api/base/enums/SketchStatusEnum.java b/src/main/java/com/featureprobe/api/base/enums/SketchStatusEnum.java new file mode 100644 index 0000000..44f144f --- /dev/null +++ b/src/main/java/com/featureprobe/api/base/enums/SketchStatusEnum.java @@ -0,0 +1,20 @@ +package com.featureprobe.api.base.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +public enum SketchStatusEnum { + + PENDING,REVOKE,RELEASE,CANCEL; + + private static final Map namesMap = Arrays.stream(SketchStatusEnum.values()) + .collect(Collectors.toMap(pt -> pt.name(), pt -> pt)); + + @JsonCreator + public static SketchStatusEnum forValue(String value) { + return namesMap.get(value); + } +} diff --git a/src/main/java/com/featureprobe/api/controller/ApprovalRecordController.java b/src/main/java/com/featureprobe/api/controller/ApprovalRecordController.java new file mode 100644 index 0000000..ec5efde --- /dev/null +++ b/src/main/java/com/featureprobe/api/controller/ApprovalRecordController.java @@ -0,0 +1,32 @@ +package com.featureprobe.api.controller; + +import com.featureprobe.api.base.doc.DefaultApiResponses; +import com.featureprobe.api.dto.ApprovalRecordQueryRequest; +import com.featureprobe.api.dto.ApprovalRecordResponse; +import com.featureprobe.api.service.ApprovalRecordService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@Slf4j +@DefaultApiResponses +@Tag(name = "Approval record", description = "Using the approval API, you can query approval record") +@RequestMapping("/approvalRecords") +@AllArgsConstructor +@RestController +public class ApprovalRecordController { + + private ApprovalRecordService approvalRecordService; + + @GetMapping + public Page list(@Validated ApprovalRecordQueryRequest queryRequest) { + return approvalRecordService.list(queryRequest); + } + +} diff --git a/src/main/java/com/featureprobe/api/controller/DictionaryController.java b/src/main/java/com/featureprobe/api/controller/DictionaryController.java index 058af97..11a3ec8 100644 --- a/src/main/java/com/featureprobe/api/controller/DictionaryController.java +++ b/src/main/java/com/featureprobe/api/controller/DictionaryController.java @@ -27,7 +27,7 @@ public DictionaryResponse query(@PathVariable("key") String key) { @PostMapping("/{key}") public DictionaryResponse save(@PathVariable("key") String key, @RequestBody String value) { - return dictionaryService.save(key, value); + return dictionaryService.create(key, value); } } diff --git a/src/main/java/com/featureprobe/api/controller/ProjectController.java b/src/main/java/com/featureprobe/api/controller/ProjectController.java index 5152d0b..2b5fa7c 100644 --- a/src/main/java/com/featureprobe/api/controller/ProjectController.java +++ b/src/main/java/com/featureprobe/api/controller/ProjectController.java @@ -7,7 +7,9 @@ import com.featureprobe.api.base.doc.ProjectKeyParameter; import com.featureprobe.api.base.enums.ResponseCodeEnum; import com.featureprobe.api.base.enums.ValidateTypeEnum; +import com.featureprobe.api.dto.ApprovalSettings; import com.featureprobe.api.dto.BaseResponse; +import com.featureprobe.api.dto.PreferenceCreateRequest; import com.featureprobe.api.dto.ProjectCreateRequest; import com.featureprobe.api.dto.ProjectQueryRequest; import com.featureprobe.api.dto.ProjectResponse; @@ -81,5 +83,19 @@ public BaseResponse exists( return new BaseResponse(ResponseCodeEnum.SUCCESS); } - + @PostMapping("/{projectKey}") + @CreateApiResponse + @Operation(summary = "Save project setting", description = "Update a project settings.") + public BaseResponse preference(@PathVariable("projectKey") String projectKey, + @RequestBody @Validated PreferenceCreateRequest createRequest) { + projectService.createPreference(projectKey, createRequest); + return new BaseResponse(ResponseCodeEnum.SUCCESS); + } + + @GetMapping("/{projectKey}/approvalSettings") + @CreateApiResponse + @Operation(summary = "Query project settings", description = "Query a project settings.") + public List approvalSettingsList(@PathVariable("projectKey") String projectKey) { + return projectService.approvalSettingsList(projectKey); + } } diff --git a/src/main/java/com/featureprobe/api/controller/TargetingController.java b/src/main/java/com/featureprobe/api/controller/TargetingController.java index 732f50f..56a6a9c 100644 --- a/src/main/java/com/featureprobe/api/controller/TargetingController.java +++ b/src/main/java/com/featureprobe/api/controller/TargetingController.java @@ -1,16 +1,21 @@ package com.featureprobe.api.controller; +import com.featureprobe.api.base.doc.CreateApiResponse; import com.featureprobe.api.base.doc.DefaultApiResponses; import com.featureprobe.api.base.doc.EnvironmentKeyParameter; import com.featureprobe.api.base.doc.GetApiResponse; import com.featureprobe.api.base.doc.PatchApiResponse; import com.featureprobe.api.base.doc.ProjectKeyParameter; import com.featureprobe.api.base.doc.ToggleKeyParameter; +import com.featureprobe.api.base.enums.ResponseCodeEnum; import com.featureprobe.api.dto.AfterTargetingVersionResponse; +import com.featureprobe.api.dto.BaseResponse; +import com.featureprobe.api.dto.TargetingDiffResponse; import com.featureprobe.api.dto.TargetingRequest; import com.featureprobe.api.dto.TargetingResponse; import com.featureprobe.api.dto.TargetingVersionRequest; import com.featureprobe.api.dto.TargetingVersionResponse; +import com.featureprobe.api.dto.UpdateApprovalStatusRequest; import com.featureprobe.api.service.TargetingService; import com.featureprobe.api.validate.ResourceExistsValidate; import io.swagger.v3.oas.annotations.Operation; @@ -43,13 +48,43 @@ public class TargetingController { @PatchApiResponse @PatchMapping @Operation(summary = "Update targeting", description = "Update targeting.") - public TargetingRequest update( + public TargetingResponse update( @PathVariable("projectKey") String projectKey, @PathVariable("environmentKey") String environmentKey, @PathVariable("toggleKey") String toggleKey, @RequestBody @Validated TargetingRequest targetingRequest) { - targetingService.update(projectKey, environmentKey, toggleKey, targetingRequest); - return targetingRequest; + return targetingService.update(projectKey, environmentKey, toggleKey, targetingRequest); + } + + @PatchMapping("/sketch/publish") + @CreateApiResponse + @Operation(summary = "Publish targeting sketch", description = "Publish targeting sketch.") + public TargetingResponse publishSketch(@PathVariable("projectKey") String projectKey, + @PathVariable("environmentKey") String environmentKey, + @PathVariable("toggleKey") String toggleKey) { + return targetingService.publishSketch(projectKey, environmentKey, toggleKey); + } + + @PatchMapping("/sketch/cancel") + @CreateApiResponse + @Operation(summary = "Cancel targeting sketch", description = "Cancel targeting sketch.") + public BaseResponse cancelSketch(@PathVariable("projectKey") String projectKey, + @PathVariable("environmentKey") String environmentKey, + @PathVariable("toggleKey") String toggleKey) { + targetingService.cancelSketch(projectKey, environmentKey, toggleKey); + return new BaseResponse(ResponseCodeEnum.SUCCESS); + } + + + @PatchApiResponse + @PatchMapping("/approvalStatus") + @Operation(summary = "Update targeting approval status", description = "Update targeting approval status.") + public BaseResponse updateApprovalStatus(@PathVariable("projectKey") String projectKey, + @PathVariable("environmentKey") String environmentKey, + @PathVariable("toggleKey") String toggleKey, + @RequestBody @Validated UpdateApprovalStatusRequest updateRequest) { + targetingService.updateApprovalStatus(projectKey, environmentKey, toggleKey, updateRequest); + return new BaseResponse(ResponseCodeEnum.SUCCESS); } @GetApiResponse @@ -83,4 +118,14 @@ public AfterTargetingVersionResponse allAfterVersions(@PathVariable("projectKey" @PathVariable("version") Long version) { return targetingService.queryAfterVersion(projectKey, environmentKey, toggleKey, version); } + + @GetApiResponse + @GetMapping("/diff") + @Operation(summary = "Get targeting diff.", description = "Get targeting diff.") + public TargetingDiffResponse diff(@PathVariable("projectKey") String projectKey, + @PathVariable("environmentKey") String environmentKey, + @PathVariable("toggleKey") String toggleKey) { + return targetingService.diff(projectKey, environmentKey, toggleKey); + } + } diff --git a/src/main/java/com/featureprobe/api/controller/ToggleController.java b/src/main/java/com/featureprobe/api/controller/ToggleController.java index 99c2440..7570b4b 100644 --- a/src/main/java/com/featureprobe/api/controller/ToggleController.java +++ b/src/main/java/com/featureprobe/api/controller/ToggleController.java @@ -53,7 +53,7 @@ public class ToggleController { public Page list( @PathVariable(name = "projectKey") String projectKey, @Validated ToggleSearchRequest filter) { - return toggleService.query(projectKey, filter); + return toggleService.list(projectKey, filter); } @CreateApiResponse diff --git a/src/main/java/com/featureprobe/api/dto/ApprovalRecordQueryRequest.java b/src/main/java/com/featureprobe/api/dto/ApprovalRecordQueryRequest.java new file mode 100644 index 0000000..30af61d --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/ApprovalRecordQueryRequest.java @@ -0,0 +1,19 @@ +package com.featureprobe.api.dto; + +import com.featureprobe.api.base.enums.ApprovalTypeEnum; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Data +public class ApprovalRecordQueryRequest extends PaginationRequest{ + + private String keyword; + + @NotNull + private String status; + + @NotNull + private ApprovalTypeEnum type; + +} diff --git a/src/main/java/com/featureprobe/api/dto/ApprovalRecordResponse.java b/src/main/java/com/featureprobe/api/dto/ApprovalRecordResponse.java new file mode 100644 index 0000000..01d5c4c --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/ApprovalRecordResponse.java @@ -0,0 +1,46 @@ +package com.featureprobe.api.dto; + +import com.featureprobe.api.base.enums.ApprovalStatusEnum; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +@Data +public class ApprovalRecordResponse { + + private String title; + + private String projectName; + + private String projectKey; + + private String toggleName; + + private String toggleKey; + + private String environmentName; + + private String environmentKey; + + private ApprovalStatusEnum status; + + private String submitBy; + + private boolean locked; + + private Date lockedTime; + + private List reviewers; + + private String approvedBy; + + private String comment; + + private Date approvalTime; + + private Date sketchTime; + + private Date cancelTime; + +} diff --git a/src/main/java/com/featureprobe/api/dto/ApprovalSettings.java b/src/main/java/com/featureprobe/api/dto/ApprovalSettings.java new file mode 100644 index 0000000..3e0bf91 --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/ApprovalSettings.java @@ -0,0 +1,16 @@ +package com.featureprobe.api.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class ApprovalSettings { + + private String environmentKey; + + private Boolean enable; + + private List reviewers; + +} diff --git a/src/main/java/com/featureprobe/api/dto/BaseResponse.java b/src/main/java/com/featureprobe/api/dto/BaseResponse.java index 95c227d..1ee79a7 100644 --- a/src/main/java/com/featureprobe/api/dto/BaseResponse.java +++ b/src/main/java/com/featureprobe/api/dto/BaseResponse.java @@ -13,7 +13,6 @@ public class BaseResponse { private String message; public BaseResponse(ResponseCodeEnum responseCode) { - this.code = responseCode.code(); this.message = responseCode.message(); } diff --git a/src/main/java/com/featureprobe/api/dto/PreferenceCreateRequest.java b/src/main/java/com/featureprobe/api/dto/PreferenceCreateRequest.java new file mode 100644 index 0000000..8ac1c2e --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/PreferenceCreateRequest.java @@ -0,0 +1,13 @@ +package com.featureprobe.api.dto; + +import lombok.Data; + +import java.util.List; + +@Data +public class PreferenceCreateRequest { + + List approvalSettings; + +} + diff --git a/src/main/java/com/featureprobe/api/dto/TargetingDiffRequest.java b/src/main/java/com/featureprobe/api/dto/TargetingDiffRequest.java new file mode 100644 index 0000000..a18bea0 --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/TargetingDiffRequest.java @@ -0,0 +1,12 @@ +package com.featureprobe.api.dto; + +import lombok.Data; + +@Data +public class TargetingDiffRequest { + + private Long currentVersion; + + private Long targetVersion; + +} diff --git a/src/main/java/com/featureprobe/api/dto/TargetingDiffResponse.java b/src/main/java/com/featureprobe/api/dto/TargetingDiffResponse.java new file mode 100644 index 0000000..d9c627b --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/TargetingDiffResponse.java @@ -0,0 +1,15 @@ +package com.featureprobe.api.dto; + +import com.featureprobe.api.model.TargetingContent; +import lombok.Data; + +@Data +public class TargetingDiffResponse { + + private Boolean currentDisabled; + private TargetingContent currentContent; + + private Boolean oldDisabled; + private TargetingContent oldContent; + +} diff --git a/src/main/java/com/featureprobe/api/dto/TargetingRequest.java b/src/main/java/com/featureprobe/api/dto/TargetingRequest.java index 94bcbbe..c4acc5a 100644 --- a/src/main/java/com/featureprobe/api/dto/TargetingRequest.java +++ b/src/main/java/com/featureprobe/api/dto/TargetingRequest.java @@ -1,11 +1,15 @@ package com.featureprobe.api.dto; import com.featureprobe.api.model.TargetingContent; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; +import java.util.List; @Data +@AllArgsConstructor +@NoArgsConstructor public class TargetingRequest { private TargetingContent content; @@ -13,4 +17,6 @@ public class TargetingRequest { private String comment; private Boolean disabled; -} + + private List reviewers; +} \ No newline at end of file diff --git a/src/main/java/com/featureprobe/api/dto/TargetingResponse.java b/src/main/java/com/featureprobe/api/dto/TargetingResponse.java index 18c0022..032c19f 100644 --- a/src/main/java/com/featureprobe/api/dto/TargetingResponse.java +++ b/src/main/java/com/featureprobe/api/dto/TargetingResponse.java @@ -4,10 +4,27 @@ import lombok.Data; import java.util.Date; +import java.util.List; @Data public class TargetingResponse { + private String status; + + private boolean enableApproval; + + private List reviewers; + + private String approvalComment; + + private String submitBy; + + private boolean locked; + + private Date lockedTime; + + private String approvalBy; + private Boolean disabled; private TargetingContent content; diff --git a/src/main/java/com/featureprobe/api/dto/TargetingVersionResponse.java b/src/main/java/com/featureprobe/api/dto/TargetingVersionResponse.java index 3b141cb..6850480 100644 --- a/src/main/java/com/featureprobe/api/dto/TargetingVersionResponse.java +++ b/src/main/java/com/featureprobe/api/dto/TargetingVersionResponse.java @@ -1,5 +1,6 @@ package com.featureprobe.api.dto; +import com.featureprobe.api.base.enums.ApprovalStatusEnum; import com.featureprobe.api.model.TargetingContent; import lombok.Data; @@ -25,4 +26,12 @@ public class TargetingVersionResponse { private Date createdTime; private String createdBy; + + private ApprovalStatusEnum approvalStatus; + + private String approvalBy; + + private Date approvalTime; + + private String approvalComment; } diff --git a/src/main/java/com/featureprobe/api/dto/ToggleItemResponse.java b/src/main/java/com/featureprobe/api/dto/ToggleItemResponse.java index 2411763..5e3e040 100644 --- a/src/main/java/com/featureprobe/api/dto/ToggleItemResponse.java +++ b/src/main/java/com/featureprobe/api/dto/ToggleItemResponse.java @@ -24,4 +24,10 @@ public class ToggleItemResponse { private Date modifiedTime; private String modifiedBy; + + private boolean locked; + + private String lockedBy; + + private Date lockedTime; } diff --git a/src/main/java/com/featureprobe/api/dto/UpdateApprovalStatusRequest.java b/src/main/java/com/featureprobe/api/dto/UpdateApprovalStatusRequest.java new file mode 100644 index 0000000..2431794 --- /dev/null +++ b/src/main/java/com/featureprobe/api/dto/UpdateApprovalStatusRequest.java @@ -0,0 +1,13 @@ +package com.featureprobe.api.dto; + +import com.featureprobe.api.base.enums.ApprovalStatusEnum; +import lombok.Data; + +@Data +public class UpdateApprovalStatusRequest { + + private ApprovalStatusEnum status; + + private String comment; + +} diff --git a/src/main/java/com/featureprobe/api/entity/ApprovalRecord.java b/src/main/java/com/featureprobe/api/entity/ApprovalRecord.java new file mode 100644 index 0000000..04ad557 --- /dev/null +++ b/src/main/java/com/featureprobe/api/entity/ApprovalRecord.java @@ -0,0 +1,71 @@ +package com.featureprobe.api.entity; + +import com.featureprobe.api.base.config.TenantEntityListener; +import com.featureprobe.api.base.entity.AbstractAuditEntity; +import com.featureprobe.api.base.enums.ApprovalStatusEnum; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.Filter; +import org.hibernate.annotations.FilterDef; +import org.hibernate.annotations.ParamDef; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import java.io.Serializable; + +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@Entity +@Table(name = "approval_record") +@DynamicInsert +@EntityListeners(TenantEntityListener.class) +@ToString(callSuper = true) +@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "organizationId", type = "long")}) +@Filter(name = "tenantFilter", condition = "organization_id = :organizationId") +public class ApprovalRecord extends AbstractAuditEntity implements TenantSupport, Serializable { + + @Column(name = "organization_id") + private Long organizationId; + + @Column(name = "project_key") + private String projectKey; + + @Column(name = "environment_key") + private String environmentKey; + + @Column(name = "toggle_key") + private String toggleKey; + + private String submitBy; + + private String approvedBy; + + @Column(name = "reviewers") + private String reviewers; + + @Enumerated(EnumType.STRING) + private ApprovalStatusEnum status; + + private String title; + + @Column(columnDefinition = "TEXT") + private String comment; + + + +} diff --git a/src/main/java/com/featureprobe/api/entity/Environment.java b/src/main/java/com/featureprobe/api/entity/Environment.java index 8974dee..7c9ad2b 100644 --- a/src/main/java/com/featureprobe/api/entity/Environment.java +++ b/src/main/java/com/featureprobe/api/entity/Environment.java @@ -63,6 +63,11 @@ public class Environment extends AbstractAuditEntity implements TenantSupport, S @Column(columnDefinition = "TINYINT") private boolean archived; + @Column(name = "enable_approval", columnDefinition = "TINYINT") + private boolean enableApproval; + + private String reviewers; + @ManyToOne(cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) @JoinColumnsOrFormulas(value = { @JoinColumnOrFormula(column=@JoinColumn(name ="organization_id", referencedColumnName ="organization_id")), diff --git a/src/main/java/com/featureprobe/api/entity/TargetingSketch.java b/src/main/java/com/featureprobe/api/entity/TargetingSketch.java new file mode 100644 index 0000000..877a042 --- /dev/null +++ b/src/main/java/com/featureprobe/api/entity/TargetingSketch.java @@ -0,0 +1,69 @@ +package com.featureprobe.api.entity; + +import com.featureprobe.api.base.config.TenantEntityListener; +import com.featureprobe.api.base.entity.AbstractAuditEntity; +import com.featureprobe.api.base.enums.SketchStatusEnum; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.Filter; +import org.hibernate.annotations.FilterDef; +import org.hibernate.annotations.ParamDef; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Table; +import java.io.Serializable; + +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@Entity +@Table(name = "targeting_sketch") +@DynamicInsert +@EntityListeners(TenantEntityListener.class) +@ToString(callSuper = true) +@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "organizationId", type = "long")}) +@Filter(name = "tenantFilter", condition = "organization_id = :organizationId") +public class TargetingSketch extends AbstractAuditEntity implements TenantSupport, Serializable { + + @Column(name = "approval_id") + private Long approvalId; + + @Column(name = "organization_id") + private Long organizationId; + + @Column(name = "project_key") + private String projectKey; + + @Column(name = "environment_key") + private String environmentKey; + + @Column(name = "toggle_key") + private String toggleKey; + + @Column(name = "old_version") + private Long oldVersion; + + @Column(columnDefinition = "TEXT") + private String content; + + private String comment; + + @Column(columnDefinition = "TINYINT") + private Boolean disabled; + + @Enumerated(EnumType.STRING) + private SketchStatusEnum status; + +} diff --git a/src/main/java/com/featureprobe/api/entity/TargetingVersion.java b/src/main/java/com/featureprobe/api/entity/TargetingVersion.java index 94e5d37..bd69103 100644 --- a/src/main/java/com/featureprobe/api/entity/TargetingVersion.java +++ b/src/main/java/com/featureprobe/api/entity/TargetingVersion.java @@ -53,4 +53,7 @@ public class TargetingVersion extends AbstractAuditEntity implements TenantSuppo private Long version; + @Column(name = "approval_id") + private Long approvalId; + } diff --git a/src/main/java/com/featureprobe/api/mapper/ApprovalRecordMapper.java b/src/main/java/com/featureprobe/api/mapper/ApprovalRecordMapper.java new file mode 100644 index 0000000..5da5aef --- /dev/null +++ b/src/main/java/com/featureprobe/api/mapper/ApprovalRecordMapper.java @@ -0,0 +1,16 @@ +package com.featureprobe.api.mapper; + +import com.featureprobe.api.dto.ApprovalRecordResponse; +import com.featureprobe.api.entity.ApprovalRecord; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface ApprovalRecordMapper extends BaseMapper { + + ApprovalRecordMapper INSTANCE = Mappers.getMapper(ApprovalRecordMapper.class); + + @Mapping(target = "reviewers", ignore = true) + ApprovalRecordResponse entityToResponse(ApprovalRecord approvalRecord); +} diff --git a/src/main/java/com/featureprobe/api/mapper/EnvironmentMapper.java b/src/main/java/com/featureprobe/api/mapper/EnvironmentMapper.java index e5bd8f3..4d4bea9 100644 --- a/src/main/java/com/featureprobe/api/mapper/EnvironmentMapper.java +++ b/src/main/java/com/featureprobe/api/mapper/EnvironmentMapper.java @@ -1,15 +1,20 @@ package com.featureprobe.api.mapper; +import com.featureprobe.api.dto.ApprovalSettings; import com.featureprobe.api.dto.EnvironmentCreateRequest; import com.featureprobe.api.dto.EnvironmentResponse; import com.featureprobe.api.dto.EnvironmentUpdateRequest; import com.featureprobe.api.entity.Environment; +import com.featureprobe.api.model.Variation; import org.mapstruct.BeanMapping; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; import org.mapstruct.NullValuePropertyMappingStrategy; import org.mapstruct.factory.Mappers; +import java.util.List; + @Mapper public interface EnvironmentMapper { @@ -19,6 +24,24 @@ public interface EnvironmentMapper { Environment requestToEntity(EnvironmentCreateRequest createRequest); + @Mapping(target = "environmentKey", source = "key") + @Mapping(target = "enable", source = "enableApproval") + @Mapping(target = "reviewers", expression = "java(toReviewerList(environment.getReviewers()))") + ApprovalSettings entityToApprovalSettings(Environment environment); + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) void mapEntity(EnvironmentUpdateRequest updateRequest, @MappingTarget Environment environment); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + @Mapping(target = "enableApproval", source = "enable") + @Mapping(target = "reviewers", expression = "java(toReviewers(approvalSettings.getReviewers()))") + void mapEntity(ApprovalSettings approvalSettings, @MappingTarget Environment environment); + + default List toReviewerList(String reviewers) { + return JsonMapper.toListObject(reviewers, String.class); + } + + default String toReviewers(List reviewers) { + return JsonMapper.toJSONString(reviewers); + } } diff --git a/src/main/java/com/featureprobe/api/model/BaseRule.java b/src/main/java/com/featureprobe/api/model/BaseRule.java index 3f24822..f00ec56 100644 --- a/src/main/java/com/featureprobe/api/model/BaseRule.java +++ b/src/main/java/com/featureprobe/api/model/BaseRule.java @@ -1,5 +1,6 @@ package com.featureprobe.api.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import org.apache.commons.collections4.CollectionUtils; @@ -10,6 +11,7 @@ public class BaseRule { private List conditions; + @JsonIgnore public boolean isNotEmptyConditions() { return CollectionUtils.isNotEmpty(conditions); } diff --git a/src/main/java/com/featureprobe/api/model/ConditionValue.java b/src/main/java/com/featureprobe/api/model/ConditionValue.java index d744723..b3f2152 100644 --- a/src/main/java/com/featureprobe/api/model/ConditionValue.java +++ b/src/main/java/com/featureprobe/api/model/ConditionValue.java @@ -1,5 +1,6 @@ package com.featureprobe.api.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.featureprobe.sdk.server.model.Condition; import com.featureprobe.sdk.server.model.ConditionType; import com.featureprobe.sdk.server.model.PredicateType; @@ -26,18 +27,22 @@ public Condition toCondition() { return condition; } + @JsonIgnore public boolean isSegmentType() { return StringUtils.equals(ConditionType.SEGMENT.toValue(), type); } + @JsonIgnore public boolean isNumberType() { return StringUtils.equals(ConditionType.NUMBER.toValue(), type); } + @JsonIgnore public boolean isDatetimeType() { return StringUtils.equals(ConditionType.DATETIME.toValue(), type); } + @JsonIgnore public boolean isSemVerType() { return StringUtils.equals(ConditionType.SEMVER.toValue(), type); } diff --git a/src/main/java/com/featureprobe/api/repository/ApprovalRecordRepository.java b/src/main/java/com/featureprobe/api/repository/ApprovalRecordRepository.java new file mode 100644 index 0000000..fc601bb --- /dev/null +++ b/src/main/java/com/featureprobe/api/repository/ApprovalRecordRepository.java @@ -0,0 +1,17 @@ +package com.featureprobe.api.repository; + +import com.featureprobe.api.entity.ApprovalRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ApprovalRecordRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByProjectKeyAndEnvironmentKeyAndToggleKey(String projectKey, String environmentKey, + String toggleKey); + +} diff --git a/src/main/java/com/featureprobe/api/repository/TargetingSketchRepository.java b/src/main/java/com/featureprobe/api/repository/TargetingSketchRepository.java new file mode 100644 index 0000000..c299773 --- /dev/null +++ b/src/main/java/com/featureprobe/api/repository/TargetingSketchRepository.java @@ -0,0 +1,20 @@ +package com.featureprobe.api.repository; + +import com.featureprobe.api.base.enums.SketchStatusEnum; +import com.featureprobe.api.entity.TargetingSketch; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TargetingSketchRepository extends JpaRepository, + JpaSpecificationExecutor { + + List findAllByStatus(SketchStatusEnum status); + + Optional findByApprovalId(Long approvalId); + +} diff --git a/src/main/java/com/featureprobe/api/repository/ToggleRepository.java b/src/main/java/com/featureprobe/api/repository/ToggleRepository.java index 34d9a1a..13fe4b9 100644 --- a/src/main/java/com/featureprobe/api/repository/ToggleRepository.java +++ b/src/main/java/com/featureprobe/api/repository/ToggleRepository.java @@ -28,4 +28,6 @@ public interface ToggleRepository extends JpaRepository, JpaSpecif boolean existsByProjectKeyAndName(String projectKey, String key); + List findByNameLike(String name); + } diff --git a/src/main/java/com/featureprobe/api/service/ApprovalRecordService.java b/src/main/java/com/featureprobe/api/service/ApprovalRecordService.java new file mode 100644 index 0000000..65ca893 --- /dev/null +++ b/src/main/java/com/featureprobe/api/service/ApprovalRecordService.java @@ -0,0 +1,159 @@ +package com.featureprobe.api.service; + +import com.featureprobe.api.auth.TokenHelper; +import com.featureprobe.api.base.enums.ApprovalStatusEnum; +import com.featureprobe.api.base.enums.ApprovalTypeEnum; +import com.featureprobe.api.base.enums.ResourceType; +import com.featureprobe.api.base.enums.SketchStatusEnum; +import com.featureprobe.api.base.exception.ResourceNotFoundException; +import com.featureprobe.api.dto.ApprovalRecordQueryRequest; +import com.featureprobe.api.dto.ApprovalRecordResponse; +import com.featureprobe.api.entity.ApprovalRecord; +import com.featureprobe.api.entity.Environment; +import com.featureprobe.api.entity.Project; +import com.featureprobe.api.entity.TargetingSketch; +import com.featureprobe.api.entity.Toggle; +import com.featureprobe.api.mapper.ApprovalRecordMapper; +import com.featureprobe.api.mapper.JsonMapper; +import com.featureprobe.api.repository.ApprovalRecordRepository; +import com.featureprobe.api.repository.EnvironmentRepository; +import com.featureprobe.api.repository.ProjectRepository; +import com.featureprobe.api.repository.TargetingSketchRepository; +import com.featureprobe.api.repository.ToggleRepository; +import com.featureprobe.api.util.PageRequestUtil; +import lombok.AllArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import javax.persistence.criteria.Predicate; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@AllArgsConstructor +@Service +public class ApprovalRecordService { + + private ProjectRepository projectRepository; + private EnvironmentRepository environmentRepository; + private ToggleRepository toggleRepository; + private ApprovalRecordRepository approvalRecordRepository; + private TargetingSketchRepository targetingSketchRepository; + + @PersistenceContext + public EntityManager entityManager; + + public Page list(ApprovalRecordQueryRequest queryRequest) { + Specification spec = buildListSpec(queryRequest); + Pageable pageable = PageRequestUtil.toPageable(queryRequest, Sort.Direction.DESC, "createdTime"); + Page approvalRecords = approvalRecordRepository.findAll(spec, pageable); + Page res = approvalRecords.map(approvalRecord -> translateResponse(approvalRecord)); + List sortedRes = res.getContent().stream() + .sorted(Comparator.comparing(ApprovalRecordResponse::isLocked).reversed()).collect(Collectors.toList()); + return new PageImpl<>(sortedRes, pageable, res.getTotalElements()); + } + + private Specification buildListSpec(ApprovalRecordQueryRequest queryRequest) { + return (root, query, cb) -> { + Predicate p2 = cb.equal(root.get("submitBy"), TokenHelper.getAccount()); + Predicate p3 = cb.like(root.get("reviewers"), "%\"" + TokenHelper.getAccount() + "\"%"); + Predicate statusPredicate; + if (isTargetingSketchStatus(queryRequest.getStatus())) { + List targetingSketches = targetingSketchRepository + .findAllByStatus(SketchStatusEnum.forValue(queryRequest.getStatus())); + Set approvalIds = targetingSketches.stream() + .map(TargetingSketch::getApprovalId).collect(Collectors.toSet()); + Predicate p4 = root.get("id").in(approvalIds); + Predicate p5 = cb.equal(root.get("status"), ApprovalStatusEnum.PASS); + if(StringUtils.equals(SketchStatusEnum.CANCEL.name(), queryRequest.getStatus())) { + if (queryRequest.getType() == ApprovalTypeEnum.APPLY) { + statusPredicate = cb.and(p2, p4, p5); + } else { + statusPredicate = cb.and(p3, p4, p5); + } + } else { + if (queryRequest.getType() == ApprovalTypeEnum.APPLY) { + statusPredicate = cb.and(p2, p4); + } else { + statusPredicate = cb.and(p3, p4); + } + } + } else { + Predicate p1 = cb.equal(root.get("status"), ApprovalStatusEnum.forValue(queryRequest.getStatus())); + if (queryRequest.getType() == ApprovalTypeEnum.APPLY) { + statusPredicate = cb.and(p1, p2); + } else { + statusPredicate = cb.and(p1, p3); + } + } + if (StringUtils.isNotBlank(queryRequest.getKeyword())) { + List toggles = toggleRepository.findByNameLike(queryRequest.getKeyword()); + Set toggleKeys = toggles.stream().map(Toggle::getKey).collect(Collectors.toSet()); + Predicate p4 = root.get("toggleKey").in(toggleKeys); + Predicate p5 = cb.like(root.get("title"), "%" + queryRequest.getKeyword() + "%"); + query.where(statusPredicate, cb.or(p4, p5)); + } else { + query.where(statusPredicate); + } + return query.getRestriction(); + }; + } + + private boolean isTargetingSketchStatus(String status) { + return StringUtils.equals(status, SketchStatusEnum.RELEASE.name()) || + StringUtils.equals(status, SketchStatusEnum.CANCEL.name()); + } + + + private ApprovalRecordResponse translateResponse(ApprovalRecord approvalRecord) { + ApprovalRecordResponse approvalRecordResponse = ApprovalRecordMapper.INSTANCE.entityToResponse(approvalRecord); + approvalRecordResponse.setProjectName(selectApprovalRecordProject(approvalRecord).getName()); + approvalRecordResponse.setEnvironmentName(selectApprovalRecordEnvironment(approvalRecord).getName()); + approvalRecordResponse.setToggleName(selectApprovalRecordToggle(approvalRecord).getName()); + approvalRecordResponse.setReviewers(JsonMapper.toListObject(approvalRecord.getReviewers(), String.class)); + approvalRecordResponse.setComment(approvalRecord.getComment()); + approvalRecordResponse.setApprovalTime(approvalRecord.getModifiedTime()); + Optional targetingSketch = targetingSketchRepository.findByApprovalId(approvalRecord.getId()); + approvalRecordResponse.setSketchTime(targetingSketch.get().getModifiedTime()); + if(ApprovalStatusEnum.REJECT == approvalRecord.getStatus()) { + approvalRecordResponse.setCancelTime(targetingSketch.get().getModifiedTime()); + } + if (locked(targetingSketch.get())) { + approvalRecordResponse.setLocked(true); + approvalRecordResponse.setLockedTime(targetingSketch.get().getCreatedTime()); + } + return approvalRecordResponse; + } + + private boolean locked(TargetingSketch targetingSketch) { + return targetingSketch.getStatus() == SketchStatusEnum.PENDING; + } + + private Project selectApprovalRecordProject(ApprovalRecord approvalRecord) { + return projectRepository.findByKey(approvalRecord.getProjectKey()).orElseThrow(() -> + new ResourceNotFoundException(ResourceType.PROJECT, approvalRecord.getProjectKey())); + } + + private Environment selectApprovalRecordEnvironment(ApprovalRecord approvalRecord) { + return environmentRepository.findByProjectKeyAndKey(approvalRecord.getProjectKey(), + approvalRecord.getEnvironmentKey()).orElseThrow(() -> + new ResourceNotFoundException(ResourceType.ENVIRONMENT, + approvalRecord.getProjectKey() + "-" + approvalRecord.getEnvironmentKey())); + } + + private Toggle selectApprovalRecordToggle(ApprovalRecord approvalRecord) { + return toggleRepository.findByProjectKeyAndKey(approvalRecord.getProjectKey(), + approvalRecord.getToggleKey()).orElseThrow(() -> + new ResourceNotFoundException(ResourceType.TOGGLE, + approvalRecord.getProjectKey() + "-" + approvalRecord.getToggleKey())); + } + +} diff --git a/src/main/java/com/featureprobe/api/service/DictionaryService.java b/src/main/java/com/featureprobe/api/service/DictionaryService.java index 84bbad2..02121ee 100644 --- a/src/main/java/com/featureprobe/api/service/DictionaryService.java +++ b/src/main/java/com/featureprobe/api/service/DictionaryService.java @@ -24,7 +24,7 @@ public class DictionaryService { private DictionaryRepository dictionaryRepository; - public DictionaryResponse save(String key, String value) { + public DictionaryResponse create(String key, String value) { Optional dictionaryOptional = dictionaryRepository .findByAccountAndKey(TokenHelper.getAccount(), key); if(dictionaryOptional.isPresent()) { diff --git a/src/main/java/com/featureprobe/api/service/ProjectService.java b/src/main/java/com/featureprobe/api/service/ProjectService.java index 846ff17..a127364 100644 --- a/src/main/java/com/featureprobe/api/service/ProjectService.java +++ b/src/main/java/com/featureprobe/api/service/ProjectService.java @@ -6,12 +6,15 @@ import com.featureprobe.api.base.exception.ResourceConflictException; import com.featureprobe.api.base.exception.ResourceNotFoundException; import com.featureprobe.api.base.exception.ResourceOverflowException; +import com.featureprobe.api.dto.ApprovalSettings; +import com.featureprobe.api.dto.PreferenceCreateRequest; import com.featureprobe.api.dto.ProjectCreateRequest; import com.featureprobe.api.dto.ProjectQueryRequest; import com.featureprobe.api.dto.ProjectResponse; import com.featureprobe.api.dto.ProjectUpdateRequest; import com.featureprobe.api.entity.Environment; import com.featureprobe.api.entity.Project; +import com.featureprobe.api.mapper.EnvironmentMapper; import com.featureprobe.api.mapper.ProjectMapper; import com.featureprobe.api.repository.EnvironmentRepository; import com.featureprobe.api.repository.ProjectRepository; @@ -20,6 +23,7 @@ import com.featureprobe.sdk.server.FeatureProbe; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; @@ -28,6 +32,8 @@ import javax.persistence.PersistenceContext; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; @Slf4j @@ -70,6 +76,24 @@ public ProjectResponse update(String projectKey, ProjectUpdateRequest updateRequ return ProjectMapper.INSTANCE.entityToResponse(projectRepository.save(project)); } + @Transactional(rollbackFor = Exception.class) + public void createPreference(String projectKey, PreferenceCreateRequest createRequest) { + Map approvalSettingsMap = createRequest.getApprovalSettings().stream() + .collect(Collectors.toMap(ApprovalSettings::getEnvironmentKey, Function.identity())); + if (CollectionUtils.isNotEmpty(createRequest.getApprovalSettings())) { + List environments = environmentRepository.findAllByProjectKey(projectKey); + environments.stream().forEach(environment -> EnvironmentMapper.INSTANCE + .mapEntity(approvalSettingsMap.get(environment.getKey()), environment)); + environmentRepository.saveAll(environments); + } + } + + public List approvalSettingsList(String projectKey) { + List environments = environmentRepository.findAllByProjectKey(projectKey); + return environments.stream().map(environment -> + EnvironmentMapper.INSTANCE.entityToApprovalSettings(environment)).collect(Collectors.toList()); + } + private void archiveAllEnvironments(List environments) { for(Environment environment : environments) { environment.setArchived(true); diff --git a/src/main/java/com/featureprobe/api/service/SegmentService.java b/src/main/java/com/featureprobe/api/service/SegmentService.java index 804489f..137d51b 100644 --- a/src/main/java/com/featureprobe/api/service/SegmentService.java +++ b/src/main/java/com/featureprobe/api/service/SegmentService.java @@ -169,24 +169,4 @@ public void validateName(String projectKey, String name) { } } - public Page findAllByKeyword(String projectKey, String keyword, Pageable pageable) { - Specification spec = buildQueryKeywordSpec(projectKey, keyword); - Page segments = segmentRepository.findAll(spec, pageable); - return segments.map(segment -> SegmentMapper.INSTANCE.entityToResponse(segment)); - } - - private Specification buildQueryKeywordSpec(String projectKey, String keyword) { - return (root, query, cb) -> { - Predicate p3 = cb.equal(root.get("projectKey"), projectKey); - if (StringUtils.isNotBlank(keyword)) { - Predicate p0 = cb.like(root.get("name"), "%" + keyword + "%"); - Predicate p1 = cb.like(root.get("key"), "%" + keyword + "%"); - Predicate p2 = cb.like(root.get("description"), "%" + keyword + "%"); - query.where(cb.or(p0, p1, p2), cb.and(p3)); - } else { - query.where(p3); - } - return query.getRestriction(); - }; - } } diff --git a/src/main/java/com/featureprobe/api/service/TargetingService.java b/src/main/java/com/featureprobe/api/service/TargetingService.java index 0dec327..d3ee43f 100644 --- a/src/main/java/com/featureprobe/api/service/TargetingService.java +++ b/src/main/java/com/featureprobe/api/service/TargetingService.java @@ -1,16 +1,26 @@ package com.featureprobe.api.service; +import com.featureprobe.api.auth.TokenHelper; +import com.featureprobe.api.base.enums.ApprovalStatusEnum; import com.featureprobe.api.base.enums.ResourceType; +import com.featureprobe.api.base.enums.SketchStatusEnum; import com.featureprobe.api.base.exception.ResourceNotFoundException; import com.featureprobe.api.dto.AfterTargetingVersionResponse; +import com.featureprobe.api.dto.PaginationRequest; +import com.featureprobe.api.dto.TargetingDiffResponse; import com.featureprobe.api.dto.TargetingRequest; import com.featureprobe.api.dto.TargetingResponse; import com.featureprobe.api.dto.TargetingVersionRequest; import com.featureprobe.api.dto.TargetingVersionResponse; +import com.featureprobe.api.dto.UpdateApprovalStatusRequest; +import com.featureprobe.api.entity.ApprovalRecord; +import com.featureprobe.api.entity.Environment; import com.featureprobe.api.entity.Targeting; import com.featureprobe.api.entity.TargetingSegment; +import com.featureprobe.api.entity.TargetingSketch; import com.featureprobe.api.entity.TargetingVersion; import com.featureprobe.api.entity.VariationHistory; +import com.featureprobe.api.mapper.JsonMapper; import com.featureprobe.api.mapper.TargetingMapper; import com.featureprobe.api.mapper.TargetingVersionMapper; import com.featureprobe.api.model.BaseRule; @@ -18,23 +28,31 @@ import com.featureprobe.api.model.TargetingContent; import com.featureprobe.api.model.ToggleRule; import com.featureprobe.api.model.Variation; +import com.featureprobe.api.repository.ApprovalRecordRepository; +import com.featureprobe.api.repository.EnvironmentRepository; import com.featureprobe.api.repository.SegmentRepository; import com.featureprobe.api.repository.TargetingRepository; import com.featureprobe.api.repository.TargetingSegmentRepository; +import com.featureprobe.api.repository.TargetingSketchRepository; import com.featureprobe.api.repository.TargetingVersionRepository; import com.featureprobe.api.repository.VariationHistoryRepository; import com.featureprobe.api.util.PageRequestUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; - import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; +import javax.persistence.criteria.Predicate; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeSet; import java.util.regex.Pattern; @@ -63,50 +81,104 @@ public class TargetingService { private VariationHistoryRepository variationHistoryRepository; + private EnvironmentRepository environmentRepository; + + private ApprovalRecordRepository approvalRecordRepository; + + private TargetingSketchRepository targetingSketchRepository; + @PersistenceContext public EntityManager entityManager; @Transactional(rollbackFor = Exception.class) - public TargetingResponse update(String projectKey, String environmentKey, - String toggleKey, TargetingRequest targetingRequest) { + public TargetingResponse update(String projectKey, String environmentKey, String toggleKey, + TargetingRequest targetingRequest) { validateTargetingContent(projectKey, targetingRequest.getContent()); - Targeting existedTargeting = targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, - environmentKey, toggleKey).get(); - long oldVersion = existedTargeting.getVersion(); - Targeting updatedTargeting = updateTargeting(existedTargeting, targetingRequest); - if (updatedTargeting.getVersion() > oldVersion) { - saveTargetingSegmentRefs(projectKey, updatedTargeting, targetingRequest.getContent()); - saveTargetingVersion(buildTargetingVersion(updatedTargeting, targetingRequest.getComment())); - saveVariationHistory(updatedTargeting, targetingRequest.getContent()); + Environment environment = selectEnvironment(projectKey, environmentKey); + if (environment.isEnableApproval()) { + List reviews = JsonMapper.toListObject(environment.getReviewers(), String.class); + targetingRequest.setReviewers(reviews); + return submitApproval(projectKey, environmentKey, toggleKey, targetingRequest); } - return TargetingMapper.INSTANCE.entityToResponse(updatedTargeting); + return publishTargeting(projectKey, environmentKey, toggleKey, targetingRequest, null); } - private Targeting updateTargeting(Targeting currentTargeting, TargetingRequest updateTargetingRequest) { - TargetingMapper.INSTANCE.mapEntity(updateTargetingRequest, currentTargeting); - return targetingRepository.saveAndFlush(currentTargeting); + public TargetingResponse publishSketch(String projectKey, String environmentKey, String toggleKey) { + Optional approvalRecordOptional = queryNewestApprovalRecord(projectKey, + environmentKey, toggleKey); + Optional targetingSketchOptional = queryNewestTargetingSketch(projectKey, environmentKey, + toggleKey); + if (approvalRecordOptional.isPresent() && targetingSketchOptional.isPresent() && + publishableStatus(approvalRecordOptional.get())) { + TargetingSketch sketch = targetingSketchOptional.get(); + sketch.setStatus(SketchStatusEnum.RELEASE); + targetingSketchRepository.save(sketch); + TargetingRequest targetingRequest = new TargetingRequest(JsonMapper.toObject(sketch.getContent(), + TargetingContent.class), sketch.getComment(), sketch.getDisabled(), null); + return publishTargeting(projectKey, environmentKey, toggleKey, targetingRequest, + approvalRecordOptional.get().getId()); + } + return null; } - private TargetingVersion buildTargetingVersion(Targeting targeting, String comment) { - TargetingVersion targetingVersion = new TargetingVersion(); - targetingVersion.setProjectKey(targeting.getProjectKey()); - targetingVersion.setEnvironmentKey(targeting.getEnvironmentKey()); - targetingVersion.setToggleKey(targeting.getToggleKey()); - targetingVersion.setContent(targeting.getContent()); - targetingVersion.setDisabled(targeting.getDisabled()); - targetingVersion.setVersion(targeting.getVersion()); - targetingVersion.setComment(comment); - return targetingVersion; + public void cancelSketch(String projectKey, String environmentKey, String toggleKey) { + Optional approvalRecordOptional = queryNewestApprovalRecord(projectKey, + environmentKey, toggleKey); + Optional targetingSketchOptional = queryNewestTargetingSketch(projectKey, environmentKey, + toggleKey); + if (approvalRecordOptional.isPresent() && targetingSketchOptional.isPresent()) { + TargetingSketch sketch = targetingSketchOptional.get(); + sketch.setStatus(SketchStatusEnum.CANCEL); + targetingSketchRepository.save(sketch); + } } - private void saveTargetingVersion(TargetingVersion targetingVersion) { - targetingVersionRepository.save(targetingVersion); + public void updateApprovalStatus(String projectKey, String environmentKey, String toggleKey, + UpdateApprovalStatusRequest updateRequest) { + Optional approvalRecordOptional = queryNewestApprovalRecord(projectKey, + environmentKey, toggleKey); + Optional targetingSketchOptional = queryNewestTargetingSketch(projectKey, environmentKey, + toggleKey); + if (approvalRecordOptional.isPresent() && targetingSketchOptional.isPresent() && + checkStateMachine(approvalRecordOptional.get(), updateRequest.getStatus())) { + if (updateRequest.getStatus() == ApprovalStatusEnum.REVOKE) { + TargetingSketch sketch = targetingSketchOptional.get(); + sketch.setStatus(SketchStatusEnum.REVOKE); + targetingSketchRepository.save(sketch); + } + ApprovalRecord approvalRecord = approvalRecordOptional.get(); + approvalRecord.setStatus(updateRequest.getStatus()); + approvalRecord.setComment(updateRequest.getComment()); + approvalRecord.setApprovedBy(TokenHelper.getAccount()); + approvalRecordRepository.saveAndFlush(approvalRecord); + if (updateRequest.getStatus() == ApprovalStatusEnum.JUMP) { + publishSketch(projectKey, environmentKey, toggleKey); + } + return; + } + throw new IllegalArgumentException(); + } + + private boolean checkStateMachine(ApprovalRecord approvalRecord, ApprovalStatusEnum status) { + switch (status) { + case PASS: + case REJECT: + return approvalRecord.getStatus() == ApprovalStatusEnum.PENDING && + JsonMapper.toListObject(approvalRecord.getReviewers(), String.class) + .contains(TokenHelper.getAccount()); + case REVOKE: + case JUMP: + return approvalRecord.getStatus() == ApprovalStatusEnum.PENDING && + StringUtils.equals(approvalRecord.getSubmitBy(), TokenHelper.getAccount()); + default: + return false; + } } public Page queryVersions(String projectKey, String environmentKey, String toggleKey, TargetingVersionRequest targetingVersionRequest) { - Page targetingVersions ; - if(Objects.isNull(targetingVersionRequest.getVersion())) { + Page targetingVersions; + if (Objects.isNull(targetingVersionRequest.getVersion())) { targetingVersions = targetingVersionRepository .findAllByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey, PageRequestUtil.toCreatedTimeDescSortPageable(targetingVersionRequest)); @@ -116,8 +188,7 @@ public Page queryVersions(String projectKey, String en projectKey, environmentKey, toggleKey, targetingVersionRequest.getVersion(), PageRequestUtil.toCreatedTimeDescSortPageable(targetingVersionRequest)); } - return targetingVersions.map(targetingVersion -> - TargetingVersionMapper.INSTANCE.entityToResponse(targetingVersion)); + return targetingVersions.map(targetingVersion -> translateTargetingVersionResponse(targetingVersion)); } public AfterTargetingVersionResponse queryAfterVersion(String projectKey, String environmentKey, String toggleKey, @@ -126,16 +197,157 @@ public AfterTargetingVersionResponse queryAfterVersion(String projectKey, String .findAllByProjectKeyAndEnvironmentKeyAndToggleKeyAndVersionGreaterThanEqualOrderByVersionDesc( projectKey, environmentKey, toggleKey, version); List versions = targetingVersions.stream().map(targetingVersion -> - TargetingVersionMapper.INSTANCE.entityToResponse(targetingVersion)).collect(Collectors.toList()); + translateTargetingVersionResponse(targetingVersion)).collect(Collectors.toList()); long total = targetingVersionRepository.countByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey); return new AfterTargetingVersionResponse(total, versions); } + private TargetingVersionResponse translateTargetingVersionResponse(TargetingVersion targetingVersion) { + TargetingVersionResponse targetingVersionResponse = TargetingVersionMapper.INSTANCE + .entityToResponse(targetingVersion); + if(Objects.nonNull(targetingVersion.getApprovalId())) { + Optional approvalRecord = approvalRecordRepository + .findById(targetingVersion.getApprovalId()); + targetingVersionResponse.setApprovalStatus(approvalRecord.get().getStatus()); + targetingVersionResponse.setApprovalTime(approvalRecord.get().getModifiedTime()); + targetingVersionResponse.setApprovalBy(approvalRecord.get().getApprovedBy()); + targetingVersionResponse.setApprovalComment(approvalRecord.get().getComment()); + } + return targetingVersionResponse; + } + + public TargetingDiffResponse diff(String projectKey, String environmentKey, String toggleKey) { + TargetingDiffResponse diffResponse = new TargetingDiffResponse(); + Optional targetingSketch = queryNewestTargetingSketch(projectKey, environmentKey, toggleKey); + Optional targeting = targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, + environmentKey, toggleKey); + if (targetingSketch.isPresent() && targeting.isPresent()) { + diffResponse.setCurrentDisabled(targetingSketch.get().getDisabled()); + diffResponse.setCurrentContent(JsonMapper.toObject(targetingSketch.get().getContent(), + TargetingContent.class)); + diffResponse.setOldDisabled(targeting.get().getDisabled()); + diffResponse.setOldContent(JsonMapper.toObject(targeting.get().getContent(), TargetingContent.class)); + } + return diffResponse; + } + + public TargetingResponse queryByKey(String projectKey, String environmentKey, String toggleKey) { + Environment environment = selectEnvironment(projectKey, environmentKey); + Targeting targeting = targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, + environmentKey, toggleKey).get(); + TargetingResponse targetingResponse = TargetingMapper.INSTANCE.entityToResponse(targeting); + Optional newestApprovalRecord = queryNewestApprovalRecord(projectKey, environmentKey, + toggleKey); + Optional targetingSketch = queryNewestTargetingSketch(projectKey, environmentKey, toggleKey); + if (newestApprovalRecord.isPresent() && targetingSketch.isPresent() && locked(targetingSketch.get())) { + targetingResponse.setContent(JsonMapper.toObject(targetingSketch.get().getContent(), + TargetingContent.class)); + targetingResponse.setDisabled(targetingSketch.get().getDisabled()); + targetingResponse.setVersion(targetingSketch.get().getOldVersion() + 1); + targetingResponse.setStatus(newestApprovalRecord.get().getStatus().name()); + targetingResponse.setReviewers(JsonMapper.toListObject(newestApprovalRecord.get().getReviewers(), + String.class)); + targetingResponse.setSubmitBy(newestApprovalRecord.get().getSubmitBy()); + targetingResponse.setApprovalBy(newestApprovalRecord.get().getApprovedBy()); + targetingResponse.setApprovalComment(newestApprovalRecord.get().getComment()); + targetingResponse.setLocked(true); + targetingResponse.setLockedTime(newestApprovalRecord.get().getCreatedTime()); + } else { + targetingResponse.setStatus(SketchStatusEnum.RELEASE.name()); + if (environment.isEnableApproval()) { + targetingResponse.setReviewers(JsonMapper.toListObject(environment.getReviewers(), String.class)); + } + } + targetingResponse.setEnableApproval(environment.isEnableApproval()); + return targetingResponse; + } + + private boolean locked(TargetingSketch targetingSketch) { + return targetingSketch.getStatus() == SketchStatusEnum.PENDING; + } + + private boolean publishableStatus(ApprovalRecord approvalRecord) { + return approvalRecord.getStatus() == ApprovalStatusEnum.JUMP || + approvalRecord.getStatus() == ApprovalStatusEnum.PASS; + } + + private TargetingResponse submitApproval(String projectKey, String environmentKey, String toggleKey, + TargetingRequest targetingRequest) { + Targeting targeting = selectTargeting(projectKey, environmentKey, toggleKey); + ApprovalRecord approvalRecord = approvalRecordRepository.save(buildApprovalRecord(projectKey, environmentKey, + toggleKey, targetingRequest)); + targetingSketchRepository.save(buildTargetingSketch(projectKey, environmentKey, toggleKey, + approvalRecord.getId(), targeting.getVersion(), targetingRequest)); + return TargetingMapper.INSTANCE.entityToResponse(targeting); + } + + private TargetingResponse publishTargeting(String projectKey, String environmentKey, String toggleKey, + TargetingRequest targetingRequest, Long approvalId) { + Targeting existedTargeting = selectTargeting(projectKey, environmentKey, toggleKey); + long oldVersion = existedTargeting.getVersion(); + Targeting updatedTargeting = updateTargeting(existedTargeting, targetingRequest); + if (updatedTargeting.getVersion() > oldVersion) { + saveTargetingSegmentRefs(projectKey, updatedTargeting, targetingRequest.getContent()); + saveTargetingVersion(buildTargetingVersion(updatedTargeting, targetingRequest.getComment(), approvalId)); + saveVariationHistory(updatedTargeting, targetingRequest.getContent()); + } + return TargetingMapper.INSTANCE.entityToResponse(updatedTargeting); + } + + private ApprovalRecord buildApprovalRecord(String projectKey, String environmentKey, String toggleKey, + TargetingRequest targetingRequest) { + ApprovalRecord approvalRecord = new ApprovalRecord(); + approvalRecord.setProjectKey(projectKey); + approvalRecord.setEnvironmentKey(environmentKey); + approvalRecord.setToggleKey(toggleKey); + approvalRecord.setTitle(targetingRequest.getComment()); + approvalRecord.setSubmitBy(TokenHelper.getAccount()); + approvalRecord.setReviewers(JsonMapper.toJSONString(targetingRequest.getReviewers())); + approvalRecord.setStatus(ApprovalStatusEnum.PENDING); + return approvalRecord; + } + + private TargetingSketch buildTargetingSketch(String projectKey, String environmentKey, String toggleKey, + Long approvalId, Long oldVersion, + TargetingRequest targetingRequest) { + TargetingSketch sketch = new TargetingSketch(); + sketch.setApprovalId(approvalId); + sketch.setProjectKey(projectKey); + sketch.setEnvironmentKey(environmentKey); + sketch.setToggleKey(toggleKey); + sketch.setOldVersion(oldVersion); + sketch.setContent(JsonMapper.toJSONString(targetingRequest.getContent())); + sketch.setComment(targetingRequest.getComment()); + sketch.setDisabled(targetingRequest.getDisabled()); + sketch.setStatus(SketchStatusEnum.PENDING); + return sketch; + } + + private Targeting updateTargeting(Targeting currentTargeting, TargetingRequest updateTargetingRequest) { + TargetingMapper.INSTANCE.mapEntity(updateTargetingRequest, currentTargeting); + return targetingRepository.saveAndFlush(currentTargeting); + } + + private TargetingVersion buildTargetingVersion(Targeting targeting, String comment, Long approvalId) { + TargetingVersion targetingVersion = new TargetingVersion(); + targetingVersion.setProjectKey(targeting.getProjectKey()); + targetingVersion.setEnvironmentKey(targeting.getEnvironmentKey()); + targetingVersion.setToggleKey(targeting.getToggleKey()); + targetingVersion.setContent(targeting.getContent()); + targetingVersion.setDisabled(targeting.getDisabled()); + targetingVersion.setVersion(targeting.getVersion()); + targetingVersion.setComment(comment); + targetingVersion.setApprovalId(approvalId); + return targetingVersion; + } + + private void saveTargetingVersion(TargetingVersion targetingVersion) { + targetingVersionRepository.save(targetingVersion); + } private void saveTargetingSegmentRefs(String projectKey, Targeting targeting, TargetingContent targetingContent) { targetingSegmentRepository.deleteByTargetingId(targeting.getId()); - List targetingSegmentList = getTargetingSegments(projectKey, targeting, targetingContent); if (!CollectionUtils.isEmpty(targetingSegmentList)) { targetingSegmentRepository.saveAll(targetingSegmentList); @@ -157,7 +369,6 @@ private List getTargetingSegments(String projectKey, Targeting private void saveVariationHistory(Targeting targeting, TargetingContent targetingContent) { List variations = targetingContent.getVariations(); - List variationHistories = IntStream.range(0, targetingContent .getVariations().size()) .mapToObj(index -> convertVariationToEntity(targeting, index, @@ -178,10 +389,38 @@ private VariationHistory convertVariationToEntity(Targeting targeting, int index return variationHistory; } - public TargetingResponse queryByKey(String projectKey, String environmentKey, String toggleKey) { - Targeting targeting = targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, - environmentKey, toggleKey).get(); - return TargetingMapper.INSTANCE.entityToResponse(targeting); + private Optional queryNewestApprovalRecord(String projectKey, String environmentKey, + String toggleKey) { + Specification spec = (root, query, cb) -> { + Predicate p1 = cb.equal(root.get("projectKey"), projectKey); + Predicate p2 = cb.equal(root.get("environmentKey"), environmentKey); + Predicate p3 = cb.equal(root.get("toggleKey"), toggleKey); + return query.where(p1, p2, p3).getRestriction(); + }; + Pageable pageable = PageRequestUtil.toPageable(new PaginationRequest(), Sort.Direction.DESC, + "createdTime"); + Page approvalRecords = approvalRecordRepository.findAll(spec, pageable); + if (CollectionUtils.isEmpty(approvalRecords.getContent())) { + return Optional.empty(); + } + return Optional.of(approvalRecords.getContent().get(0)); + } + + private Optional queryNewestTargetingSketch(String projectKey, String environmentKey, + String toggleKey) { + Specification spec = (root, query, cb) -> { + Predicate p1 = cb.equal(root.get("projectKey"), projectKey); + Predicate p2 = cb.equal(root.get("environmentKey"), environmentKey); + Predicate p3 = cb.equal(root.get("toggleKey"), toggleKey); + return query.where(p1, p2, p3).getRestriction(); + }; + Pageable pageable = PageRequestUtil.toPageable(new PaginationRequest(), Sort.Direction.DESC, + "createdTime"); + Page targetingSketches = targetingSketchRepository.findAll(spec, pageable); + if (CollectionUtils.isEmpty(targetingSketches.getContent())) { + return Optional.empty(); + } + return Optional.of(targetingSketches.getContent().get(0)); } private void validateTargetingContent(String projectKey, TargetingContent content) { @@ -237,4 +476,14 @@ private void validateSemVer(ToggleRule toggleRule) { })); } + private Environment selectEnvironment(String projectKey, String environmentKey) { + return environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey).orElseThrow(() -> + new ResourceNotFoundException(ResourceType.ENVIRONMENT, projectKey + "-" + environmentKey)); + } + + private Targeting selectTargeting(String projectKey, String environmentKey, String toggleKey) { + return targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, + toggleKey).orElseThrow(() -> new ResourceNotFoundException(ResourceType.TARGETING, + projectKey + "-" + environmentKey + "-" + toggleKey)); + } } diff --git a/src/main/java/com/featureprobe/api/service/ToggleService.java b/src/main/java/com/featureprobe/api/service/ToggleService.java index a985524..adb23f9 100644 --- a/src/main/java/com/featureprobe/api/service/ToggleService.java +++ b/src/main/java/com/featureprobe/api/service/ToggleService.java @@ -2,19 +2,23 @@ import com.featureprobe.api.auth.TokenHelper; import com.featureprobe.api.base.enums.ResourceType; +import com.featureprobe.api.base.enums.SketchStatusEnum; import com.featureprobe.api.base.enums.VisitFilter; import com.featureprobe.api.base.exception.ResourceConflictException; import com.featureprobe.api.base.exception.ResourceNotFoundException; import com.featureprobe.api.base.exception.ResourceOverflowException; +import com.featureprobe.api.dto.PaginationRequest; import com.featureprobe.api.dto.ToggleCreateRequest; import com.featureprobe.api.dto.ToggleItemResponse; import com.featureprobe.api.dto.ToggleResponse; import com.featureprobe.api.dto.ToggleSearchRequest; import com.featureprobe.api.dto.ToggleUpdateRequest; +import com.featureprobe.api.entity.ApprovalRecord; import com.featureprobe.api.entity.Environment; import com.featureprobe.api.entity.Event; import com.featureprobe.api.entity.Tag; import com.featureprobe.api.entity.Targeting; +import com.featureprobe.api.entity.TargetingSketch; import com.featureprobe.api.entity.TargetingVersion; import com.featureprobe.api.entity.Toggle; import com.featureprobe.api.entity.VariationHistory; @@ -22,10 +26,12 @@ import com.featureprobe.api.mapper.ToggleMapper; import com.featureprobe.api.model.TargetingContent; import com.featureprobe.api.model.Variation; +import com.featureprobe.api.repository.ApprovalRecordRepository; import com.featureprobe.api.repository.EnvironmentRepository; import com.featureprobe.api.repository.EventRepository; import com.featureprobe.api.repository.TagRepository; import com.featureprobe.api.repository.TargetingRepository; +import com.featureprobe.api.repository.TargetingSketchRepository; import com.featureprobe.api.repository.TargetingVersionRepository; import com.featureprobe.api.repository.ToggleRepository; import com.featureprobe.api.repository.VariationHistoryRepository; @@ -56,6 +62,7 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; @@ -80,6 +87,8 @@ public class ToggleService { private VariationHistoryRepository variationHistoryRepository; + private TargetingSketchRepository targetingSketchRepository; + private FeatureProbe featureProbe; @PersistenceContext @@ -175,7 +184,6 @@ private Targeting createDefaultTargeting(Toggle toggle, Environment environment) targeting.setContent(TargetingContent.newDefault(toggle).toJson()); targeting.setToggleKey(toggle.getKey()); targeting.setEnvironmentKey(environment.getKey()); - return targeting; } @@ -215,7 +223,7 @@ private void setToggleTagRefs(Toggle toggle, List tagNames) { } @Archived - public Page query(String projectKey, ToggleSearchRequest searchRequest) { + public Page list(String projectKey, ToggleSearchRequest searchRequest) { Page togglePage; if (StringUtils.isNotBlank(searchRequest.getEnvironmentKey())) { Environment environment = environmentRepository @@ -370,9 +378,37 @@ private ToggleItemResponse entityToItemResponse(Toggle toggle, String projectKey environmentKey, toggle.getKey()).get(); toggleItem.setDisabled(targeting.getDisabled()); toggleItem.setVisitedTime(queryToggleVisited(projectKey, environmentKey, toggle.getKey())); + Optional targetingSketchOptional = queryNewestTargetingSketch(projectKey, environmentKey, + toggle.getKey()); + if (targetingSketchOptional.isPresent()) { + toggleItem.setLocked(locked(targetingSketchOptional.get())); + toggleItem.setLockedBy(targetingSketchOptional.get().getCreatedBy().getAccount()); + toggleItem.setLockedTime(targetingSketchOptional.get().getCreatedTime()); + } return toggleItem; } + private boolean locked(TargetingSketch targetingSketch) { + return targetingSketch.getStatus() == SketchStatusEnum.PENDING; + } + + private Optional queryNewestTargetingSketch(String projectKey, String environmentKey, + String toggleKey) { + Specification spec = (root, query, cb) -> { + Predicate p1 = cb.equal(root.get("projectKey"), projectKey); + Predicate p2 = cb.equal(root.get("environmentKey"), environmentKey); + Predicate p3 = cb.equal(root.get("toggleKey"), toggleKey); + return query.where(p1, p2, p3).getRestriction(); + }; + Pageable pageable = PageRequestUtil.toPageable(new PaginationRequest(), Sort.Direction.DESC, + "createdTime"); + Page targetingSketches = targetingSketchRepository.findAll(spec, pageable); + if (org.springframework.util.CollectionUtils.isEmpty(targetingSketches.getContent())) { + return Optional.empty(); + } + return Optional.of(targetingSketches.getContent().get(0)); + } + private Date queryToggleVisited(String projectKey, String environmentKey, String toggleKey) { Environment environment = environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey).get(); Pageable pageable = PageRequest.of(0, 1, diff --git a/src/main/resources/db/migration/V30__add_approval_schema.sql b/src/main/resources/db/migration/V30__add_approval_schema.sql new file mode 100644 index 0000000..ec4783a --- /dev/null +++ b/src/main/resources/db/migration/V30__add_approval_schema.sql @@ -0,0 +1,44 @@ +create table approval_record +( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `organization_id` bigint NOT null DEFAULT 0, + `project_key` varchar(128) NOT NULL DEFAULT '', + `environment_key` varchar(128) NOT NULL DEFAULT '', + `toggle_key` varchar(128) NOT NULL DEFAULT '', + `submit_by` varchar(128) NOT NULL DEFAULT '', + `approved_by` varchar(128) NOT NULL DEFAULT '', + `reviewers` varchar(256) NOT NULL DEFAULT '', + `status` varchar(64) NOT NULL DEFAULT '', + `title` varchar(1024) NOT NULL DEFAULT '', + `comment` TEXT, + `modified_by` bigint(20) NOT NULL DEFAULT 0, + `created_by` bigint(20) NOT NULL DEFAULT 0, + `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +)ENGINE=InnoDB collate = utf8mb4_unicode_ci; + +create table targeting_sketch ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `approval_id` bigint NOT null DEFAULT 0, + `organization_id` bigint NOT null DEFAULT 0, + `project_key` varchar(128) NOT NULL DEFAULT '', + `environment_key` varchar(128) NOT NULL DEFAULT '', + `toggle_key` varchar(128) NOT NULL DEFAULT '', + `old_version` bigint default 0 not null, + `content` text, + `comment` varchar(1024) default '' not null, + `disabled` tinyint default 1 not null, + `status` varchar(64) NOT NULL DEFAULT '', + `modified_by` bigint(20) NOT NULL DEFAULT 0, + `created_by` bigint(20) NOT NULL DEFAULT 0, + `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +)ENGINE=InnoDB collate = utf8mb4_unicode_ci; + +alter table environment add enable_approval tinyint default 0 not null after deleted; +alter table environment add reviewers VARCHAR(256) default '' not null after deleted; +alter table targeting_version add approval_id bigint; + + diff --git a/src/test/groovy/com/featureprobe/api/service/ApprovalRecordServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/ApprovalRecordServiceSpec.groovy new file mode 100644 index 0000000..eace81d --- /dev/null +++ b/src/test/groovy/com/featureprobe/api/service/ApprovalRecordServiceSpec.groovy @@ -0,0 +1,60 @@ +package com.featureprobe.api.service + +import com.featureprobe.api.base.enums.ApprovalStatusEnum +import com.featureprobe.api.base.enums.ApprovalTypeEnum +import com.featureprobe.api.dto.ApprovalRecordQueryRequest +import com.featureprobe.api.entity.ApprovalRecord +import com.featureprobe.api.entity.Environment +import com.featureprobe.api.entity.Project +import com.featureprobe.api.entity.TargetingSketch +import com.featureprobe.api.entity.Toggle +import com.featureprobe.api.repository.ApprovalRecordRepository +import com.featureprobe.api.repository.EnvironmentRepository +import com.featureprobe.api.repository.ProjectRepository +import com.featureprobe.api.repository.TargetingSketchRepository +import com.featureprobe.api.repository.ToggleRepository +import org.hibernate.internal.SessionImpl +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import spock.lang.Specification + +import javax.persistence.EntityManager + +class ApprovalRecordServiceSpec extends Specification { + + ApprovalRecordService approvalRecordService + ProjectRepository projectRepository + EnvironmentRepository environmentRepository + ToggleRepository toggleRepository + ApprovalRecordRepository approvalRecordRepository + TargetingSketchRepository targetingSketchRepository + ApprovalRecord approvalRecord + EntityManager entityManager + + def setup() { + projectRepository = Mock(ProjectRepository) + environmentRepository = Mock(EnvironmentRepository) + toggleRepository = Mock(ToggleRepository) + approvalRecordRepository = Mock(ApprovalRecordRepository) + targetingSketchRepository = Mock(TargetingSketchRepository) + entityManager = Mock(SessionImpl) + approvalRecordService = new ApprovalRecordService(projectRepository, environmentRepository, toggleRepository, + approvalRecordRepository, targetingSketchRepository, entityManager) + approvalRecord = new ApprovalRecord(id: 1, organizationId: -1, projectKey: "projectKey", + environmentKey: "environmentKey", toggleKey: "toggleKey", submitBy: "Admin", approvedBy: "Test", reviewers: "[\"manager\"]", status: ApprovalStatusEnum.PENDING, title: "title") + } + + def "Query approval record list"() { + when: + def list = approvalRecordService.list(new ApprovalRecordQueryRequest(keyword: "test", status: "PENDING", type: ApprovalTypeEnum.APPLY)) + then: + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl<>([approvalRecord], Pageable.ofSize(1), 1) + 1 * projectRepository.findByKey("projectKey") >> Optional.of(new Project(name: "projectName")) + 1 * environmentRepository.findByProjectKeyAndKey("projectKey", "environmentKey") >> Optional.of(new Environment(name: "environmentName")) + 1 * toggleRepository.findByProjectKeyAndKey("projectKey", "toggleKey") >> Optional.of(new Toggle(name: "toggleName")) + 1 * targetingSketchRepository.findByApprovalId(1) >> Optional.of(new TargetingSketch(modifiedTime: new Date())) + 1 == list.content.size() + } + +} + diff --git a/src/test/groovy/com/featureprobe/api/service/DictionaryServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/DictionaryServiceSpec.groovy new file mode 100644 index 0000000..8b0b181 --- /dev/null +++ b/src/test/groovy/com/featureprobe/api/service/DictionaryServiceSpec.groovy @@ -0,0 +1,65 @@ +package com.featureprobe.api.service + +import com.featureprobe.api.auth.TokenHelper +import com.featureprobe.api.entity.Dictionary +import com.featureprobe.api.repository.DictionaryRepository +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken +import spock.lang.Specification + +class DictionaryServiceSpec extends Specification { + + DictionaryRepository dictionaryRepository + DictionaryService dictionaryService + + def setup(){ + dictionaryRepository = Mock(DictionaryRepository) + dictionaryService = new DictionaryService(dictionaryRepository) + } + + def "save dictionary"(){ + given: + setAuthContext("Admin", "OWNER") + when: + def dictionary = dictionaryService.create("key", "value") + then: + 1 * dictionaryRepository.findByAccountAndKey("Admin", "key") >> + Optional.of(new Dictionary()) + 1 * dictionaryRepository.save(_) >> new Dictionary( key: "key", value: "value") + "key" == dictionary.key + "value" == dictionary.value + } + + def "update dictionary"(){ + given: + setAuthContext("Admin", "OWNER") + when: + def dictionary = dictionaryService.create("key", "value") + then: + 1 * dictionaryRepository.findByAccountAndKey("Admin", "key") >> + Optional.empty() + 1 * dictionaryRepository.save(_) >> new Dictionary( key: "key", value: "value") + "key" == dictionary.key + "value" == dictionary.value + } + + def "query dictionary"(){ + given: + setAuthContext("Admin", "OWNER") + when: + def dictionary = dictionaryService.query("key") + then: + 1 * dictionaryRepository.findByAccountAndKey(TokenHelper.getAccount(), "key") >> Optional.of(new Dictionary(key: "key", value: "value")) + "key" == dictionary.key + "value" == dictionary.value + } + + private setAuthContext(String account, String role) { + SecurityContextHolder.setContext(new SecurityContextImpl( + new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") + .claim("role", role).claim("account", account).build()))) + } +} + diff --git a/src/test/groovy/com/featureprobe/api/service/EnvironmentServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/EnvironmentServiceSpec.groovy index b7242d6..b14216f 100644 --- a/src/test/groovy/com/featureprobe/api/service/EnvironmentServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/EnvironmentServiceSpec.groovy @@ -3,6 +3,8 @@ package com.featureprobe.api.service import com.featureprobe.api.base.enums.ValidateTypeEnum import com.featureprobe.api.base.exception.ResourceConflictException import com.featureprobe.api.dto.EnvironmentCreateRequest +import com.featureprobe.api.dto.EnvironmentQueryRequest +import com.featureprobe.api.dto.EnvironmentResponse import com.featureprobe.api.dto.EnvironmentUpdateRequest import com.featureprobe.api.entity.Environment import com.featureprobe.api.entity.Project @@ -40,6 +42,8 @@ class EnvironmentServiceSpec extends Specification { FeatureProbe featureProbe EntityManager entityManager + IncludeArchivedEnvironmentService includeArchivedEnvironmentService + def projectName def projectKey def environmentName @@ -66,8 +70,9 @@ class EnvironmentServiceSpec extends Specification { entityManager = Mock(SessionImpl) environmentService = new EnvironmentService(environmentRepository, projectRepository, toggleRepository, targetingRepository, featureProbe, entityManager) + includeArchivedEnvironmentService = new IncludeArchivedEnvironmentService(environmentRepository, entityManager) createRequest = new EnvironmentCreateRequest(name: environmentName, key: environmentKey) - updateRequest = new EnvironmentUpdateRequest(name: "env_test_update") + updateRequest = new EnvironmentUpdateRequest(name: "env_test_update", resetServerSdk: true, resetClientSdk: true) setAuthContext("Admin", "ADMIN") } @@ -119,6 +124,44 @@ class EnvironmentServiceSpec extends Specification { "client-123" == environment.clientSdkKey } + def "validate include archived environment by key"(){ + when: + includeArchivedEnvironmentService.validateIncludeArchivedEnvironment(projectKey, ValidateTypeEnum.KEY, "environmentKey") + then: + 1 * environmentRepository.existsByProjectKeyAndKey(projectKey, "environmentKey") >> false + } + + def "validate include archived environment by key is conflict"(){ + when: + includeArchivedEnvironmentService.validateIncludeArchivedEnvironment(projectKey, ValidateTypeEnum.KEY, "environmentKey") + then: + 1 * environmentRepository.existsByProjectKeyAndKey(projectKey, "environmentKey") >> true + thrown(ResourceConflictException) + } + + def "validate include archived environment by name"(){ + when: + includeArchivedEnvironmentService.validateIncludeArchivedEnvironment(projectKey, ValidateTypeEnum.NAME, "environmentName") + then: + 1 * environmentRepository.existsByProjectKeyAndName(projectKey, "environmentName") >> false + } + + def "validate include archived environment by name is conflict"(){ + when: + includeArchivedEnvironmentService.validateIncludeArchivedEnvironment(projectKey, ValidateTypeEnum.NAME, "environmentName") + then: + 1 * environmentRepository.existsByProjectKeyAndName(projectKey, "environmentName") >> true + thrown(ResourceConflictException) + } + + def "environment list is archived"(){ + when: + def list = environmentService.list(projectKey, new EnvironmentQueryRequest(archived: true)) + then: + 1 * environmentRepository.findAllByProjectKeyAndArchived(projectKey, true) >> [new Environment()] + 1 == list.size() + } + private setAuthContext(String account, String role) { SecurityContextHolder.setContext(new SecurityContextImpl( new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") diff --git a/src/test/groovy/com/featureprobe/api/service/MemberServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/MemberServiceSpec.groovy index 745fe1e..deb9335 100644 --- a/src/test/groovy/com/featureprobe/api/service/MemberServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/MemberServiceSpec.groovy @@ -198,6 +198,30 @@ class MemberServiceSpec extends Specification { thrown(ResourceNotFoundException) } + def " member validate pass"() { + when: + def validate = memberIncludeDeletedService.validateAccountIncludeDeleted("Admin") + then: + 1 * memberRepository.existsByAccount("Admin") >> false + true == validate + } + + def " member validate conflict"() { + when: + def validate = memberIncludeDeletedService.validateAccountIncludeDeleted("Admin") + then: + 1 * memberRepository.existsByAccount("Admin") >> true + thrown(ResourceConflictException) + } + + def "query include deleted service"() { + when: + def member = memberIncludeDeletedService.queryMemberByAccountIncludeDeleted("Admin") + then: + 1 * memberRepository.findByAccount("Admin") >> Optional.of(new Member()) + true == member.isPresent() + } + private setAuthContext(String account, String role) { SecurityContextHolder.setContext(new SecurityContextImpl( new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") diff --git a/src/test/groovy/com/featureprobe/api/service/MetricServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/MetricServiceSpec.groovy index fbe6605..ec1a502 100644 --- a/src/test/groovy/com/featureprobe/api/service/MetricServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/MetricServiceSpec.groovy @@ -3,6 +3,7 @@ package com.featureprobe.api.service import com.featureprobe.api.auth.tenant.TenantContext import com.featureprobe.api.base.constants.MetricType import com.featureprobe.api.base.enums.OrganizationRoleEnum +import com.featureprobe.api.dto.AccessStatusResponse import com.featureprobe.api.dto.MetricResponse import com.featureprobe.api.entity.Environment import com.featureprobe.api.entity.Event @@ -166,7 +167,6 @@ class MetricServiceSpec extends Specification { true | 49 } - def "test `getPointIntervalCount`"() { expect: intervalCount == metricService.getPointIntervalCount(lastHours) @@ -218,6 +218,15 @@ class MetricServiceSpec extends Specification { } } + def "query access status"(){ + when: + def isAccess = metricService.isAccess("projectKey", "dev", "toggleKey") + then: + 1 * environmentRepository.findByProjectKeyAndKey("projectKey", "dev") >> Optional.of(new Environment(serverSdkKey: "123")) + 1 * eventRepository.existsBySdkKeyAndToggleKey("123", "toggleKey") >> true + true == isAccess.getIsAccess() + } + private setAuthContext(String account, String role) { SecurityContextHolder.setContext(new SecurityContextImpl( new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") diff --git a/src/test/groovy/com/featureprobe/api/service/OrganizationServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/OrganizationServiceSpec.groovy new file mode 100644 index 0000000..548b767 --- /dev/null +++ b/src/test/groovy/com/featureprobe/api/service/OrganizationServiceSpec.groovy @@ -0,0 +1,32 @@ +package com.featureprobe.api.service + +import com.featureprobe.api.base.enums.OrganizationRoleEnum +import com.featureprobe.api.entity.Organization +import com.featureprobe.api.entity.OrganizationMember +import com.featureprobe.api.repository.OrganizationMemberRepository +import com.featureprobe.api.repository.OrganizationRepository +import spock.lang.Specification + +class OrganizationServiceSpec extends Specification{ + + OrganizationRepository organizationRepository + OrganizationMemberRepository organizationMemberRepository + OrganizationService organizationService + + def setup() { + organizationRepository = Mock(OrganizationRepository) + organizationMemberRepository = Mock(OrganizationMemberRepository) + organizationService = new OrganizationService(organizationRepository, organizationMemberRepository) + } + + def "query organization member" () { + when: + def organizationMember = organizationService.queryOrganizationMember(1, 1) + then: + 1 * organizationMemberRepository.findByOrganizationIdAndMemberId(1, 1) >> + Optional.of(new OrganizationMember(1, 1, OrganizationRoleEnum.OWNER)) + 1 * organizationRepository.getById(1) >> new Organization(name: "Admin") + "Admin" == organizationMember.organizationName + } +} + diff --git a/src/test/groovy/com/featureprobe/api/service/ProjectServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/ProjectServiceSpec.groovy index 1cfb8ef..6a62429 100644 --- a/src/test/groovy/com/featureprobe/api/service/ProjectServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/ProjectServiceSpec.groovy @@ -2,6 +2,8 @@ package com.featureprobe.api.service import com.featureprobe.api.base.enums.ValidateTypeEnum import com.featureprobe.api.base.exception.ResourceConflictException +import com.featureprobe.api.dto.ApprovalSettings +import com.featureprobe.api.dto.PreferenceCreateRequest import com.featureprobe.api.dto.ProjectCreateRequest import com.featureprobe.api.dto.ProjectQueryRequest import com.featureprobe.api.dto.ProjectUpdateRequest @@ -52,7 +54,7 @@ class ProjectServiceSpec extends Specification { projectService = new ProjectService(projectRepository, environmentRepository, featureProbe, entityManager) queryRequest = new ProjectQueryRequest(keyword: keyword) createRequest = new ProjectCreateRequest(name: projectName, key: projectKey) - projectUpdateRequest = new ProjectUpdateRequest(name: "project_test_update", description: projectKey) + projectUpdateRequest = new ProjectUpdateRequest(name: "project_test_update", description: projectKey, archived: true) setAuthContext("Admin", "ADMIN") } @@ -87,7 +89,8 @@ class ProjectServiceSpec extends Specification { def ret = projectService.update(projectKey, projectUpdateRequest) then: 1 * projectRepository.findByKey(projectKey) >> - Optional.of(new Project(name: projectName, key: projectKey)) + Optional.of(new Project(name: projectName, key: projectKey, environments: [new Environment()])) + 1 * environmentRepository.saveAll(_) 1 * projectRepository.existsByName(projectUpdateRequest.name) >> false 1 * projectRepository.save(_) >> new Project(name: projectName, key: projectKey) with(ret) { @@ -114,6 +117,47 @@ class ProjectServiceSpec extends Specification { thrown ResourceConflictException } + def "validate projecte by name&key not exists"() { + when: + projectService.validateExists(ValidateTypeEnum.NAME, "name") + projectService.validateExists(ValidateTypeEnum.KEY, "key") + then: + projectRepository.existsByName("name") >> false + projectRepository.existsByKey("key") >> false + } + + def "validate projecte by name is exists"() { + when: + projectService.validateExists(ValidateTypeEnum.NAME, "name") + then: + projectRepository.existsByName("name") >> true + thrown ResourceConflictException + } + + def "validate projecte by keu is exists"() { + when: + projectService.validateExists(ValidateTypeEnum.KEY, "key") + then: + projectRepository.existsByKey("key") >> true + thrown ResourceConflictException + } + + def "create preference"() { + when: + projectService.createPreference(projectKey, new PreferenceCreateRequest(approvalSettings: [new ApprovalSettings(environmentKey: "dev", enable: true, reviewers: ["Admin"])])) + then: + 1 * environmentRepository.findAllByProjectKey(projectKey) >> [new Environment(key: "dev")] + 1 * environmentRepository.saveAll(_) + } + + def "query approval settings list"(){ + when: + def list = projectService.approvalSettingsList(projectKey) + then: + 1 * environmentRepository.findAllByProjectKey(projectKey) >> [new Environment(key: "dev", enableApproval: true, reviewers: "[\"Admin\"]")] + 1 == list.size() + } + private setAuthContext(String account, String role) { SecurityContextHolder.setContext(new SecurityContextImpl( new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") diff --git a/src/test/groovy/com/featureprobe/api/service/SegmentServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/SegmentServiceSpec.groovy index d7c1abd..0b480f8 100644 --- a/src/test/groovy/com/featureprobe/api/service/SegmentServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/SegmentServiceSpec.groovy @@ -197,5 +197,30 @@ class SegmentServiceSpec extends Specification{ 1 == segments.size() } + def "validate exists"(){ + when: + segmentService.validateExists(projectKey, ValidateTypeEnum.KEY, "key") + segmentService.validateExists(projectKey, ValidateTypeEnum.NAME, "name") + then: + 1 * segmentRepository.existsByProjectKeyAndKey(projectKey, "key") >> false + 1 * segmentRepository.existsByProjectKeyAndName(projectKey, "name") >> false + } + + def "validate exists by key is conflict"(){ + when: + segmentService.validateExists(projectKey, ValidateTypeEnum.KEY, "key") + then: + 1 * segmentRepository.existsByProjectKeyAndKey(projectKey, "key") >> true + thrown(ResourceConflictException) + } + + def "validate exists by name is conflict"(){ + when: + segmentService.validateExists(projectKey, ValidateTypeEnum.NAME, "name") + then: + 1 * segmentRepository.existsByProjectKeyAndName(projectKey, "name") >> true + thrown(ResourceConflictException) + } + } diff --git a/src/test/groovy/com/featureprobe/api/service/ServerServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/ServerServiceSpec.groovy index 5e6a4e1..c06268f 100644 --- a/src/test/groovy/com/featureprobe/api/service/ServerServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/ServerServiceSpec.groovy @@ -1,5 +1,6 @@ package com.featureprobe.api.service +import com.featureprobe.api.dto.SdkKeyResponse import com.featureprobe.api.entity.Environment import com.featureprobe.api.entity.Project import com.featureprobe.api.entity.Segment @@ -58,6 +59,14 @@ class ServerServiceSpec extends Specification { "\"predicate\":\"is one of\",\"objects\":[\"huahau\",\"kaka\",\"dada\"]}],\"name\":\"\"}]" } + def "query all sdkKeys"(){ + when: + def keys = serverService.queryAllSdkKeys() + then: + 1 * environmentRepository.findAll() >> [new Environment(clientSdkKey: "client-123", serverSdkKey: "server-123")] + 1 == keys.clientKeyToServerKey.size() + } + def "test get sdk server key"() { given: environmentRepository.findByServerSdkKeyOrClientSdkKey("key1", "key1") >> diff --git a/src/test/groovy/com/featureprobe/api/service/TargetingServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/TargetingServiceSpec.groovy index a4448c3..41f9c5f 100644 --- a/src/test/groovy/com/featureprobe/api/service/TargetingServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/TargetingServiceSpec.groovy @@ -1,20 +1,35 @@ package com.featureprobe.api.service +import com.featureprobe.api.auth.tenant.TenantContext +import com.featureprobe.api.base.enums.ApprovalStatusEnum +import com.featureprobe.api.base.enums.OrganizationRoleEnum +import com.featureprobe.api.base.enums.SketchStatusEnum import com.featureprobe.api.base.exception.ResourceNotFoundException import com.featureprobe.api.dto.TargetingRequest import com.featureprobe.api.dto.TargetingVersionRequest +import com.featureprobe.api.dto.UpdateApprovalStatusRequest +import com.featureprobe.api.entity.ApprovalRecord +import com.featureprobe.api.entity.Environment import com.featureprobe.api.entity.Targeting +import com.featureprobe.api.entity.TargetingSketch import com.featureprobe.api.entity.TargetingVersion import com.featureprobe.api.mapper.JsonMapper import com.featureprobe.api.model.TargetingContent +import com.featureprobe.api.repository.ApprovalRecordRepository +import com.featureprobe.api.repository.EnvironmentRepository import com.featureprobe.api.repository.SegmentRepository import com.featureprobe.api.repository.TargetingRepository import com.featureprobe.api.repository.TargetingSegmentRepository +import com.featureprobe.api.repository.TargetingSketchRepository import com.featureprobe.api.repository.TargetingVersionRepository import com.featureprobe.api.repository.VariationHistoryRepository import org.hibernate.internal.SessionImpl import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import spock.lang.Specification import spock.lang.Title @@ -30,6 +45,9 @@ class TargetingServiceSpec extends Specification { TargetingSegmentRepository targetingSegmentRepository TargetingVersionRepository targetingVersionRepository VariationHistoryRepository variationHistoryRepository + EnvironmentRepository environmentRepository + ApprovalRecordRepository approvalRecordRepository + TargetingSketchRepository targetingSketchRepository EntityManager entityManager def projectKey @@ -39,6 +57,8 @@ class TargetingServiceSpec extends Specification { def numberErrorContent def datetimeErrorContent def semVerErrorContent + ApprovalRecord approvalRecord + TargetingSketch targetingSketch def setup() { targetingRepository = Mock(TargetingRepository) @@ -46,9 +66,13 @@ class TargetingServiceSpec extends Specification { targetingSegmentRepository = Mock(TargetingSegmentRepository) targetingVersionRepository = Mock(TargetingVersionRepository) variationHistoryRepository = Mock(VariationHistoryRepository) + environmentRepository = Mock(EnvironmentRepository) + approvalRecordRepository = Mock(ApprovalRecordRepository) + targetingSketchRepository = Mock(TargetingSketchRepository) entityManager = Mock(SessionImpl) targetingService = new TargetingService(targetingRepository, segmentRepository, - targetingSegmentRepository, targetingVersionRepository, variationHistoryRepository, entityManager) + targetingSegmentRepository, targetingVersionRepository, variationHistoryRepository, + environmentRepository, approvalRecordRepository, targetingSketchRepository, entityManager) projectKey = "feature_probe" environmentKey = "test" @@ -92,6 +116,11 @@ class TargetingServiceSpec extends Specification { "\"disabledServe\":{\"select\":1},\"defaultServe\":{\"select\":1},\"variations\":[{\"value\":\"red\"," + "\"name\":\"Red Button\",\"description\":\"Set button color to Red\"},{\"value\":\"blue\"," + "\"name\":\"Blue Button\",\"description\":\"Set button color to Blue\"}]}" + approvalRecord = new ApprovalRecord(id: 1, organizationId: -1, projectKey: "projectKey", + environmentKey: "environmentKey", toggleKey: "toggleKey", submitBy: "Admin", approvedBy: "Test", reviewers: "[\"Admin\"]", status: ApprovalStatusEnum.PENDING, title: "title") + targetingSketch = new TargetingSketch(approvalId: 1, organizationId: -1, projectKey: "projectKey", + environmentKey: "environmentKey", toggleKey: "toggleKey", oldVersion: 1, content: content, comment: "test", disabled: true, status: SketchStatusEnum.PENDING) + TenantContext.setCurrentOrganization(new com.featureprobe.api.dto.OrganizationMember(1, "organization", OrganizationRoleEnum.OWNER)) } def "update targeting"() { @@ -107,6 +136,7 @@ class TargetingServiceSpec extends Specification { then: segmentRepository.existsByProjectKeyAndKey(projectKey, _) >> true + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> Optional.of(new Environment(enableApproval: false)) 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> Optional.of(new Targeting(id: 1, toggleKey: toggleKey, environmentKey: environmentKey, content: "", disabled: true, version: 1)) @@ -125,6 +155,31 @@ class TargetingServiceSpec extends Specification { } } + def "update targeting & enable approval"() { + given: + TargetingRequest targetingRequest = new TargetingRequest() + TargetingContent targetingContent = JsonMapper.toObject(content, TargetingContent.class); + targetingRequest.setContent(targetingContent) + targetingRequest.setDisabled(false) + setAuthContext("Admin", "ADMIN") + + when: + def ret = targetingService.update(projectKey, environmentKey, toggleKey, targetingRequest) + + then: + segmentRepository.existsByProjectKeyAndKey(projectKey, _) >> true + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> Optional.of(new Environment(enableApproval: true, reviewers: "[\"Admin\"]")) + targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, + toggleKey) >> Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, + content: content, disabled: false)) + 1 * approvalRecordRepository.save(_) >> approvalRecord + 1 * targetingSketchRepository.save(_) + with(ret) { + content == it.content + false == it.disabled + } + } + def "update targeting segment not found" () { when: TargetingRequest targetingRequest = new TargetingRequest() @@ -175,15 +230,33 @@ class TargetingServiceSpec extends Specification { when: def ret = targetingService.queryByKey(projectKey, environmentKey, toggleKey) then: + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> Optional.of(new Environment(enableApproval: false)) 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, content: content, disabled: false)) + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl([new ApprovalRecord(status: ApprovalStatusEnum.PASS, reviewers: "[\"Admin\"]")], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([new TargetingSketch(content: content, disabled: false, oldVersion: 1)], Pageable.ofSize(1), 1) with(ret) { content == it.content false == it.disabled } } + def "query targeting by key & enable approval"() { + when: + def ret = targetingService.queryByKey(projectKey, environmentKey, toggleKey) + then: + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> Optional.of(new Environment(enableApproval: true, reviewers: "[\"Admin\"]")) + 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> + Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, + content: content, disabled: false)) + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl([approvalRecord], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([targetingSketch], Pageable.ofSize(1), 1) + with(ret) { + content == it.content + true == it.disabled + } + } def "query targeting version"() { when: @@ -225,5 +298,65 @@ class TargetingServiceSpec extends Specification { } + def "update approval status to PASS"() { + given: + setAuthContext("Admin", "ADMIN") + when: + targetingService.updateApprovalStatus(projectKey, environmentKey, toggleKey, new UpdateApprovalStatusRequest(status: ApprovalStatusEnum.PASS, comment: "Pass")) + then: + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl<>([approvalRecord], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl<>([targetingSketch], Pageable.ofSize(1), 1) + 1 * approvalRecordRepository.saveAndFlush(_) + } + + def "update approval status to REVOKE"() { + given: + setAuthContext("Admin", "ADMIN") + when: + targetingService.updateApprovalStatus(projectKey, environmentKey, toggleKey, new UpdateApprovalStatusRequest(status: ApprovalStatusEnum.REVOKE, comment: "Pass")) + then: + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl<>([approvalRecord], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl<>([targetingSketch], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.save(_) + 1 * approvalRecordRepository.saveAndFlush(_) + } + + def "update approval status to JUMP"() { + given: + setAuthContext("Admin", "ADMIN") + when: + targetingService.updateApprovalStatus(projectKey, environmentKey, toggleKey, new UpdateApprovalStatusRequest(status: ApprovalStatusEnum.JUMP, comment: "Pass")) + then: + 2 * approvalRecordRepository.findAll(_, _) >> new PageImpl<>([approvalRecord], Pageable.ofSize(1), 1) + 2 * targetingSketchRepository.findAll(_, _) >> new PageImpl<>([targetingSketch], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.save(_) + 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, + toggleKey) >> Optional.of(new Targeting(id: 1, toggleKey: toggleKey, environmentKey: environmentKey, + content: "", disabled: true, version: 1)) + 1 * targetingRepository.saveAndFlush(_) >> new Targeting(id: 1, toggleKey: toggleKey, environmentKey: environmentKey, + content: "", disabled: false, version: 2) + 1 * targetingSegmentRepository.deleteByTargetingId(1) + 1 * targetingSegmentRepository.saveAll(_) + 1 * targetingVersionRepository.save(_) + 1 * variationHistoryRepository.saveAll(_) + 1 * approvalRecordRepository.saveAndFlush(_) + } + + def "cancel targeting sketch"() { + when: + targetingService.cancelSketch(projectKey, environmentKey, toggleKey) + then: + 1 * approvalRecordRepository.findAll(_, _) >> new PageImpl<>([approvalRecord], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl<>([targetingSketch], Pageable.ofSize(1), 1) + 1 * targetingSketchRepository.save(_) + } + + + private setAuthContext(String account, String role) { + SecurityContextHolder.setContext(new SecurityContextImpl( + new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a") + .claim("role", role).claim("account", account).build()))) + } + } diff --git a/src/test/groovy/com/featureprobe/api/service/ToggleServiceSpec.groovy b/src/test/groovy/com/featureprobe/api/service/ToggleServiceSpec.groovy index c84c6d1..6b8dedf 100644 --- a/src/test/groovy/com/featureprobe/api/service/ToggleServiceSpec.groovy +++ b/src/test/groovy/com/featureprobe/api/service/ToggleServiceSpec.groovy @@ -1,25 +1,24 @@ package com.featureprobe.api.service +import com.featureprobe.api.base.enums.SketchStatusEnum import com.featureprobe.api.base.enums.ValidateTypeEnum import com.featureprobe.api.base.enums.VisitFilter import com.featureprobe.api.base.exception.ResourceConflictException -import com.featureprobe.api.dto.ServerResponse import com.featureprobe.api.dto.ToggleCreateRequest import com.featureprobe.api.dto.ToggleSearchRequest import com.featureprobe.api.dto.ToggleUpdateRequest import com.featureprobe.api.entity.Environment import com.featureprobe.api.entity.Event -import com.featureprobe.api.entity.Project -import com.featureprobe.api.entity.Segment +import com.featureprobe.api.entity.Member import com.featureprobe.api.entity.Tag import com.featureprobe.api.entity.Targeting +import com.featureprobe.api.entity.TargetingSketch import com.featureprobe.api.entity.Toggle -import com.featureprobe.api.entity.ToggleTagRelation import com.featureprobe.api.repository.EnvironmentRepository import com.featureprobe.api.repository.EventRepository -import com.featureprobe.api.repository.SegmentRepository import com.featureprobe.api.repository.TagRepository import com.featureprobe.api.repository.TargetingRepository +import com.featureprobe.api.repository.TargetingSketchRepository import com.featureprobe.api.repository.TargetingVersionRepository import com.featureprobe.api.repository.ToggleRepository import com.featureprobe.api.repository.ToggleTagRepository @@ -58,10 +57,14 @@ class ToggleServiceSpec extends Specification { VariationHistoryRepository variationHistoryRepository + TargetingSketchRepository targetingSketchRepository + FeatureProbe featureProbe EntityManager entityManager + IncludeArchivedToggleService includeArchivedToggleService + def projectKey def environmentKey def toggleKey @@ -79,11 +82,13 @@ class ToggleServiceSpec extends Specification { eventRepository = Mock(EventRepository) targetingVersionRepository = Mock(TargetingVersionRepository) variationHistoryRepository = Mock(VariationHistoryRepository) + targetingSketchRepository = Mock(TargetingSketchRepository) featureProbe = new FeatureProbe("_") entityManager = Mock(SessionImpl) toggleService = new ToggleService(toggleRepository, tagRepository, targetingRepository, environmentRepository, eventRepository, targetingVersionRepository, - variationHistoryRepository, featureProbe, entityManager) + variationHistoryRepository, targetingSketchRepository, featureProbe, entityManager) + includeArchivedToggleService = new IncludeArchivedToggleService(toggleRepository, entityManager) projectKey = "feature_probe" environmentKey = "test" toggleKey = "feature_toggle_unit_test" @@ -115,12 +120,115 @@ class ToggleServiceSpec extends Specification { } } + def "search toggles by filter params by IN_WEEK_VISITED"() { + def toggleSearchRequest = + new ToggleSearchRequest(visitFilter: VisitFilter.IN_WEEK_VISITED, disabled: false, + tags: ["test"], keyword: "test", environmentKey: environmentKey) + when: + def page = toggleService.list(projectKey, toggleSearchRequest) + + then: + 1 * environmentRepository.findByProjectKeyAndKeyAndArchived(projectKey, environmentKey, false) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "1234", clientSdkKey: "5678")) + 1 * targetingRepository.findAllByProjectKeyAndEnvironmentKeyAndDisabled(projectKey, environmentKey, + false) >> [new Targeting(toggleKey: toggleKey)] + 1 * tagRepository.findByNameIn(["test"]) >> [new Tag(name: "test", toggles: [new Toggle(key: toggleKey)])] + 1 * eventRepository.findAll(_) >> [new Event(toggleKey: toggleKey)] + 1 * toggleRepository.findAll(_, _) >> new PageImpl<>([new Toggle(key: toggleKey, projectKey: projectKey)], + Pageable.ofSize(1), 1) + 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> + Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, + projectKey: projectKey, disabled: true)) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([new TargetingSketch(content: rules, disabled: false, oldVersion: 1, status: SketchStatusEnum.PENDING, createdBy: new Member(account: "Admin"))], Pageable.ofSize(1), 1) + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "123", clientSdkKey: "123")) + 1 * eventRepository.findAll(_, _) >> new PageImpl<>([new Event(toggleKey: toggleKey, sdkKey: "123", + startDate: new Date())], + Pageable.ofSize(1), 1) + with(page) { + 1 == it.size + null != it.getContent().get(0).visitedTime + true == it.getContent().get(0).isLocked() + } + } + + def "search toggles by filter params by OUT_WEEK_VISITED"() { + def toggleSearchRequest = + new ToggleSearchRequest(visitFilter: VisitFilter.OUT_WEEK_VISITED, disabled: false, + tags: ["test"], keyword: "test", environmentKey: environmentKey) + when: + def page = toggleService.list(projectKey, toggleSearchRequest) + + then: + 1 * environmentRepository.findByProjectKeyAndKeyAndArchived(projectKey, environmentKey, false) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "1234", clientSdkKey: "5678")) + 1 * targetingRepository.findAllByProjectKeyAndEnvironmentKeyAndDisabled(projectKey, environmentKey, + false) >> [new Targeting(toggleKey: toggleKey)] + 1 * tagRepository.findByNameIn(["test"]) >> [new Tag(name: "test", toggles: [new Toggle(key: toggleKey)])] + 2 * eventRepository.findAll(_) >> [new Event(toggleKey: toggleKey)] + 1 * toggleRepository.findAll(_, _) >> new PageImpl<>([new Toggle(key: toggleKey, projectKey: projectKey)], + Pageable.ofSize(1), 1) + 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> + Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, + projectKey: projectKey, disabled: true)) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([new TargetingSketch(content: rules, disabled: false, oldVersion: 1, status: SketchStatusEnum.PENDING, createdBy: new Member(account: "Admin"))], Pageable.ofSize(1), 1) + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "123", clientSdkKey: "123")) + 1 * eventRepository.findAll(_, _) >> new PageImpl<>([new Event(toggleKey: toggleKey, sdkKey: "123", + startDate: new Date())], + Pageable.ofSize(1), 1) + with(page) { + 1 == it.size + null != it.getContent().get(0).visitedTime + true == it.getContent().get(0).isLocked() + } + } + + def "search toggles by filter params by NOT_VISITED"() { + def toggleSearchRequest = + new ToggleSearchRequest(visitFilter: VisitFilter.NOT_VISITED, disabled: false, + tags: ["test"], keyword: "test", environmentKey: environmentKey) + when: + def page = toggleService.list(projectKey, toggleSearchRequest) + + then: + 1 * environmentRepository.findByProjectKeyAndKeyAndArchived(projectKey, environmentKey, false) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "1234", clientSdkKey: "5678")) + 1 * targetingRepository.findAllByProjectKeyAndEnvironmentKeyAndDisabled(projectKey, environmentKey, + false) >> [new Targeting(toggleKey: toggleKey)] + 1 * tagRepository.findByNameIn(["test"]) >> [new Tag(name: "test", toggles: [new Toggle(key: toggleKey)])] + 1 * eventRepository.findAll(_) >> [new Event(toggleKey: toggleKey)] + 1 * toggleRepository.findAll(_) >> [new Toggle(key: toggleKey, projectKey: projectKey)] + 1 * toggleRepository.findAll(_, _) >> new PageImpl<>([new Toggle(key: toggleKey, projectKey: projectKey)], + Pageable.ofSize(1), 1) + 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> + Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, + projectKey: projectKey, disabled: true)) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([new TargetingSketch(content: rules, disabled: false, oldVersion: 1, status: SketchStatusEnum.PENDING, createdBy: new Member(account: "Admin"))], Pageable.ofSize(1), 1) + 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> + Optional.of(new Environment(key: environmentKey, serverSdkKey: "123", clientSdkKey: "123")) + 1 * eventRepository.findAll(_, _) >> new PageImpl<>([new Event(toggleKey: toggleKey, sdkKey: "123", + startDate: new Date())], + Pageable.ofSize(1), 1) + with(page) { + 1 == it.size + null != it.getContent().get(0).visitedTime + true == it.getContent().get(0).isLocked() + } + } + def "search toggles by filter params"() { def toggleSearchRequest = new ToggleSearchRequest(visitFilter: VisitFilter.IN_WEEK_VISITED, disabled: false, tags: ["test"], keyword: "test", environmentKey: environmentKey) + def toggleSearchRequest2 = + new ToggleSearchRequest(visitFilter: VisitFilter.OUT_WEEK_VISITED, disabled: false, + tags: ["test"], keyword: "test", environmentKey: environmentKey) + def toggleSearchRequest3 = + new ToggleSearchRequest(visitFilter: VisitFilter.NOT_VISITED, disabled: false, + tags: ["test"], keyword: "test", environmentKey: environmentKey) when: - def page = toggleService.query(projectKey, toggleSearchRequest) + def page = toggleService.list(projectKey, toggleSearchRequest) then: 1 * environmentRepository.findByProjectKeyAndKeyAndArchived(projectKey, environmentKey, false) >> @@ -134,6 +242,7 @@ class ToggleServiceSpec extends Specification { 1 * targetingRepository.findByProjectKeyAndEnvironmentKeyAndToggleKey(projectKey, environmentKey, toggleKey) >> Optional.of(new Targeting(toggleKey: toggleKey, environmentKey: environmentKey, projectKey: projectKey, disabled: true)) + 1 * targetingSketchRepository.findAll(_, _) >> new PageImpl([new TargetingSketch(content: rules, disabled: false, oldVersion: 1, status: SketchStatusEnum.PENDING, createdBy: new Member(account: "Admin"))], Pageable.ofSize(1), 1) 1 * environmentRepository.findByProjectKeyAndKey(projectKey, environmentKey) >> Optional.of(new Environment(key: environmentKey, serverSdkKey: "123", clientSdkKey: "123")) 1 * eventRepository.findAll(_, _) >> new PageImpl<>([new Event(toggleKey: toggleKey, sdkKey: "123", @@ -141,7 +250,8 @@ class ToggleServiceSpec extends Specification { Pageable.ofSize(1), 1) with(page) { 1 == it.size - it.getContent().get(0).visitedTime != null + null != it.getContent().get(0).visitedTime + true == it.getContent().get(0).isLocked() } } @@ -188,6 +298,36 @@ class ToggleServiceSpec extends Specification { } } + def "validate include archived toggle by key"() { + when: + includeArchivedToggleService.validateIncludeArchivedToggle(projectKey, ValidateTypeEnum.KEY, "toggleKey") + then: + 1 * toggleRepository.existsByProjectKeyAndKey(projectKey, "toggleKey") >> false + } + + def "validate include archived toggle by key is conflict"() { + when: + includeArchivedToggleService.validateIncludeArchivedToggle(projectKey, ValidateTypeEnum.KEY, "toggleKey") + then: + 1 * toggleRepository.existsByProjectKeyAndKey(projectKey, "toggleKey") >> true + thrown(ResourceConflictException) + } + + def "validate include archived toggle by name"() { + when: + includeArchivedToggleService.validateIncludeArchivedToggle(projectKey, ValidateTypeEnum.NAME, "toggleName") + then: + 1 * toggleRepository.existsByProjectKeyAndName(projectKey, "toggleName") >> false + } + + def "validate include archived toggle by name is conflict"() { + when: + includeArchivedToggleService.validateIncludeArchivedToggle(projectKey, ValidateTypeEnum.NAME, "toggleName") + then: + 1 * toggleRepository.existsByProjectKeyAndName(projectKey, "toggleName") >> true + thrown(ResourceConflictException) + } + private setAuthContext(String account, String role) { SecurityContextHolder.setContext(new SecurityContextImpl( new JwtAuthenticationToken(new Jwt.Builder("21212").header("a","a")