From bfe2a18c1de6271058fb10e9068218e0caa003b8 Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Sat, 20 Apr 2024 17:44:59 +0300 Subject: [PATCH] Add modification (add/remove) expense categories to plan jar --- .../io/myfinbox/income/domain/Income.java | 2 +- .../adapter/web/JarController.java | 24 ++++ .../spendingplan/adapter/web/JarsApi.java | 26 ++++ .../AddOrRemoveJarCategoryService.java | 95 +++++++++++++ .../AddOrRemoveJarCategoryUseCase.java | 45 ++++++ .../CategoriesJarCommandValidator.java | 57 ++++++++ .../io/myfinbox/spendingplan/domain/Jar.java | 35 ++--- .../domain/JarExpenseCategories.java | 19 +++ .../domain/JarExpenseCategory.java | 52 +++++++ .../spendingplan/domain/JarIdentifier.java | 21 +++ .../io/myfinbox/spendingplan/domain/Jars.java | 11 +- .../io/myfinbox/spendingplan/domain/Plan.java | 12 +- ...__spending_jar_expense_category_schema.sql | 13 ++ server/src/main/resources/openapi.yml | 19 +++ .../myfinbox/spendingplan/DataSamples.groovy | 31 ++++- .../adapter/web/JarControllerSpec.groovy | 66 ++++++++- .../AddOrRemoveJarCategoryServiceSpec.groovy | 128 ++++++++++++++++++ .../web/jar_expense_category-create.sql | 12 ++ 18 files changed, 633 insertions(+), 35 deletions(-) create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryService.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryUseCase.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategories.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/domain/JarIdentifier.java create mode 100644 server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql create mode 100644 server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy create mode 100644 server/src/test/resources/spendingplan/web/jar_expense_category-create.sql diff --git a/server/src/main/java/io/myfinbox/income/domain/Income.java b/server/src/main/java/io/myfinbox/income/domain/Income.java index 88082b6..07064a4 100644 --- a/server/src/main/java/io/myfinbox/income/domain/Income.java +++ b/server/src/main/java/io/myfinbox/income/domain/Income.java @@ -29,7 +29,7 @@ @Table(name = "incomes") @Getter @ToString -@EqualsAndHashCode +@EqualsAndHashCode(callSuper = false) @NoArgsConstructor(access = PRIVATE, force = true) // JPA compliant public class Income extends AbstractAggregateRoot { diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java index 2683bc9..0cb86eb 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java @@ -1,7 +1,10 @@ package io.myfinbox.spendingplan.adapter.web; import io.myfinbox.rest.CreateJarResource; +import io.myfinbox.rest.JarCategoryModificationResource; import io.myfinbox.shared.ApiFailureHandler; +import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase; +import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove; import io.myfinbox.spendingplan.application.CreateJarUseCase; import io.myfinbox.spendingplan.application.JarCommand; import io.myfinbox.spendingplan.domain.Jar; @@ -9,10 +12,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Objects; import java.util.UUID; +import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.ResponseEntity.created; +import static org.springframework.http.ResponseEntity.ok; import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; @RestController @@ -21,6 +27,7 @@ final class JarController implements JarsApi { private final CreateJarUseCase createJarUseCase; + private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase; private final ApiFailureHandler apiFailureHandler; @PostMapping(path = "/{planId}/jars", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) @@ -30,6 +37,14 @@ public ResponseEntity create(@PathVariable UUID planId, @RequestBody CreateJa .body(toResource(jar))); } + @PutMapping(path = "/{planId}/jars/{jarId}/expense-categories", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) + public ResponseEntity modifyExpenseCategories(@PathVariable UUID planId, + @PathVariable UUID jarId, + @RequestBody JarCategoryModificationResource resource) { + return addOrRemoveJarCategoryUseCase.addOrRemove(planId, jarId, toCommand(resource)) + .fold(apiFailureHandler::handle, ok -> ok().build()); + } + private JarCommand toCommand(CreateJarResource resource) { return JarCommand.builder() .name(resource.getName()) @@ -48,4 +63,13 @@ private CreateJarResource toResource(Jar jar) { .percentage(jar.getPercentage().value()) .description(jar.getDescription()); } + + private JarCategoriesCommand toCommand(JarCategoryModificationResource resource) { + var categoryToAdds = resource.getCategories() + .stream() + .filter(Objects::nonNull) // avoid null categoryToAdd + .map(categoryToAdd -> new JarCategoryToAddOrRemove(categoryToAdd.getCategoryId(), categoryToAdd.getToAdd())) + .toList(); + return new JarCategoriesCommand(categoryToAdds); + } } diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java index 9b9fcc7..2d176e2 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java @@ -1,6 +1,7 @@ package io.myfinbox.spendingplan.adapter.web; import io.myfinbox.rest.CreateJarResource; +import io.myfinbox.rest.JarCategoryModificationResource; import io.myfinbox.shared.ApiErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -45,4 +46,29 @@ public interface JarsApi { }) ResponseEntity create(@Parameter(in = PATH, description = "Spending plan ID to be added new jar", required = true) UUID planId, @RequestBody(description = "Jar Resource to be created", required = true) CreateJarResource resource); + + @Operation( + summary = "Modify Expense Categories for Spending Plan Jar", + description = "Add or remove provided categories to a spending plan jar in MyFinBox. This endpoint modifies the categories based on the 'toAdd' flag.", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG} + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Success: Categories modified successfully", + content = @Content), + @ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Spending plan or jar not found", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "422", + description = "Request schema validation failure. At least one category must be provided, null categories not allowed, or duplicate categories provided", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal Server Error", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) + }) + ResponseEntity modifyExpenseCategories( + @Parameter(in = PATH, description = "ID of the spending plan containing the jar to modify categories", required = true) UUID planId, + @Parameter(in = PATH, description = "ID of the spending jar to modify categories", required = true) UUID jarId, + @RequestBody(description = "Resource containing categories to add or remove", required = true) JarCategoryModificationResource resource); + } diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryService.java b/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryService.java new file mode 100644 index 0000000..e6a819a --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryService.java @@ -0,0 +1,95 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.shared.Failure; +import io.myfinbox.spendingplan.domain.*; +import io.vavr.control.Either; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static io.myfinbox.spendingplan.domain.JarExpenseCategory.CategoryIdentifier; +import static io.myfinbox.spendingplan.domain.Plan.PlanIdentifier; +import static java.util.Objects.isNull; + +@Service +@Transactional +@RequiredArgsConstructor +class AddOrRemoveJarCategoryService implements AddOrRemoveJarCategoryUseCase { + + static final String VALIDATION_CREATE_FAILURE_MESSAGE = "Failed to validate the request to add or remove categories to plan jar."; + static final String PLAN_NOT_FOUND_MESSAGE = "Spending plan was not found."; + static final String JAR_NOT_FOUND_MESSAGE = "Spending jar was not found."; + static final String PLAN_JAR_NOT_FOUND_MESSAGE = "Spending plan jar was not found."; + + private final CategoriesJarCommandValidator validator = new CategoriesJarCommandValidator(); + + private final Jars jars; + private final JarExpenseCategories jarExpenseCategories; + + @Override + public Either> addOrRemove(UUID planId, UUID jarId, JarCategoriesCommand command) { + var either = validate(planId, jarId, command); + if (either.isLeft()) { + return either; + } + + // require plan jar to exist + var jar = jars.findByIdAndPlanId(new JarIdentifier(jarId), new PlanIdentifier(planId)); + if (jar.isEmpty()) { + return Either.left(Failure.ofNotFound(PLAN_JAR_NOT_FOUND_MESSAGE)); + } + + // select unchecked and delete + command.categories().stream() + .filter(jarCategoryToAddOrRemove -> !jarCategoryToAddOrRemove.toAdd()) + .map(JarCategoryToAddOrRemove::categoryId) + .forEach(category -> + jarExpenseCategories.deleteByJarIdAndCategoryId(new JarIdentifier(jarId), new CategoryIdentifier(category))); + + // select checked and create if not exists + var toCreateCategories = filterToCreate(jar.get(), command); + + jarExpenseCategories.saveAll(toCreateCategories); + + return Either.right(toCreateCategories); + } + + private Either> validate(UUID planId, UUID jarId, JarCategoriesCommand command) { + // schema validation + var failure = validator.validate(command.categories()); + if (failure.isInvalid()) { + return Either.left(Failure.ofValidation(VALIDATION_CREATE_FAILURE_MESSAGE, List.of(failure.getError()))); + } + + // validate non null plan id + if (isNull(planId)) { + return Either.left(Failure.ofNotFound(PLAN_NOT_FOUND_MESSAGE)); + } + + // validate non null jar id + if (isNull(jarId)) { + return Either.left(Failure.ofNotFound(JAR_NOT_FOUND_MESSAGE)); + } + + return Either.right(null); + } + + private List filterToCreate(Jar jar, JarCategoriesCommand command) { + return command.categories().stream() + .filter(JarCategoryToAddOrRemove::toAdd) + .map(JarCategoryToAddOrRemove::categoryId) + .filter(category -> !existsByJarIdAndCategoryId(jar.getId(), category)) + .map(category -> JarExpenseCategory.builder() + .categoryId(new CategoryIdentifier(category)) + .jar(jar) + .build()) + .toList(); + } + + private boolean existsByJarIdAndCategoryId(JarIdentifier jarId, UUID category) { + return jarExpenseCategories.existsByJarIdAndCategoryId(jarId, new CategoryIdentifier(category)); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryUseCase.java b/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryUseCase.java new file mode 100644 index 0000000..3be6a45 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryUseCase.java @@ -0,0 +1,45 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.shared.Failure; +import io.myfinbox.spendingplan.domain.JarExpenseCategory; +import io.vavr.control.Either; + +import java.util.List; +import java.util.UUID; + +import static java.util.Objects.isNull; + +/** + * Interface representing a use case for adding or removing categories to/from a plan jar. + */ +public interface AddOrRemoveJarCategoryUseCase { + + /** + * Adds or removes categories to/from a specified jar within a spending plan jar. + * + * @param planId The ID of the spending plan containing the jar. + * @param jarId The ID of the jar to which categories will be added or removed. + * @param command The command containing information about the categories to add or remove. + * @return An {@link Either} containing either a {@link Failure} if the operation fails + * or a list of added {@link JarExpenseCategory} after the operation. + */ + Either> addOrRemove(UUID planId, UUID jarId, JarCategoriesCommand command); + + record JarCategoriesCommand(List categories) { + + public static final String CATEGORIES_JAR_FIELD = "categories"; + } + + record JarCategoryToAddOrRemove(UUID categoryId, Boolean toAdd) { + + /** + * Gets whether the category is checked or not. + * Null is treated as true. + * + * @return True if the category is checked, false otherwise. + */ + public Boolean toAdd() { // null treated as true + return isNull(toAdd) || toAdd; + } + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java b/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java new file mode 100644 index 0000000..f8b3b46 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java @@ -0,0 +1,57 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.shared.Failure; +import io.vavr.control.Validation; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import static io.myfinbox.shared.Failure.FieldViolation; +import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand.CATEGORIES_JAR_FIELD; +import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove; +import static io.vavr.API.Invalid; +import static io.vavr.API.Valid; +import static java.util.Objects.isNull; + +final class CategoriesJarCommandValidator { + + Validation> validate(List categories) { + if (isNull(categories) || categories.isEmpty()) { + return Invalid(Failure.FieldViolation.builder() + .field(CATEGORIES_JAR_FIELD) + .message("At least one category must be provided.") + .rejectedValue(categories) + .build()); + } + + var anyNull = categories.stream() + .map(JarCategoryToAddOrRemove::categoryId) + .anyMatch(Objects::isNull); + + if (anyNull) { + return Invalid(Failure.FieldViolation.builder() + .field(CATEGORIES_JAR_FIELD) + .message("Null categoryId not allowed.") + .rejectedValue(categories) + .build()); + } + + var categoryCount = categories.stream() + .collect(Collectors.groupingBy(JarCategoryToAddOrRemove::categoryId)); + + var anyDuplicate = categoryCount.values().stream() + .map(List::size) + .anyMatch(size -> size > 1); + + if (anyDuplicate) { + return Invalid(Failure.FieldViolation.builder() + .field(CATEGORIES_JAR_FIELD) + .message("Duplicate category ids provided.") + .rejectedValue(categories) + .build()); + } + + return Valid(categories); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java b/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java index d41bfbb..cce4b76 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java @@ -1,6 +1,5 @@ package io.myfinbox.spendingplan.domain; -import com.fasterxml.jackson.annotation.JsonIgnore; import io.hypersistence.utils.hibernate.type.money.MonetaryAmountType; import jakarta.persistence.*; import lombok.*; @@ -11,25 +10,31 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Instant; +import java.util.List; import java.util.UUID; import static io.myfinbox.shared.Guards.*; -import static lombok.AccessLevel.PRIVATE; +import static jakarta.persistence.CascadeType.ALL; +import static lombok.AccessLevel.PACKAGE; @Entity @Table(name = "spending_jars") @Getter @ToString -@EqualsAndHashCode -@NoArgsConstructor(access = PRIVATE, force = true) +@EqualsAndHashCode(of = "id") +@NoArgsConstructor(access = PACKAGE, force = true) public class Jar { public static final int MAX_NAME_LENGTH = 255; @EmbeddedId private final JarIdentifier id; + private final Instant creationTimestamp; + private String name; + private String description; + @Embedded @AttributeOverride(name = "value", column = @Column(name = "percentage")) private Percentage percentage; @@ -39,13 +44,13 @@ public class Jar { @CompositeType(MonetaryAmountType.class) private MonetaryAmount amountToReach; - private String name; - private String description; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "plan_id", referencedColumnName = "id", nullable = false) private Plan plan; + @OneToMany(mappedBy = "jar", cascade = ALL, orphanRemoval = true) + private List jarExpenseCategories; + @Builder public Jar(Percentage percentage, MonetaryAmount amountToReach, @@ -74,31 +79,15 @@ private void setAmountToReach(MonetaryAmount amountToReach) { this.amountToReach = greaterThanZero(amountToReach, "amountToReach must be greater than 0."); } - @JsonIgnore public BigDecimal getAmountToReachAsNumber() { return amountToReach.getNumber().numberValue(BigDecimal.class) .setScale(2, RoundingMode.HALF_UP); } - @JsonIgnore public String getCurrencyCode() { return amountToReach.getCurrency().getCurrencyCode(); } - @Embeddable - public record JarIdentifier(UUID id) implements Serializable { - - public JarIdentifier { - notNull(id, "id cannot be null"); - } - - @Override - public String toString() { - return id.toString(); - } - } - - @Embeddable public record Percentage(Integer value) implements Serializable { public Percentage { diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategories.java b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategories.java new file mode 100644 index 0000000..4f4ae1f --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategories.java @@ -0,0 +1,19 @@ +package io.myfinbox.spendingplan.domain; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static io.myfinbox.spendingplan.domain.JarExpenseCategory.CategoryIdentifier; + +@Repository +public interface JarExpenseCategories extends CrudRepository { + + List findByJarId(JarIdentifier jarId); + + boolean existsByJarIdAndCategoryId(JarIdentifier jarId, CategoryIdentifier categoryId); + + void deleteByJarIdAndCategoryId(JarIdentifier jarId, CategoryIdentifier categoryId); + +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java new file mode 100644 index 0000000..921a9d9 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java @@ -0,0 +1,52 @@ +package io.myfinbox.spendingplan.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +import static io.myfinbox.shared.Guards.notNull; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PACKAGE; + +@Entity +@Getter +@ToString +@Table(name = "spending_jar_expense_category") +@EqualsAndHashCode(of = {"jar", "categoryId"}) +@NoArgsConstructor(access = PACKAGE, force = true) +public class JarExpenseCategory { + + private final Instant creationTimestamp; + @Embedded + @AttributeOverride(name = "id", column = @Column(name = "category_id")) + private final CategoryIdentifier categoryId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "jar_id", referencedColumnName = "id", nullable = false) + private final Jar jar; + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Builder + public JarExpenseCategory(Jar jar, CategoryIdentifier categoryId) { + this.jar = notNull(jar, "jar cannot be null."); + this.categoryId = notNull(categoryId, "categoryId cannot be null."); + this.creationTimestamp = Instant.now(); + } + + @Embeddable + public record CategoryIdentifier(UUID id) implements Serializable { + + public CategoryIdentifier { + notNull(id, "id cannot be null"); + } + + @Override + public String toString() { + return id.toString(); + } + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/JarIdentifier.java b/server/src/main/java/io/myfinbox/spendingplan/domain/JarIdentifier.java new file mode 100644 index 0000000..b3c43b8 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/JarIdentifier.java @@ -0,0 +1,21 @@ +package io.myfinbox.spendingplan.domain; + +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.UUID; + +import static io.myfinbox.shared.Guards.notNull; + +@Embeddable +public record JarIdentifier(UUID id) implements Serializable { + + public JarIdentifier { + notNull(id, "id cannot be null"); + } + + @Override + public String toString() { + return id.toString(); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java b/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java index 6add1d2..f06b29b 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java @@ -1,12 +1,21 @@ package io.myfinbox.spendingplan.domain; -import io.myfinbox.spendingplan.domain.Jar.JarIdentifier; +import io.myfinbox.spendingplan.domain.Plan.PlanIdentifier; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface Jars extends JpaRepository { boolean existsByNameAndPlan(String name, Plan plan); + @Query(value = """ + SELECT j FROM Jar j + WHERE j.id = :jarId AND j.plan.id = :planId + """) + Optional findByIdAndPlanId(JarIdentifier jarId, PlanIdentifier planId); + } diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java b/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java index f06964c..fa93e7c 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java @@ -17,14 +17,14 @@ import static io.myfinbox.shared.Guards.*; import static jakarta.persistence.CascadeType.ALL; import static java.util.Objects.requireNonNull; -import static lombok.AccessLevel.PRIVATE; +import static lombok.AccessLevel.PACKAGE; @Entity @Table(name = "spending_plans") @Getter @ToString -@EqualsAndHashCode(callSuper = false) -@NoArgsConstructor(access = PRIVATE, force = true) +@EqualsAndHashCode(callSuper = false, of = {"id", "name", "account"}) +@NoArgsConstructor(access = PACKAGE, force = true) public class Plan extends AbstractAggregateRoot { public static final int MAX_NAME_LENGTH = 255; @@ -34,6 +34,9 @@ public class Plan extends AbstractAggregateRoot { private final Instant creationTimestamp; + private String name; + private String description; + @Embedded @AttributeOverride(name = "id", column = @Column(name = "account_id")) private final AccountIdentifier account; @@ -43,9 +46,6 @@ public class Plan extends AbstractAggregateRoot { @CompositeType(MonetaryAmountType.class) private MonetaryAmount amount; - private String name; - private String description; - @OneToMany(mappedBy = "plan", cascade = ALL, orphanRemoval = true) private final List jars = new ArrayList<>(); diff --git a/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql b/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql new file mode 100644 index 0000000..11763a5 --- /dev/null +++ b/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS spending_jar_expense_category +( + id SERIAL PRIMARY KEY, + jar_id UUID NOT NULL, + category_id UUID NOT NULL, + creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (jar_id) REFERENCES spending_jars (id) +) +; + +CREATE UNIQUE INDEX unique_spending_jar_expense_category_id_idx ON spending_jar_expense_category (jar_id, category_id); + +CREATE INDEX search_expense_category_jar_id_idx ON spending_jar_expense_category (jar_id); diff --git a/server/src/main/resources/openapi.yml b/server/src/main/resources/openapi.yml index cdbaa7e..1b9eed3 100644 --- a/server/src/main/resources/openapi.yml +++ b/server/src/main/resources/openapi.yml @@ -253,3 +253,22 @@ components: type: string example: "Necessities spending: Rent, Food, Bills etc." description: Additional description attached to the jar. + + JarCategoryModificationResource: + type: object + description: Object representing the modification of expense categories associated with a jar for tracking expenses. + properties: + categories: + type: array + items: + type: object + properties: + categoryId: + type: string + format: uuid + description: Unique identifier for the expense category. + toAdd: + type: boolean + default: true + example: true + description: Flag indicating whether to add or remove the category. diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy index c508c65..2430ead 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy @@ -1,6 +1,5 @@ package io.myfinbox.spendingplan - import com.fasterxml.jackson.databind.json.JsonMapper import groovy.json.JsonOutput import io.myfinbox.spendingplan.application.JarCommand @@ -8,6 +7,8 @@ import io.myfinbox.spendingplan.application.PlanCommand import io.myfinbox.spendingplan.domain.Jar import io.myfinbox.spendingplan.domain.Plan +import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand + class DataSamples { static MAPPER = JsonMapper.builder() @@ -17,6 +18,8 @@ class DataSamples { static planId = "3b257779-a5db-4e87-9365-72c6f8d4977d" static jarId = "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca" static accountId = "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca" + static jarCategoryId = "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca" + static jarCategoryId2 = "ee0a4cdc-84f0-4f81-8aea-224dad4915e7" static timestamp = "2024-03-23T10:00:04.224870Z" static amount = 1000.0 static currency = 'EUR' @@ -72,6 +75,19 @@ class DataSamples { description : "Necessities spending: Rent, Food, Bills etc.", ] + static JAR_CATEGORIES_COMMAND = [ + categories: [JAR_CATEGORY_TO_ADD_OR_REMOVE] + ] + + static JAR_CATEGORY_TO_ADD_OR_REMOVE = [ + categoryId: jarCategoryId, + toAdd : true + ] + + static JAR_CATEGORIES_RESOURCE = [ + categories: [] + ] + static newSampleCreatePlanResource(map = [:]) { JsonOutput.toJson(CREATE_PLAN_RESOURCE + map) as String } @@ -95,4 +111,17 @@ class DataSamples { static newSampleJar(map = [:]) { MAPPER.readValue(JsonOutput.toJson(JAR + map) as String, Jar.class) } + + static newSampleJarCategoriesCommand(map = [:]) { + MAPPER.readValue(JsonOutput.toJson(JAR_CATEGORIES_COMMAND + map) as String, JarCategoriesCommand.class) + } + + static newSampleJarCategoryToAddAsMap(map = [:]) { + return JAR_CATEGORY_TO_ADD_OR_REMOVE + map + } + + + static newSampleJarCategoriesResource(map = [:]) { + JsonOutput.toJson(JAR_CATEGORIES_RESOURCE + map) as String + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy index 3aa2335..e2690f0 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy @@ -3,6 +3,8 @@ package io.myfinbox.spendingplan.adapter.web import groovy.json.JsonOutput import groovy.json.JsonSlurper import io.myfinbox.TestServerApplication +import io.myfinbox.spendingplan.domain.JarExpenseCategories +import io.myfinbox.spendingplan.domain.JarIdentifier import org.skyscreamer.jsonassert.JSONAssert import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -20,10 +22,11 @@ import spock.lang.Specification import spock.lang.Tag import static io.myfinbox.spendingplan.DataSamples.* +import static io.myfinbox.spendingplan.domain.JarExpenseCategory.CategoryIdentifier import static org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT -import static org.springframework.http.HttpStatus.CREATED -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY +import static org.springframework.http.HttpMethod.PUT +import static org.springframework.http.HttpStatus.* import static org.springframework.http.MediaType.APPLICATION_JSON @Tag("integration") @@ -38,8 +41,11 @@ class JarControllerSpec extends Specification { @Autowired TestRestTemplate restTemplate + @Autowired + JarExpenseCategories jarExpenseCategories + def cleanup() { - JdbcTestUtils.deleteFromTables(jdbcTemplate, 'spending_jars', 'spending_plans') + JdbcTestUtils.deleteFromTables(jdbcTemplate, 'spending_jar_expense_category', 'spending_jars', 'spending_plans') } @Sql('/spendingplan/web/plan-create.sql') @@ -93,10 +99,56 @@ class JarControllerSpec extends Specification { JSONAssert.assertEquals(expectedCreationPercentageValidationFailure(), response.getBody(), LENIENT) } + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql', '/spendingplan/web/jar_expense_category-create.sql']) + def "should modify provided category list"() { + given: 'one plan, one jar with 2 existing categories, user wants to remove existing and add one new' + def category3Id = UUID.randomUUID() + var request = newSampleJarCategoriesResource(categories: [ + newSampleJarCategoryToAddAsMap(categoryId: jarCategoryId, toAdd: false), + newSampleJarCategoryToAddAsMap(categoryId: jarCategoryId2, toAdd: false), + newSampleJarCategoryToAddAsMap(categoryId: category3Id) + ]) + + when: 'modify categories' + var response = putJarCategories(request) + + then: 'response has status code ok' + assert response.getStatusCode() == OK + + and: 'only one category was recorded into the database' + assert jarExpenseCategories.findByJarId(new JarIdentifier(UUID.fromString(jarId))).size() == 1 + assert jarExpenseCategories.existsByJarIdAndCategoryId(new JarIdentifier(UUID.fromString(jarId)), new CategoryIdentifier(category3Id)) + } + + def "should fail to modify when plan jar not found"() { + given: 'user wants to modify categories for provided plan jar' + var request = newSampleJarCategoriesResource( + categories: [newSampleJarCategoryToAddAsMap(categoryId: jarCategoryId)] + ) + + when: 'modification fails' + var response = putJarCategories(request) + + then: 'response has status code not found' + assert response.getStatusCode() == NOT_FOUND + + and: 'response body contains validation failure response' + JSONAssert.assertEquals(expectedCategoriesModificationFailure(), response.getBody(), LENIENT) + } + def postJar(String req) { restTemplate.postForEntity("/v1/plans/${planId}/jars", entityRequest(req), String.class) } + def putJarCategories(String req) { + restTemplate.exchange( + "/v1/plans/${planId}/jars/${jarId}/expense-categories", + PUT, + entityRequest(req), + String.class + ) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) @@ -128,4 +180,12 @@ class JarControllerSpec extends Specification { def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile()) JsonOutput.toJson(failureAsMap) } + + def expectedCategoriesModificationFailure() { + JsonOutput.toJson([ + status : 404, + errorCode: "NOT_FOUND", + message : "Spending plan jar was not found." + ]) + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy new file mode 100644 index 0000000..896302e --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy @@ -0,0 +1,128 @@ +package io.myfinbox.spendingplan.application + +import io.myfinbox.shared.Failure +import io.myfinbox.spendingplan.domain.JarExpenseCategories +import io.myfinbox.spendingplan.domain.JarIdentifier +import io.myfinbox.spendingplan.domain.Jars +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.* +import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryService.* +import static io.myfinbox.spendingplan.domain.JarExpenseCategory.CategoryIdentifier +import static io.myfinbox.spendingplan.domain.Plan.PlanIdentifier +import static java.lang.Boolean.FALSE + +@Tag("unit") +class AddOrRemoveJarCategoryServiceSpec extends Specification { + + Jars jars + JarExpenseCategories jarExpenseCategories + AddOrRemoveJarCategoryService service + + def setup() { + jars = Mock() + jarExpenseCategories = Mock() + service = new AddOrRemoveJarCategoryService(jars, jarExpenseCategories) + } + + def "should fail modify categories to jar when invalid categories"() { + given: 'a new command with invalid categories' + def command = newSampleJarCategoriesCommand(categories: categories) + + when: 'attempting to add or remove an invalid categories command' + def either = service.addOrRemove(UUID.randomUUID(), UUID.randomUUID(), command) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates validation failure for add or remove categories request' + assert either.getLeft() == Failure.ofValidation(VALIDATION_CREATE_FAILURE_MESSAGE, [ + Failure.FieldViolation.builder() + .field('categories') + .message(failMessage) + .rejectedValue(command.categories()) + .build() + ]) + + where: + categories | failMessage + null | 'At least one category must be provided.' + [] | 'At least one category must be provided.' + [newSampleJarCategoryToAddAsMap(categoryId: null)] | 'Null categoryId not allowed.' + [newSampleJarCategoryToAddAsMap(), newSampleJarCategoryToAddAsMap()] | 'Duplicate category ids provided.' + } + + def "should fail modify categories to jar when null plan ID"() { + given: 'a new command' + def command = newSampleJarCategoriesCommand(categories: [newSampleJarCategoryToAddAsMap()]) + + when: 'attempting to add or remove categories for null plan id' + def either = service.addOrRemove(null, null, command) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates plan not found failure' + assert either.getLeft() == Failure.ofNotFound(PLAN_NOT_FOUND_MESSAGE) + } + + def "should fail modify categories to jar when null jar ID"() { + given: 'a new command' + def command = newSampleJarCategoriesCommand(categories: [newSampleJarCategoryToAddAsMap()]) + + when: 'attempting to add or remove categories for null jar id' + def either = service.addOrRemove(UUID.randomUUID(), null, command) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates jar not found failure' + assert either.getLeft() == Failure.ofNotFound(JAR_NOT_FOUND_MESSAGE) + } + + def "should fail modify categories to jar when plan jar not found"() { + given: 'a new command' + def command = newSampleJarCategoriesCommand(categories: [newSampleJarCategoryToAddAsMap()]) + + 1 * jars.findByIdAndPlanId(_ as JarIdentifier, _ as PlanIdentifier) >>Optional.empty() + + when: 'attempting to add or remove categories for null jar id' + def either = service.addOrRemove(UUID.randomUUID(), UUID.randomUUID(), command) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates plan jar not found failure' + assert either.getLeft() == Failure.ofNotFound(PLAN_JAR_NOT_FOUND_MESSAGE) + } + + def "should modify categories to plan jar successfully"() { + given: 'a new command' + def categoryId2 = UUID.randomUUID() + def command = newSampleJarCategoriesCommand(categories: [ + newSampleJarCategoryToAddAsMap(toAdd: null), newSampleJarCategoryToAddAsMap(categoryId: categoryId2, toAdd: false) + ]) + + 1 * jars.findByIdAndPlanId(_ as JarIdentifier, _ as PlanIdentifier) >> Optional.of(newSampleJar()) + 1 * jarExpenseCategories.existsByJarIdAndCategoryId(new JarIdentifier(UUID.fromString(jarId)), new CategoryIdentifier(UUID.fromString(jarCategoryId))) >> FALSE + + when: 'attempting to add or remove categories for null jar id' + def either = service.addOrRemove(UUID.randomUUID(), UUID.fromString(jarId), command) + + then: 'a success result is present' + assert either.isRight() + + and: 'the added categories are present' + assert either.get().size() == 1 + assert either.get().getFirst().getCategoryId() == new CategoryIdentifier(UUID.fromString(jarCategoryId)) + assert either.get().getFirst().getJar() == newSampleJar() + assert either.get().getFirst().getCreationTimestamp() != null + + and: 'jar to remove was invoked' + 1 * jarExpenseCategories.deleteByJarIdAndCategoryId(new JarIdentifier(UUID.fromString(jarId)), new CategoryIdentifier(categoryId2)) + + and: 'jar to add was invoked' + 1 * jarExpenseCategories.saveAll(_ as List) + } +} diff --git a/server/src/test/resources/spendingplan/web/jar_expense_category-create.sql b/server/src/test/resources/spendingplan/web/jar_expense_category-create.sql new file mode 100644 index 0000000..0e972b7 --- /dev/null +++ b/server/src/test/resources/spendingplan/web/jar_expense_category-create.sql @@ -0,0 +1,12 @@ +INSERT INTO server.spending_jar_expense_category(id, + jar_id, + category_id, + creation_timestamp) +VALUES (1, + 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + '2024-03-23T10:00:04.224870Z'), + (2, + 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + 'ee0a4cdc-84f0-4f81-8aea-224dad4915e7', + '2024-03-23T10:00:04.224870Z');