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 0cb86eb..e4c3fa3 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,14 +1,14 @@ package io.myfinbox.spendingplan.adapter.web; -import io.myfinbox.rest.CreateJarResource; import io.myfinbox.rest.JarCategoryModificationResource; +import io.myfinbox.rest.JarResource; 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; import lombok.RequiredArgsConstructor; +import org.springframework.core.convert.ConversionService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -29,12 +29,13 @@ final class JarController implements JarsApi { private final CreateJarUseCase createJarUseCase; private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase; private final ApiFailureHandler apiFailureHandler; + private final ConversionService conversionService; @PostMapping(path = "/{planId}/jars", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) - public ResponseEntity create(@PathVariable UUID planId, @RequestBody CreateJarResource resource) { + public ResponseEntity create(@PathVariable UUID planId, @RequestBody JarResource resource) { return createJarUseCase.create(planId, toCommand(resource)) .fold(apiFailureHandler::handle, jar -> created(fromCurrentRequest().path("/{id}").build(jar.getId().id())) - .body(toResource(jar))); + .body(conversionService.convert(jar, JarResource.class))); } @PutMapping(path = "/{planId}/jars/{jarId}/expense-categories", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) @@ -45,7 +46,7 @@ public ResponseEntity modifyExpenseCategories(@PathVariable UUID planId, .fold(apiFailureHandler::handle, ok -> ok().build()); } - private JarCommand toCommand(CreateJarResource resource) { + private JarCommand toCommand(JarResource resource) { return JarCommand.builder() .name(resource.getName()) .percentage(resource.getPercentage()) @@ -53,17 +54,6 @@ private JarCommand toCommand(CreateJarResource resource) { .build(); } - private CreateJarResource toResource(Jar jar) { - return new CreateJarResource() - .jarId(jar.getId().id()) - .creationTimestamp(jar.getCreationTimestamp().toString()) - .amountToReach(jar.getAmountToReachAsNumber()) - .currencyCode(jar.getCurrencyCode()) - .name(jar.getName()) - .percentage(jar.getPercentage().value()) - .description(jar.getDescription()); - } - private JarCategoriesCommand toCommand(JarCategoryModificationResource resource) { var categoryToAdds = resource.getCategories() .stream() 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 2d176e2..5415b6f 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,7 +1,7 @@ package io.myfinbox.spendingplan.adapter.web; -import io.myfinbox.rest.CreateJarResource; import io.myfinbox.rest.JarCategoryModificationResource; +import io.myfinbox.rest.JarResource; import io.myfinbox.shared.ApiErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -32,7 +32,7 @@ public interface JarsApi { @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Successful Operation", headers = @Header(name = LOCATION, description = "Created jar source URI location", schema = @Schema(implementation = URI.class)), - content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = CreateJarResource.class))), + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = JarResource.class))), @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 not found", @@ -45,7 +45,7 @@ public interface JarsApi { content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) }) 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); + @RequestBody(description = "Jar Resource to be created", required = true) JarResource resource); @Operation( summary = "Modify Expense Categories for Spending Plan Jar", diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java index c55e0c5..8d7b8fb 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java @@ -3,13 +3,11 @@ import io.myfinbox.rest.CreateClassicPlanResource; import io.myfinbox.rest.PlanResource; import io.myfinbox.shared.ApiFailureHandler; -import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase; +import io.myfinbox.shared.Failure; +import io.myfinbox.spendingplan.application.*; import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand; -import io.myfinbox.spendingplan.application.CreatePlanUseCase; -import io.myfinbox.spendingplan.application.PlanCommand; -import io.myfinbox.spendingplan.application.UpdatePlanUseCase; -import io.myfinbox.spendingplan.domain.Plan; import lombok.RequiredArgsConstructor; +import org.springframework.core.convert.ConversionService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -28,37 +26,52 @@ final class PlanController implements PlansApi { private final CreatePlanUseCase createPlanUseCase; private final ClassicPlanBuilderUseCase classicPlanBuilderUseCase; private final UpdatePlanUseCase updatePlanUseCase; + private final PlanQuery planQuery; private final ApiFailureHandler apiFailureHandler; + private final ConversionService conversionService; @PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) public ResponseEntity create(@RequestBody PlanResource resource) { return createPlanUseCase.create(toCommand(resource)) .fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id())) - .body(toResource(plan))); + .body(conversionService.convert(plan, PlanResource.class))); } @PostMapping(path = "/classic", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) public ResponseEntity createClassic(@RequestBody CreateClassicPlanResource resource) { return classicPlanBuilderUseCase.create(toCommand(resource)) .fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id())) - .body(toResource(plan))); + .body(conversionService.convert(plan, PlanResource.class))); } @PutMapping(path = "/{planId}", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) public ResponseEntity update(@PathVariable UUID planId, @RequestBody PlanResource resource) { return updatePlanUseCase.update(planId, toCommand(resource)) - .fold(apiFailureHandler::handle, plan -> ok().body(toResource(plan))); + .fold(apiFailureHandler::handle, plan -> ok().body(conversionService.convert(plan, PlanResource.class))); } - private PlanResource toResource(Plan plan) { - return new PlanResource() - .planId(plan.getId().id()) - .name(plan.getName()) - .creationTimestamp(plan.getCreationTimestamp().toString()) - .amount(plan.getAmountAsNumber()) - .currencyCode(plan.getCurrencyCode()) - .accountId(plan.getAccount().id()) - .description(plan.getDescription()); + @GetMapping(path = "/{planId}", produces = APPLICATION_JSON_VALUE) + public ResponseEntity one(@PathVariable UUID planId) { + var plans = planQuery.search() + .withPlanId(planId) + .list(); + + if (plans.isEmpty()) { + return apiFailureHandler.handle(Failure.ofNotFound("Plan with ID '%s' was not found.".formatted(planId))); + } + + return ok().body(conversionService.convert(plans.getFirst(), PlanResource.class)); + } + + @GetMapping(produces = APPLICATION_JSON_VALUE) + public ResponseEntity list(@RequestParam("accountId") UUID accountId) { + var plans = planQuery.search() + .withAccountId(accountId) + .list(); + + return ok().body(plans.stream() + .map(plan -> conversionService.convert(plan, PlanResource.class)) + .toList()); } private PlanCommand toCommand(PlanResource resource) { diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlansApi.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlansApi.java index 3ac5b66..b310ff8 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlansApi.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlansApi.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -18,6 +19,7 @@ import java.util.UUID; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.QUERY; import static org.springframework.http.HttpHeaders.LOCATION; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -84,4 +86,35 @@ public interface PlansApi { }) ResponseEntity update(@Parameter(in = PATH, description = "PlanId to be updated", required = true) UUID planId, @RequestBody(description = "Spending Plan Resource to be updated", required = true) PlanResource resource); + + @Operation(summary = "Query a list of spending plans for a specified account in the MyFinBox", + description = "Query a list of spending plans for a specified account in the MyFinBox", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful Operation", + content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = PlanResource.class)))), + @ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure", + 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 list(@Parameter(in = QUERY, description = "Account to be used for selecting spending plans", required = true) UUID accountId); + + @Operation(summary = "Query a spending plan for a specified plan ID in the MyFinBox", + description = "Query a spending plan for a specified plan ID in the MyFinBox", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful Operation", + content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = PlanResource.class)))), + @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 = "Plan not found Failure", + 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 one(@Parameter(in = PATH, description = "Plan Id to be used for searching", required = true) UUID planId); + } diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverter.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverter.java new file mode 100644 index 0000000..52b5ba5 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverter.java @@ -0,0 +1,22 @@ +package io.myfinbox.spendingplan.adapter.web.converters; + +import io.myfinbox.rest.JarResource; +import io.myfinbox.spendingplan.domain.Jar; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +final class JarToResourceConverter implements Converter { + + @Override + public JarResource convert(Jar jar) { + return new JarResource() + .jarId(jar.getId().id()) + .creationTimestamp(jar.getCreationTimestamp().toString()) + .amountToReach(jar.getAmountToReachAsNumber()) + .currencyCode(jar.getCurrencyCode()) + .name(jar.getName()) + .percentage(jar.getPercentage().value()) + .description(jar.getDescription()); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverter.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverter.java new file mode 100644 index 0000000..c206c27 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverter.java @@ -0,0 +1,31 @@ +package io.myfinbox.spendingplan.adapter.web.converters; + +import io.myfinbox.rest.JarResource; +import io.myfinbox.rest.PlanResource; +import io.myfinbox.spendingplan.domain.Jar; +import io.myfinbox.spendingplan.domain.Plan; +import lombok.RequiredArgsConstructor; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +final class PlanToResourceConverter implements Converter { + + private final Converter jarResourceConverter; + + @Override + public PlanResource convert(Plan plan) { + return new PlanResource() + .planId(plan.getId().id()) + .name(plan.getName()) + .creationTimestamp(plan.getCreationTimestamp().toString()) + .amount(plan.getAmountAsNumber()) + .currencyCode(plan.getCurrencyCode()) + .accountId(plan.getAccount().id()) + .description(plan.getDescription()) + .jars(plan.getJars().stream() + .map(jarResourceConverter::convert) + .toList()); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/PlanQuery.java b/server/src/main/java/io/myfinbox/spendingplan/application/PlanQuery.java new file mode 100644 index 0000000..c4d2e23 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/PlanQuery.java @@ -0,0 +1,48 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.Plan; + +import java.util.List; +import java.util.UUID; + +/** + * Represents a query interface for searching and retrieving plans. + */ +public interface PlanQuery { + + /** + * Initiates a search for plans. + * + * @return a builder to further customize the plan search. + */ + PlanQueryBuilder search(); + + /** + * Builder interface for constructing and executing a plan query. + */ + interface PlanQueryBuilder { + + /** + * Filters the plans by the specified plan ID. + * + * @param planId the unique identifier of the plan. + * @return the updated query builder. + */ + PlanQueryBuilder withPlanId(UUID planId); + + /** + * Filters the plans by the specified account ID. + * + * @param accountId the unique identifier of the account. + * @return the updated query builder. + */ + PlanQueryBuilder withAccountId(UUID accountId); + + /** + * Executes the query and returns a list of plans matching the criteria. + * + * @return a list of plans matching the query criteria. + */ + List list(); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/PlanQueryService.java b/server/src/main/java/io/myfinbox/spendingplan/application/PlanQueryService.java new file mode 100644 index 0000000..013fd97 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/PlanQueryService.java @@ -0,0 +1,70 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.AccountIdentifier; +import io.myfinbox.spendingplan.domain.Plan; +import io.myfinbox.spendingplan.domain.Plans; +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.Plan.PlanIdentifier; +import static java.util.Collections.emptyList; +import static java.util.Objects.nonNull; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +class PlanQueryService implements PlanQuery { + + private final Plans plans; + + @Override + public PlanQueryBuilder search() { + return new DefaultPlanQueryBuilder(plans); + } + + @RequiredArgsConstructor + private static class DefaultPlanQueryBuilder implements PlanQueryBuilder { + + private final Plans plans; + + private UUID planId; + private UUID accountId; + + @Override + public PlanQueryBuilder withPlanId(UUID planId) { + this.planId = planId; + return this; + } + + @Override + public PlanQueryBuilder withAccountId(UUID accountId) { + this.accountId = accountId; + return this; + } + + @Override + public List list() { + if (nonNull(planId) && nonNull(accountId)) { + return plans.findByIdAndAccountIdEagerJars(new PlanIdentifier(planId), new AccountIdentifier(accountId)) + .map(List::of) + .orElse(emptyList()); + } + + if (nonNull(accountId)) { + return plans.findByAccountIdEagerJars(new AccountIdentifier(accountId)); + } + + if (nonNull(planId)) { + return plans.findByIdEagerJars(new PlanIdentifier(planId)) + .map(List::of) + .orElse(emptyList()); + } + + return emptyList(); + } + } +} 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 ffad8f6..a7d061a 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java @@ -18,6 +18,8 @@ import static io.myfinbox.shared.Guards.*; import static jakarta.persistence.CascadeType.ALL; +import static java.math.RoundingMode.HALF_UP; +import static java.util.Objects.isNull; import static java.util.Objects.requireNonNull; import static lombok.AccessLevel.PACKAGE; @@ -55,13 +57,15 @@ public class Plan extends AbstractAggregateRoot { public Plan(AccountIdentifier account, MonetaryAmount amount, String name, - String description) { + String description, + List jars) { this.id = new PlanIdentifier(UUID.randomUUID()); this.creationTimestamp = Instant.now(); this.account = notNull(account, "account cannot be null"); setAmount(amount); setName(name); this.description = description; + this.jars = isNull(jars) || jars.isEmpty() ? new ArrayList<>() : jars; } private void setName(String name) { @@ -74,7 +78,8 @@ private void setAmount(MonetaryAmount amount) { } public BigDecimal getAmountAsNumber() { - return amount.getNumber().numberValue(BigDecimal.class); + return amount.getNumber().numberValue(BigDecimal.class) + .setScale(2, HALF_UP); } public String getCurrencyCode() { diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/Plans.java b/server/src/main/java/io/myfinbox/spendingplan/domain/Plans.java index 0b9224a..0b5aac4 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Plans.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Plans.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -12,12 +13,24 @@ public interface Plans extends JpaRepository { @Query(value = """ SELECT p FROM Plan p - LEFT JOIN FETCH Jar j - ON p.id = j.plan.id + LEFT JOIN FETCH p.jars WHERE p.id = :id """) Optional findByIdEagerJars(PlanIdentifier id); - boolean existsByNameAndAccount(String name, AccountIdentifier accountId); + @Query(value = """ + SELECT p FROM Plan p + LEFT JOIN FETCH p.jars + WHERE p.id = :id AND p.account = :account + """) + Optional findByIdAndAccountIdEagerJars(PlanIdentifier id, AccountIdentifier account); + @Query(value = """ + SELECT p FROM Plan p + LEFT JOIN FETCH p.jars + WHERE p.account = :account + """) + List findByAccountIdEagerJars(AccountIdentifier account); + + boolean existsByNameAndAccount(String name, AccountIdentifier accountId); } diff --git a/server/src/main/resources/openapi.yml b/server/src/main/resources/openapi.yml index 16fc5ff..bef4839 100644 --- a/server/src/main/resources/openapi.yml +++ b/server/src/main/resources/openapi.yml @@ -230,8 +230,14 @@ components: type: string example: My base plan for tracking all spending description: Additional description attached to the spending plan. + jars: + type: array + description: Jars created for current Plan + readOnly: true + items: + $ref: '#/components/schemas/JarResource' - CreateJarResource: + JarResource: type: object properties: jarId: diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy index 0fd5a94..3e5a048 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy @@ -7,10 +7,8 @@ import io.myfinbox.expense.ExpenseDeleted import io.myfinbox.expense.ExpenseUpdated import io.myfinbox.spendingplan.application.JarCommand import io.myfinbox.spendingplan.application.PlanCommand -import io.myfinbox.spendingplan.domain.ExpenseRecord -import io.myfinbox.spendingplan.domain.Jar -import io.myfinbox.spendingplan.domain.JarExpenseCategory -import io.myfinbox.spendingplan.domain.Plan +import io.myfinbox.spendingplan.domain.* +import org.javamoney.moneta.Money import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand import static io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand @@ -30,11 +28,13 @@ class DataSamples { 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 amount = 1000.00 static currency = 'EUR' static name = 'My basic plan' static jarName = 'Necessities' static expenseDate = "2024-03-23" + static String jarDescription = "Necessities spending: Rent, Food, Bills etc." + static String planDescription = "My basic plan for tracking expenses" static AMOUNT = [ amount : amount, @@ -46,7 +46,7 @@ class DataSamples { accountId : accountId, amount : amount, currencyCode: currency, - description : "My basic plan for tracking expenses", + description : planDescription, ] static CREATE_CLASSIC_PLAN_RESOURCE = [ @@ -60,7 +60,7 @@ class DataSamples { accountId : accountId, amount : amount, currencyCode: currency, - description : "My basic plan for tracking expenses", + description : planDescription, ] static CLASSIC_PLAN_COMMAND = [ @@ -70,33 +70,55 @@ class DataSamples { ] static PLAN = [ - id : [id: planId], - name : name, - account : [id: accountId], - amount : AMOUNT, - description: "My basic plan for tracking expenses", + id : [id: planId], + name : name, + account : [id: accountId], + creationTimestamp: timestamp, + amount : AMOUNT, + description : planDescription + ] + + static PLAN_PLAIN = [ + planId : planId, + name : name, + accountId : accountId, + creationTimestamp: timestamp, + amount : amount, + currencyCode : currency, + description : planDescription ] static CREATE_JAR_RESOURCE = [ name : 'Necessities', percentage : 55, - description: "Necessities spending: Rent, Food, Bills etc.", + description: jarDescription, ] static JAR_COMMAND = [ name : jarName, percentage : 55, - description: "Necessities spending: Rent, Food, Bills etc." + description: jarDescription ] static JAR = [ - id : [id: jarId], - percentage : [value: 55], - amountToReach: AMOUNT + [amount: 550], - name : jarName, - description : "Necessities spending: Rent, Food, Bills etc.", + id : [id: jarId], + percentage : [value: 55], + amountToReach : AMOUNT + [amount: 550], + creationTimestamp: timestamp, + name : jarName, + description : jarDescription, ] + static JAR_PLAIN = [ + jarId : jarId, + percentage : 55, + amountToReach : 550.00, + creationTimestamp: timestamp, + name : jarName, + description : jarDescription, + ] + + static JAR_CATEGORIES_COMMAND = [ categories: [JAR_CATEGORY_TO_ADD_OR_REMOVE] ] @@ -162,6 +184,23 @@ class DataSamples { MAPPER.readValue(JsonOutput.toJson(PLAN + map) as String, Plan.class) } + static newSamplePlanWithJarAsString() { + JsonOutput.toJson(PLAN_PLAIN + [jars: [JAR_PLAIN]]) + } + + static newSampleListPlanWithJarAsString() { + JsonOutput.toJson([PLAN_PLAIN + [jars: [JAR_PLAIN]]]) + } + + static newSamplePlanBuilder() { + Plan.builder() + .name(name) + .account(new AccountIdentifier(UUID.fromString(accountId))) + .amount(Money.of(BigDecimal.valueOf(amount), currency)) + .description(planDescription) + .jars([newSampleJar()]) + } + static newSampleCreateJarResource(map = [:]) { JsonOutput.toJson(CREATE_JAR_RESOURCE + map) as String } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy index efed66b..a6255d0 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy @@ -11,12 +11,14 @@ import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod import org.springframework.http.ResponseEntity import org.springframework.jdbc.core.JdbcTemplate import org.springframework.modulith.test.ApplicationModuleTest import org.springframework.test.context.TestPropertySource import org.springframework.test.context.jdbc.Sql import org.springframework.test.jdbc.JdbcTestUtils +import org.springframework.web.util.UriComponentsBuilder import spock.lang.Specification import spock.lang.Tag @@ -150,6 +152,41 @@ class PlanControllerSpec extends Specification { JSONAssert.assertEquals(expectedFailure, response.getBody(), LENIENT) } + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql']) + def "should get one existing spending plan"() { + when: 'getting one plan' + def response = getOnePlan(UUID.fromString(planId)) + + then: 'the response status is "OK"' + assert response.getStatusCode() == OK + + and: 'the response body contains the expected resource' + JSONAssert.assertEquals(newSamplePlanWithJarAsString(), response.getBody(), LENIENT) + } + + def "should get not found when spending plan doesn't exit"() { + when: 'getting one plan' + def response = getOnePlan(UUID.fromString(planId)) + + then: 'the response status is "NOT_FOUND"' + assert response.getStatusCode() == NOT_FOUND + + and: 'the response body contains the not found failure' + JSONAssert.assertEquals(expectedPlanNotFoundFailure(), response.getBody(), LENIENT) + } + + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql']) + def "should list a list with one spending plan for provided account"() { + when: 'listing plans by accountId' + def response = listPlans(UUID.fromString(accountId)) + + then: 'the response status is "OK"' + assert response.getStatusCode() == OK + + and: 'the response body contains the expected resource' + JSONAssert.assertEquals(newSampleListPlanWithJarAsString(), response.getBody(), LENIENT) + } + def postPlan(String req) { restTemplate.postForEntity('/v1/plans', entityRequest(req), String.class) } @@ -167,6 +204,33 @@ class PlanControllerSpec extends Specification { ) } + def getOnePlan(UUID planId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}") + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + + def listPlans(UUID accountId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans") + .queryParam("accountId", accountId) + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) @@ -219,4 +283,12 @@ class PlanControllerSpec extends Specification { def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile()) JsonOutput.toJson(failureAsMap) } + + def expectedPlanNotFoundFailure() { + JsonOutput.toJson([ + status : 404, + errorCode: "NOT_FOUND", + message : "Plan with ID '${planId}' was not found." + ]) + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverterSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverterSpec.groovy new file mode 100644 index 0000000..bb262d0 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/JarToResourceConverterSpec.groovy @@ -0,0 +1,30 @@ +package io.myfinbox.spendingplan.adapter.web.converters + +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.* + +@Tag("unit") +class JarToResourceConverterSpec extends Specification { + + JarToResourceConverter converter + + def setup() { + converter = new JarToResourceConverter(); + } + + def "should convert jar to resource"() { + when: 'converting a jar' + def resource = converter.convert(newSampleJar()) + + then: 'resource is build as expected' + assert resource.getJarId() == UUID.fromString(jarId) + assert resource.getCreationTimestamp() == timestamp + assert resource.getAmountToReach() == BigDecimal.valueOf(550) + assert resource.getCurrencyCode() == currency + assert resource.getPercentage() == 55 + assert resource.getName() == jarName + assert resource.getDescription() == jarDescription + } +} diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverterSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverterSpec.groovy new file mode 100644 index 0000000..43ac442 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/converters/PlanToResourceConverterSpec.groovy @@ -0,0 +1,41 @@ +package io.myfinbox.spendingplan.adapter.web.converters + +import io.myfinbox.rest.JarResource +import io.myfinbox.spendingplan.domain.Jar +import org.springframework.core.convert.converter.Converter +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.* + +@Tag("unit") +class PlanToResourceConverterSpec extends Specification { + + Converter jarResourceConverter + PlanToResourceConverter converter + + def setup() { + jarResourceConverter = Mock() + converter = new PlanToResourceConverter(jarResourceConverter) + } + + def "should convert plan to resource"() { + setup: 'jar converter get a sample jar resource' + 1 * jarResourceConverter.convert(_ as Jar) >> new JarResource() + + when: 'converting a plan' + def samplePlan = newSamplePlanBuilder(); + + def resource = converter.convert(samplePlan.build()) + + then: 'resource is build a expected' + assert resource.getAccountId() == UUID.fromString(accountId) + assert resource.getPlanId() != null + assert resource.getName() == name + assert resource.getCreationTimestamp() != null + assert resource.getAmount() == amount + assert resource.getCurrencyCode() == currency + assert resource.getDescription() == planDescription + assert resource.getJars().size() == 1 + } +} diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/PlanQueryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/PlanQueryServiceSpec.groovy new file mode 100644 index 0000000..910b082 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/PlanQueryServiceSpec.groovy @@ -0,0 +1,69 @@ +package io.myfinbox.spendingplan.application + +import io.myfinbox.spendingplan.domain.AccountIdentifier +import io.myfinbox.spendingplan.domain.Plan +import io.myfinbox.spendingplan.domain.Plans +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.* + +@Tag("unit") +class PlanQueryServiceSpec extends Specification { + + Plans plans + PlanQueryService service + + def setup() { + plans = Mock() + service = new PlanQueryService(plans) + } + + def "should get empty list when accountId and planId are not provided" () { + when: 'no accountId or planId is provided in the search query' + def plans = service.search().list() + + then: 'the service returns an empty list of plans' + assert plans.isEmpty() + } + + def "should get a list with one plan when accountId and planId are provided" () { + setup: 'stub the plans repository to return a plan when both accountId and planId are provided' + 1 * plans.findByIdAndAccountIdEagerJars(_ as Plan.PlanIdentifier, _ as AccountIdentifier) >> Optional.of(newSamplePlan()) + + when: 'search is performed with both accountId and planId' + def plans = service.search() + .withAccountId(UUID.fromString(accountId)) + .withPlanId(UUID.fromString(planId)) + .list() + + then: 'the service returns a list containing one plan' + assert plans.size() == 1 + } + + def "should get a list with one plan when accountId is provided" () { + setup: 'stub the plans repository to return a plan when accountId is provided' + 1 * plans.findByAccountIdEagerJars(_ as AccountIdentifier) >> [newSamplePlan()] + + when: 'search is performed with only accountId' + def plans = service.search() + .withAccountId(UUID.fromString(accountId)) + .list() + + then: 'the service returns a list containing one plan' + assert plans.size() == 1 + } + + def "should get a list with one plan when planId is provided" () { + setup: 'stub the plans repository to return a plan when planId is provided' + 1 * plans.findByIdEagerJars(_ as Plan.PlanIdentifier) >> Optional.of(newSamplePlan()) + + when: 'search is performed with only planId' + def plans = service.search() + .withPlanId(UUID.fromString(planId)) + .list() + + then: 'the service returns a list containing one plan' + assert plans.size() == 1 + } +}