From 9b474c5ec7718ecb4a9ec76de32edcafc55cf2f8 Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Sun, 24 Mar 2024 10:32:41 +0200 Subject: [PATCH] Expose REST API for update an expense category --- .../web/ExpenseCategoryController.java | 16 ++- .../web/ExpenseCategoryControllerApi.java | 21 +++ .../expense/application/CategoryService.java | 9 ++ .../application/DefaultCategoryService.java | 34 +++++ .../io/myfinbox/expense/domain/Category.java | 10 +- .../web/ExpenseCategoryControllerSpec.groovy | 67 +++++++++- .../DefaultCategoryServiceSpec.groovy | 126 +++++++++++++++++- 7 files changed, 269 insertions(+), 14 deletions(-) diff --git a/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryController.java b/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryController.java index 5f12811..538b55b 100644 --- a/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryController.java +++ b/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryController.java @@ -6,18 +6,18 @@ import io.myfinbox.shared.ExpenseCategoryResource; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; import static io.myfinbox.expense.application.CategoryService.CategoryCommand; 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 -@RequestMapping(path = "/expenses/categories") +@RequestMapping(path = "/expenses/category") @RequiredArgsConstructor final class ExpenseCategoryController implements ExpenseCategoryControllerApi { @@ -32,6 +32,12 @@ public ResponseEntity create(@RequestBody ExpenseCategoryResource resource) { .body(toResource(category))); } + @PutMapping(path = "/{categoryId}", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) + public ResponseEntity update(@PathVariable UUID categoryId, @RequestBody ExpenseCategoryResource resource) { + return categoryService.update(categoryId, new CategoryCommand(resource.getName(), resource.getAccountId())) + .fold(apiFailureHandler::handle, category -> ok(toResource(category))); + } + private ExpenseCategoryResource toResource(Category category) { return new ExpenseCategoryResource() .categoryId(category.getId().id()) diff --git a/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerApi.java b/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerApi.java index a6e0d7b..322dbd2 100644 --- a/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerApi.java +++ b/server/src/main/java/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerApi.java @@ -3,6 +3,7 @@ import io.myfinbox.shared.ApiErrorResponse; import io.myfinbox.shared.ExpenseCategoryResource; 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.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -13,6 +14,7 @@ import org.springframework.http.ResponseEntity; import java.net.URI; +import java.util.UUID; import static io.myfinbox.expense.adapter.web.ExpenseControllerApi.TAG; import static org.springframework.http.HttpHeaders.LOCATION; @@ -38,4 +40,23 @@ public interface ExpenseCategoryControllerApi { }) ResponseEntity create(@RequestBody(description = "Expense Category Resource to be created", required = true) ExpenseCategoryResource resource); + @Operation(summary = "Update an expense category name in the MyFinBox", description = "Update an expense category name in the MyFinBox", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful Operation", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ExpenseCategoryResource.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 = "Category not found", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Category name already exists", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "422", description = "Request Schema Validation 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 update(@Parameter(description = "CategoryId to be updated", required = true) UUID categoryId, + @RequestBody(description = "Name to be updated", required = true) ExpenseCategoryResource resource); } diff --git a/server/src/main/java/io/myfinbox/expense/application/CategoryService.java b/server/src/main/java/io/myfinbox/expense/application/CategoryService.java index 0d45095..bc42e01 100644 --- a/server/src/main/java/io/myfinbox/expense/application/CategoryService.java +++ b/server/src/main/java/io/myfinbox/expense/application/CategoryService.java @@ -11,6 +11,7 @@ /** * Service interface for managing categories. */ +// FIXME: ensure the current auth user matches the accountID public interface CategoryService { /** @@ -29,6 +30,14 @@ public interface CategoryService { */ Either create(CategoryCommand command); + /** + * Updates a category name based on the provided command. + * + * @param command The command containing category update details. + * @return {@link Either} a {@link Failure} instance if the category update fails, or the update {@link Category} instance. + */ + Either update(UUID categoryId, CategoryCommand command); + record CategoryCommand(String name, UUID accountId) { public static final String FIELD_NAME = "name"; diff --git a/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java b/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java index 972300c..8f90b5f 100644 --- a/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java +++ b/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java @@ -3,6 +3,7 @@ import io.myfinbox.expense.domain.AccountIdentifier; import io.myfinbox.expense.domain.Categories; import io.myfinbox.expense.domain.Category; +import io.myfinbox.expense.domain.Category.CategoryIdentifier; import io.myfinbox.expense.domain.DefaultCategories; import io.myfinbox.shared.Failure; import io.myfinbox.shared.Failure.FieldViolation; @@ -30,7 +31,9 @@ class DefaultCategoryService implements CategoryService { public static final String VALIDATION_CREATE_FAILURE_MESSAGE = "The validation for the create category expense request has failed."; + public static final String VALIDATION_UPDATE_FAILURE_MESSAGE = "The validation for the update category expense request has failed."; public static final String CATEGORY_NAME_DUPLICATE_MESSAGE = "Category name already exists."; + public static final String CATEGORY_NOT_FOUND_MESSAGE = "Category not found."; private final CategoryCommandValidator validator = new CategoryCommandValidator(); @@ -70,6 +73,37 @@ public Either create(CategoryCommand command) { return Either.right(category); } + @Override + @Transactional + public Either update(UUID categoryId, CategoryCommand command) { + var validation = validator.validate(command); + if (validation.isInvalid()) { + return Either.left(Failure.ofValidation(VALIDATION_UPDATE_FAILURE_MESSAGE, validation.getError().toJavaList())); + } + + if (isNull(categoryId)) { + return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE)); + } + + var category = categories.findByIdAndAccount(new CategoryIdentifier(categoryId), new AccountIdentifier(command.accountId())); + if (category.isEmpty()) { + return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE)); + } + + if (category.get().sameName(command.name())) { + return Either.right(category.get()); + } + + if (categories.existsByNameAndAccount(command.name(), new AccountIdentifier(command.accountId()))) { + return Either.left(Failure.ofConflict(CATEGORY_NAME_DUPLICATE_MESSAGE)); + } + + category.get().setName(command.name()); + categories.save(category.get()); // FIXME: fix the save anti-pattern + + return Either.right(category.get()); + } + private static final class CategoryCommandValidator { Validation, CategoryCommand> validate(CategoryCommand command) { diff --git a/server/src/main/java/io/myfinbox/expense/domain/Category.java b/server/src/main/java/io/myfinbox/expense/domain/Category.java index 6eb7c1c..48b70cb 100644 --- a/server/src/main/java/io/myfinbox/expense/domain/Category.java +++ b/server/src/main/java/io/myfinbox/expense/domain/Category.java @@ -39,9 +39,17 @@ public final class Category { public Category(String name, AccountIdentifier account) { this.id = new CategoryIdentifier(UUID.randomUUID()); this.account = notNull(account, "account cannot be null"); + setName(name); + this.creationTimestamp = Instant.now(); + } + + public void setName(String name) { notBlank(name, "name cannot be blank"); this.name = doesNotOverflow(name, NAME_MAX_LENGTH, "name overflow, max length allowed '%d'".formatted(NAME_MAX_LENGTH)); - this.creationTimestamp = Instant.now(); + } + + public boolean sameName(String name) { + return this.name.equalsIgnoreCase(name); } @Embeddable diff --git a/server/src/test/groovy/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerSpec.groovy index c1fc69e..b80aab7 100644 --- a/server/src/test/groovy/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/adapter/web/ExpenseCategoryControllerSpec.groovy @@ -3,6 +3,7 @@ package io.myfinbox.expense.adapter.web import groovy.json.JsonOutput import groovy.json.JsonSlurper import io.myfinbox.TestServerApplication +import io.myfinbox.expense.DataSamples import org.skyscreamer.jsonassert.JSONAssert import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -13,14 +14,16 @@ import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.jdbc.JdbcTestUtils import spock.lang.Specification import spock.lang.Tag import static io.myfinbox.expense.DataSamples.newValidExpenseCategoryResource 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") @@ -34,12 +37,16 @@ class ExpenseCategoryControllerSpec extends Specification { @Autowired TestRestTemplate restTemplate + def cleanup() { + JdbcTestUtils.deleteFromTables(jdbcTemplate, 'expensecategory') + } + def "Should create a new category expense"() { given: 'user wants to create a new category expense' def request = newValidExpenseCategoryResource() when: 'expense category is created' - def response = postNewExpenseCategory(request) + def response = postExpenseCategory(request) then: 'response status is created' assert response.getStatusCode() == CREATED @@ -52,11 +59,11 @@ class ExpenseCategoryControllerSpec extends Specification { } def "should fail creation when request has validation failures"() { - given: 'user wants to create a new expense' + given: 'user wants to create a new expense category' var request = '{}' when: 'expense fails to create' - var response = postNewExpenseCategory(request) + var response = postExpenseCategory(request) then: 'response has status code unprocessable entity' assert response.getStatusCode() == UNPROCESSABLE_ENTITY @@ -65,8 +72,46 @@ class ExpenseCategoryControllerSpec extends Specification { JSONAssert.assertEquals(expectedCreationFailure(), response.getBody(), LENIENT) } - private postNewExpenseCategory(String request) { - restTemplate.postForEntity('/expenses/categories', entityRequest(request), String.class) + @Sql('/expense/web/expensecategory-create.sql') + def "should update an existing category expense name"() { + given: 'user wants to update an existing category name' + def request = newValidExpenseCategoryResource(name: 'NEW name') + + when: 'expense category name is updated' + def response = putExpenseCategory(request) + + then: 'response status is ok' + assert response.getStatusCode() == OK + + and: 'response body contains updated resource' + JSONAssert.assertEquals(newValidExpenseCategoryResource([name: "NEW name"]), response.getBody(), LENIENT) + } + + def "Should fail update when category not found"() { + given: 'user wants to update an existing category' + var request = newValidExpenseCategoryResource() + + when: 'expense fails to create' + var response = putExpenseCategory(request) + + then: 'status code is not found' + assert response.getStatusCode() == NOT_FOUND + + and: 'response body contains not found failure response' + JSONAssert.assertEquals(expectUpdateNotFoundFailure(), response.getBody(), LENIENT) + } + + private postExpenseCategory(String request) { + restTemplate.postForEntity('/expenses/category', entityRequest(request), String.class) + } + + private putExpenseCategory(String request) { + restTemplate.exchange( + "/expenses/category/${DataSamples.accountId.toString()}", + PUT, + entityRequest(request), + String.class + ) } def entityRequest(String req) { @@ -92,4 +137,12 @@ class ExpenseCategoryControllerSpec extends Specification { def failureAsMap = new JsonSlurper().parse(new ClassPathResource(filePath).getFile()) JsonOutput.toJson(failureAsMap) } + + def expectUpdateNotFoundFailure() { + JsonOutput.toJson([ + status : 404, + errorCode: 'NOT_FOUND', + message : 'Category not found.' + ]) + } } diff --git a/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy index b6b8b3d..df5d625 100644 --- a/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy @@ -96,7 +96,7 @@ class DefaultCategoryServiceSpec extends Specification { randStr(256) | "Name length cannot be more than ${Category.NAME_MAX_LENGTH}." } - def "should fail category creation when categoryName duplicate"() { + def "should fail category creation when name duplicate"() { setup: 'repository mock behavior and interaction' 1 * categories.existsByNameAndAccount(_ as String, _ as AccountIdentifier) >> TRUE @@ -130,6 +130,130 @@ class DefaultCategoryServiceSpec extends Specification { 1 * categories.save(_ as Category) } + def "should fail category update when accountId is null"() { + given: 'new command with null accountId' + def command = newSampleExpenseCategoryCommand(accountId: null) + + when: 'expense category fails to update' + def either = service.update(UUID.randomUUID(), command) + + then: 'result is present' + assert either.isLeft() + + and: 'failure result contains accountId failure message' + assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_UPDATE_FAILURE_MESSAGE, [ + Failure.FieldViolation.builder() + .field('accountId') + .message('AccountId cannot be null.') + .build() + ]) + } + + def "should fail category update when name is not valid"() { + given: 'new command with invalid name' + def command = newSampleExpenseCategoryCommand(name: categoryName) + + when: 'expense category fails to update' + def either = service.update(UUID.randomUUID(), command) + + then: 'failure result is present' + assert either.isLeft() + + and: 'validation failure on name field' + assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_UPDATE_FAILURE_MESSAGE, [ + Failure.FieldViolation.builder() + .field('name') + .message(failMessage) + .rejectedValue(categoryName) + .build() + ]) + + where: + categoryName | failMessage + null | 'Name cannot be empty.' + '' | 'Name cannot be empty.' + ' ' | 'Name cannot be empty.' + randStr(256) | "Name length cannot be more than ${Category.NAME_MAX_LENGTH}." + } + + def "should fail category update when category not found, null categoryId"() { + given: 'a valid command' + def command = newSampleExpenseCategoryCommand() + + when: 'expense category fails to update' + def either = service.update(null, command) + + then: 'failure result is present' + assert either.isLeft() + + and: 'not found failure result ' + assert either.getLeft() == Failure.ofNotFound(DefaultCategoryService.CATEGORY_NOT_FOUND_MESSAGE) + } + + def "should fail category update when category not found, no DB existence"() { + setup: 'a non existing category into db' + 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.empty() + + when: 'expense category fails to update' + def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand()) + + then: 'failure result is present' + assert either.isLeft() + + and: 'not found failure result ' + assert either.getLeft() == Failure.ofNotFound(DefaultCategoryService.CATEGORY_NOT_FOUND_MESSAGE) + } + + def "should not update category name when name not changed"() { + setup: 'a non existing category into db' + 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.of(newSampleCategory()) + + when: 'expense category no update' + def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand()) + + then: 'result is present' + assert either.isRight() + + and: 'same category returned' + assert either.get() == newSampleCategory() + + and: 'no database save' + 0 * categories.save(_ as Category) + } + + def "should fail category update when name duplicate"() { + setup: 'an existing category and an existing category name' + 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.of(newSampleCategory()) + 1 * categories.existsByNameAndAccount(_ as String, _ as AccountIdentifier) >> TRUE + + when: 'expense category fails to update' + def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand(name: 'Learning')) + + then: 'duplicate failure result is present' + assert either.isLeft() + + and: 'failure result contains category name exists message' + assert either.getLeft() == Failure.ofConflict(DefaultCategoryService.CATEGORY_NAME_DUPLICATE_MESSAGE) + } + + def "should update an exiting expense category"() { + setup: 'an existing category and a non existing category name' + 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.of(newSampleCategory()) + 1 * categories.existsByNameAndAccount(_ as String, _ as AccountIdentifier) >> FALSE + + when: 'expense category is updated' + def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand(name: 'Learning')) + + then: 'expense category is updated' + assert either.isRight() + + and: 'updated value' + assert either.get() == newSampleCategory(name: 'Learning') + + and: 'repository interaction' + 1 * categories.save(_ as Category) + } + static randStr(int len) { random(len, true, true) }