From bd27db4982665329ff6b06a4d2234e638179625f Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Wed, 1 May 2024 17:46:17 +0300 Subject: [PATCH] Expose REST API for creating a classic plan --- .../adapter/web/PlanController.java | 31 +++++-- .../spendingplan/adapter/web/PlansApi.java | 28 +++++- .../ClassicPlanBuilderService.java | 63 +++++++++++++ .../ClassicPlanBuilderUseCase.java | 32 +++++++ .../domain/ClassicJarDistribution.java | 66 +++++++++++++ .../io/myfinbox/spendingplan/domain/Jar.java | 2 +- .../io/myfinbox/spendingplan/domain/Plan.java | 2 +- server/src/main/resources/openapi.yml | 20 +++- .../myfinbox/spendingplan/DataSamples.groovy | 25 ++++- .../adapter/web/PlanControllerSpec.groovy | 57 ++++++++++++ .../ClassicPlanBuilderServiceSpec.groovy | 92 +++++++++++++++++++ .../application/CreatePlanServiceSpec.groovy | 2 +- ...lassic-plan-creation-failure-response.json | 19 ++++ 13 files changed, 424 insertions(+), 15 deletions(-) create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderService.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderUseCase.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/domain/ClassicJarDistribution.java create mode 100644 server/src/test/groovy/io/myfinbox/spendingplan/application/ClassicPlanBuilderServiceSpec.groovy create mode 100644 server/src/test/resources/spendingplan/web/classic-plan-creation-failure-response.json 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 1782580..b7b53e6 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 @@ -1,7 +1,10 @@ package io.myfinbox.spendingplan.adapter.web; -import io.myfinbox.rest.CreatePlanResource; +import io.myfinbox.rest.CreateClassicPlanResource; +import io.myfinbox.rest.PlanResource; import io.myfinbox.shared.ApiFailureHandler; +import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase; +import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand; import io.myfinbox.spendingplan.application.CreatePlanUseCase; import io.myfinbox.spendingplan.application.PlanCommand; import io.myfinbox.spendingplan.domain.Plan; @@ -22,17 +25,25 @@ final class PlanController implements PlansApi { private final CreatePlanUseCase createPlanUseCase; + private final ClassicPlanBuilderUseCase classicPlanBuilderUseCase; private final ApiFailureHandler apiFailureHandler; @PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) - public ResponseEntity create(@RequestBody CreatePlanResource resource) { + public ResponseEntity create(@RequestBody PlanResource resource) { return createPlanUseCase.create(toCommand(resource)) .fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id())) - .body(toCreatedResource(plan))); + .body(toResource(plan))); } - private CreatePlanResource toCreatedResource(Plan plan) { - return new CreatePlanResource() + @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))); + } + + private PlanResource toResource(Plan plan) { + return new PlanResource() .planId(plan.getId().id()) .name(plan.getName()) .creationTimestamp(plan.getCreationTimestamp().toString()) @@ -42,7 +53,7 @@ private CreatePlanResource toCreatedResource(Plan plan) { .description(plan.getDescription()); } - private PlanCommand toCommand(CreatePlanResource resource) { + private PlanCommand toCommand(PlanResource resource) { return PlanCommand.builder() .accountId(resource.getAccountId()) .name(resource.getName()) @@ -51,4 +62,12 @@ private PlanCommand toCommand(CreatePlanResource resource) { .description(resource.getDescription()) .build(); } + + private CreateClassicPlanCommand toCommand(CreateClassicPlanResource resource) { + return CreateClassicPlanCommand.builder() + .accountId(resource.getAccountId()) + .amount(resource.getAmount()) + .currencyCode(resource.getCurrencyCode()) + .build(); + } } 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 1a51387..8d17a80 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 @@ -1,6 +1,7 @@ package io.myfinbox.spendingplan.adapter.web; -import io.myfinbox.rest.CreatePlanResource; +import io.myfinbox.rest.CreateClassicPlanResource; +import io.myfinbox.rest.PlanResource; import io.myfinbox.shared.ApiErrorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.headers.Header; @@ -28,7 +29,7 @@ public interface PlansApi { @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "Spending plan created successfully", headers = @Header(name = LOCATION, description = "Created spending plan source URI location", schema = @Schema(implementation = URI.class)), - content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = CreatePlanResource.class))), + content = @Content(mediaType = APPLICATION_JSON_VALUE, 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 = "Not found Failure", @@ -40,6 +41,27 @@ public interface PlansApi { @ApiResponse(responseCode = "500", description = "Internal Server Error", content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) }) - ResponseEntity create(@RequestBody(description = "Spending Plan Resource to be created", required = true) CreatePlanResource resource); + ResponseEntity create(@RequestBody(description = "Spending Plan Resource to be created", required = true) PlanResource resource); + + @Operation(summary = "Add a new classic spending plan in the MyFinBox", + description = "Operation to add a classic plan distribution: Necessities(55%), Long Term Savings(10%), Education(10%), Play(10%), Financial(10%), Give(5%).", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Spending plan created successfully", + headers = @Header(name = LOCATION, description = "Created spending plan source URI location", schema = @Schema(implementation = URI.class)), + content = @Content(mediaType = APPLICATION_JSON_VALUE, 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 = "Not found Failure", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Spending plan name already exists", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "Field validation failures", + 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 createClassic(@RequestBody(description = "Classic spending Plan Resource to be created", required = true) CreateClassicPlanResource resource); } diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderService.java b/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderService.java new file mode 100644 index 0000000..5a1e6bc --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderService.java @@ -0,0 +1,63 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.shared.Failure; +import io.myfinbox.spendingplan.domain.ClassicJarDistribution; +import io.myfinbox.spendingplan.domain.Plan; +import io.vavr.control.Either; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +class ClassicPlanBuilderService implements ClassicPlanBuilderUseCase { + + static final String CLASSIC_SPENDING_PLAN = "My classic spending plan"; + static final String CLASSIC_PLAN_DESCRIPTION = "My classic plan distribution: Necessities(55%), Long Term Savings(10%), " + + "Education(10%), Play(10%), Financial(10%), Give(5%)."; + + private final CreatePlanUseCase createPlanUseCase; + private final CreateJarUseCase createJarUseCase; + + @Override + public Either create(CreateClassicPlanCommand command) { + log.debug("Classic spending plan creation ..."); + + var planCommand = PlanCommand.builder() + .accountId(command.accountId()) + .amount(command.amount()) + .currencyCode(command.currencyCode()) + .name(CLASSIC_SPENDING_PLAN) + .description(CLASSIC_PLAN_DESCRIPTION) + .build(); + + var planEither = createPlanUseCase.create(planCommand); + if (planEither.isLeft()) { + return planEither; + } + + var jarCommands = Arrays.stream(ClassicJarDistribution.values()) + .map(jar -> JarCommand.builder() + .name(jar.jarName()) + .percentage(jar.percentage()) + .description(jar.description()) + .build()) + .toList(); + + var planIdentifier = planEither.get().getId(); + for (var jarCommand : jarCommands) { + var jarEither = createJarUseCase.create(planIdentifier.id(), jarCommand); + if (jarEither.isLeft()) { + return Either.left(jarEither.getLeft()); + } + } + + log.debug("Classic spending plan was created '{}' , with 6 jars.", CLASSIC_SPENDING_PLAN); + return Either.right(planEither.get()); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderUseCase.java b/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderUseCase.java new file mode 100644 index 0000000..3f90637 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/ClassicPlanBuilderUseCase.java @@ -0,0 +1,32 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.shared.Failure; +import io.myfinbox.spendingplan.domain.Plan; +import io.vavr.control.Either; +import lombok.Builder; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Use case interface for creating classic plans. + */ +public interface ClassicPlanBuilderUseCase { + + /** + * Creates a classic plan based on the provided command. + * + * @param command The command containing information for creating the classic plan. + * @return Either a failure or the created plan. + */ + Either create(CreateClassicPlanCommand command); + + @Builder + record CreateClassicPlanCommand(UUID accountId, + BigDecimal amount, + String currencyCode) { + public static final String FIELD_ACCOUNT_ID = "accountId"; + public static final String FIELD_AMOUNT = "amount"; + public static final String FIELD_CURRENCY_CODE = "currencyCode"; + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/ClassicJarDistribution.java b/server/src/main/java/io/myfinbox/spendingplan/domain/ClassicJarDistribution.java new file mode 100644 index 0000000..61ee84a --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/ClassicJarDistribution.java @@ -0,0 +1,66 @@ +package io.myfinbox.spendingplan.domain; + +/** + * Enumeration representing the distribution of funds into classic jars. + * source 1 and + * source 2 + */ +public enum ClassicJarDistribution { + + /** + * Represents necessities spending: rent, food, bills, etc. + */ + NECESSITIES("Necessities", 55, + "Necessities spending: Rent, Food, Bills etc."), + + /** + * Represents long-term savings spending: big purchases, vacations, rainy day fund, unexpected medical expenses. + */ + LONG_TERM_SAVING("Long Term Savings", 10, + "Long Term Savings spending: Big Purchases, Vacations, Rainy Day Fund, Unexpected Medical Expenses."), + + /** + * Represents education spending: coaching, mentoring, books, courses, etc. + */ + EDUCATION("Education", 10, + "Education spending: Coaching, Mentoring, Books, Courses, etc."), + + /** + * Represents play spending: spoiling yourself & your family, leisure expenses, fun, etc. + */ + PLAY("Play", 10, + "Play spending: Spoiling yourself & your family, Leisure expenses, Fun, etc."), + + /** + * Represents financial spending: stocks, mutual funds, passive income vehicles, real estate investing, any other investments. + */ + FINANCIAL("Financial", 10, + "Financial spending: Stocks, Mutual Funds, Passive income Vehicles, Real Estate investing, Any other investments."), + + /** + * Represents give spending: charitable, donations. + */ + GIVE("Give", 5, "Give spending: Charitable, Donations."); + + private final String jarName; + private final int percentage; + private final String description; + + ClassicJarDistribution(String jarName, int percentage, String description) { + this.jarName = jarName; + this.percentage = percentage; + this.description = description; + } + + public String jarName() { + return jarName; + } + + public int percentage() { + return percentage; + } + + public String description() { + return description; + } +} 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 8a54d11..ed78c84 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Jar.java @@ -20,7 +20,7 @@ @Entity @Table(name = "spending_jars") @Getter -@ToString +@ToString(exclude = {"plan", "jarExpenseCategories"}) @EqualsAndHashCode(of = {"id", "name"}) @NoArgsConstructor(access = PACKAGE, force = true) public class Jar { 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 fa93e7c..09c3616 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Plan.java @@ -22,7 +22,7 @@ @Entity @Table(name = "spending_plans") @Getter -@ToString +@ToString(exclude = "jars") @EqualsAndHashCode(callSuper = false, of = {"id", "name", "account"}) @NoArgsConstructor(access = PACKAGE, force = true) public class Plan extends AbstractAggregateRoot { diff --git a/server/src/main/resources/openapi.yml b/server/src/main/resources/openapi.yml index 1b9eed3..75bf3ce 100644 --- a/server/src/main/resources/openapi.yml +++ b/server/src/main/resources/openapi.yml @@ -181,7 +181,7 @@ components: example: Base salary description: Additional description attached to the income. - CreatePlanResource: + PlanResource: type: object properties: planId: @@ -272,3 +272,21 @@ components: default: true example: true description: Flag indicating whether to add or remove the category. + + CreateClassicPlanResource: + type: object + properties: + accountId: + type: string + format: uuid + example: 3b257779-a5db-4e87-9365-72c6f8d4977d + description: The ID of the account that submitted the spending plan. + amount: + type: number + example: 1000 + description: The amount of the spending plan. + currencyCode: + type: string + example: MDL + pattern: '^[A-Z]{3}$' + description: The ISO 4217 currency code in uppercase (e.g., USD, EUR). diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy index 0023bc1..0fd5a94 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy @@ -13,6 +13,7 @@ import io.myfinbox.spendingplan.domain.JarExpenseCategory import io.myfinbox.spendingplan.domain.Plan import static io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoriesCommand +import static io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand import static io.myfinbox.spendingplan.application.ExpenseRecordTrackerUseCase.ExpenseModificationRecord class DataSamples { @@ -48,6 +49,12 @@ class DataSamples { description : "My basic plan for tracking expenses", ] + static CREATE_CLASSIC_PLAN_RESOURCE = [ + accountId : accountId, + amount : amount, + currencyCode: currency, + ] + static PLAN_COMMAND = [ name : name, accountId : accountId, @@ -56,6 +63,12 @@ class DataSamples { description : "My basic plan for tracking expenses", ] + static CLASSIC_PLAN_COMMAND = [ + accountId : accountId, + amount : amount, + currencyCode: currency + ] + static PLAN = [ id : [id: planId], name : name, @@ -112,9 +125,9 @@ class DataSamples { ] static EXPENSE_RECORD = [ - id: 1L, + id : 1L, expenseId : [id: expenseId], - categoryId : [id: jarCategoryId], + categoryId : [id: jarCategoryId], paymentType: "CASH", amount : AMOUNT, expenseDate: expenseDate, @@ -133,10 +146,18 @@ class DataSamples { JsonOutput.toJson(CREATE_PLAN_RESOURCE + map) as String } + static newSampleClassicCreatePlanResource(map = [:]) { + JsonOutput.toJson(CREATE_CLASSIC_PLAN_RESOURCE + map) as String + } + static newSampleCreatePlanCommand(map = [:]) { MAPPER.readValue(JsonOutput.toJson(PLAN_COMMAND + map) as String, PlanCommand.class) } + static newSampleClassicCreatePlanCommand(map = [:]) { + MAPPER.readValue(JsonOutput.toJson(CLASSIC_PLAN_COMMAND + map) as String, CreateClassicPlanCommand.class) + } + static newSamplePlan(map = [:]) { MAPPER.readValue(JsonOutput.toJson(PLAN + map) as String, Plan.class) } 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 c69bdc4..d8bd6a7 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 @@ -3,6 +3,7 @@ package io.myfinbox.spendingplan.adapter.web import groovy.json.JsonOutput import groovy.json.JsonSlurper import io.myfinbox.TestServerApplication +import io.myfinbox.spendingplan.domain.Jars import org.skyscreamer.jsonassert.JSONAssert import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -18,6 +19,7 @@ import org.springframework.test.jdbc.JdbcTestUtils import spock.lang.Specification import spock.lang.Tag +import static io.myfinbox.spendingplan.DataSamples.newSampleClassicCreatePlanResource import static io.myfinbox.spendingplan.DataSamples.newSampleCreatePlanResource import static org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT @@ -37,6 +39,9 @@ class PlanControllerSpec extends Specification { @Autowired TestRestTemplate restTemplate + @Autowired + Jars jars + def cleanup() { JdbcTestUtils.deleteFromTables(jdbcTemplate, 'spending_jars', 'spending_plans') } @@ -72,10 +77,48 @@ class PlanControllerSpec extends Specification { JSONAssert.assertEquals(expectedCreationFailure(), response.getBody(), LENIENT) } + def "should create a new classic spending plan"() { + given: 'user wants to create a classic spending plan' + var request = newSampleClassicCreatePlanResource() + + when: 'classic plan is created' + var response = postClassicPlan(request) + + then: 'response status is created' + assert response.getStatusCode() == CREATED + + and: 'location header contains the created classic spending plan URL location' + assert response.getHeaders().getLocation() != null + + and: 'body contains created resource' + JSONAssert.assertEquals(expectedClassicCreatedResource(response), response.getBody(), LENIENT) + + and: 'six jars were created' + assert jars.findAll().size() == 6 + } + + def "should fail classic plan creation when request has validation failures"() { + given: 'user wants to create a new spending plan' + var request = '{}' + + when: 'expense fails to create' + var response = postClassicPlan(request) + + then: 'response has status code unprocessable entity' + assert response.getStatusCode() == UNPROCESSABLE_ENTITY + + and: 'response body contains validation failure response' + JSONAssert.assertEquals(expectedClassicCreationFailure(), response.getBody(), LENIENT) + } + def postPlan(String req) { restTemplate.postForEntity('/v1/plans', entityRequest(req), String.class) } + def postClassicPlan(String req) { + restTemplate.postForEntity('/v1/plans/classic', entityRequest(req), String.class) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) @@ -94,9 +137,23 @@ class PlanControllerSpec extends Specification { ) } + def expectedClassicCreatedResource(ResponseEntity response) { + newSampleCreatePlanResource( + planId: idFromLocation(response.getHeaders().getLocation()), + name: 'My classic spending plan', + description: 'My classic plan distribution: Necessities(55%), Long Term Savings(10%), Education(10%), Play(10%), Financial(10%), Give(5%).' + ) + } + def expectedCreationFailure() { def filePath = 'spendingplan/web/plan-creation-failure-response.json' def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile()) JsonOutput.toJson(failureAsMap) } + + def expectedClassicCreationFailure() { + def filePath = 'spendingplan/web/classic-plan-creation-failure-response.json' + def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile()) + JsonOutput.toJson(failureAsMap) + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/ClassicPlanBuilderServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/ClassicPlanBuilderServiceSpec.groovy new file mode 100644 index 0000000..98bc906 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/ClassicPlanBuilderServiceSpec.groovy @@ -0,0 +1,92 @@ +package io.myfinbox.spendingplan.application + +import io.myfinbox.shared.Failure +import io.vavr.control.Either +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.newSampleClassicCreatePlanCommand +import static io.myfinbox.spendingplan.DataSamples.newSampleJar +import static io.myfinbox.spendingplan.DataSamples.newSamplePlan + +@Tag("unit") +class ClassicPlanBuilderServiceSpec extends Specification { + + CreatePlanUseCase createPlanUseCase + CreateJarUseCase createJarUseCase + ClassicPlanBuilderService service + + def setup() { + createPlanUseCase = Mock() + createJarUseCase = Mock() + service = new ClassicPlanBuilderService(createPlanUseCase, createJarUseCase) + } + + def "should fail classic plan creation when plan validation failure"() { + given: 'a plan validation failure' + 1 * createPlanUseCase.create(_ as PlanCommand) >> Either.left( + Failure.ofValidation("Plan validation failure", [ + Failure.FieldViolation.builder() + .field('accountId') + .message('AccountId cannot be null.') + .build() + ]) + ) + + when: 'attempting to create a classic plan' + def either = service.create(newSampleClassicCreatePlanCommand()) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates validation failure for create plan request' + assert either.getLeft() == Failure.ofValidation("Plan validation failure", [ + Failure.FieldViolation.builder() + .field('accountId') + .message('AccountId cannot be null.') + .build() + ]) + } + + def "should fail classic plan creation when jar validation failure"() { + given: 'a valid plan and a jar validation failure' + 1 * createPlanUseCase.create(_ as PlanCommand) >> Either.right(newSamplePlan()) + 1 * createJarUseCase.create(_ as UUID, _ as JarCommand) >> Either.left( + Failure.ofValidation("Jar validation failure", [ + Failure.FieldViolation.builder() + .field('percentage') + .message('Percentage cannot be null.') + .build() + ]) + ) + + when: 'attempting to create a classic plan' + def either = service.create(newSampleClassicCreatePlanCommand()) + + then: 'a failure result is present' + assert either.isLeft() + + and: 'the failure message indicates validation failure for create plan request' + assert either.getLeft() == Failure.ofValidation("Jar validation failure", [ + Failure.FieldViolation.builder() + .field('percentage') + .message('Percentage cannot be null.') + .build() + ]) + } + + def "should create a classic plan creation successfully"() { + given: 'a valid plan and valid jars' + 1 * createPlanUseCase.create(_ as PlanCommand) >> Either.right(newSamplePlan()) + 6 * createJarUseCase.create(_ as UUID, _ as JarCommand) >> Either.right(newSampleJar()) + + when: 'attempting to create a classic plan' + def either = service.create(newSampleClassicCreatePlanCommand()) + + then: 'plan value is present' + assert either.isRight() + + and: 'plan is build as expected' + assert either.get() == newSamplePlan() + } +} diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/CreatePlanServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/CreatePlanServiceSpec.groovy index 9e7ca4c..04cea3c 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/application/CreatePlanServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/CreatePlanServiceSpec.groovy @@ -26,7 +26,7 @@ class CreatePlanServiceSpec extends Specification { } def "should fail plan creation when accountId is null"() { - given: 'a new income command with null accountId' + given: 'a new plan command with null accountId' def command = newSampleCreatePlanCommand(accountId: null) when: 'attempting to create a plan with a null accountId' diff --git a/server/src/test/resources/spendingplan/web/classic-plan-creation-failure-response.json b/server/src/test/resources/spendingplan/web/classic-plan-creation-failure-response.json new file mode 100644 index 0000000..79f93df --- /dev/null +++ b/server/src/test/resources/spendingplan/web/classic-plan-creation-failure-response.json @@ -0,0 +1,19 @@ +{ + "status": 422, + "errorCode": "UNPROCESSABLE_ENTITY", + "message": "Validation failed for the create spending plan request.", + "errors": [ + { + "field": "accountId", + "message": "AccountId cannot be null." + }, + { + "field": "amount", + "message": "Amount cannot be null." + }, + { + "field": "currencyCode", + "message": "CurrencyCode cannot be null." + } + ] +}