From 6bddb10f6e9b4eb9710013dfe2f9e3243fc76941 Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Sun, 24 Mar 2024 14:06:22 +0200 Subject: [PATCH] Expose REST API for delete an expense category, improve the error messages and tests descriptions --- .../application/CreateAccountService.java | 14 +- .../web/ExpenseCategoryController.java | 9 +- .../web/ExpenseCategoryControllerApi.java | 23 ++- .../expense/application/CategoryService.java | 8 + .../application/CreateExpenseService.java | 4 +- .../application/DefaultCategoryService.java | 38 +++- .../application/ExpenseCommandValidator.java | 2 +- .../io/myfinbox/expense/domain/Expenses.java | 3 + .../db/migration/V4__expense_schema.sql | 6 +- .../CreateAccountServiceSpec.groovy | 10 +- .../io/myfinbox/expense/DataSamples.groovy | 2 +- .../web/ExpenseCategoryControllerSpec.groovy | 43 +++- .../CreateExpenseServiceSpec.groovy | 60 +++--- .../DefaultCategoryServiceSpec.groovy | 193 ++++++++++++------ .../account-creation-failure-response.json | 4 +- ...se-category-creation-failure-response.json | 2 +- .../resources/expense/web/expense-create.sql | 18 ++ .../expense-creation-failure-response.json | 2 +- .../expense/web/expensecategory-create.sql | 8 +- 19 files changed, 314 insertions(+), 135 deletions(-) create mode 100644 server/src/test/resources/expense/web/expense-create.sql diff --git a/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java b/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java index 8101b7b..8fb1bed 100644 --- a/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java +++ b/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java @@ -23,7 +23,7 @@ @RequiredArgsConstructor class CreateAccountService implements CreateAccountUseCase { - static final String ERROR_MESSAGE = "validation failed on create account request."; + static final String ERROR_MESSAGE = "Validation failed for the create account request."; private final CommandValidator validator = new CommandValidator(); private final Accounts accounts; @@ -36,7 +36,7 @@ public Either create(CreateAccountCommand cmd) { } if (accounts.existsByEmailAddress(new Account.EmailAddress(cmd.emailAddress()))) { - return Either.left(Failure.ofConflict("email address '%s' already exists.".formatted(cmd.emailAddress()))); + return Either.left(Failure.ofConflict("Email address '%s' already exists.".formatted(cmd.emailAddress()))); } var account = Account.builder() @@ -66,7 +66,7 @@ private Validation validateFirstName(String firstName) { return Invalid(FieldViolation.builder() .field(FIELD_FIRST_NAME) - .message("first name length cannot be more than '%d'.".formatted(Account.MAX_LENGTH)) + .message("First name length cannot exceed '%d' characters.".formatted(Account.MAX_LENGTH)) .rejectedValue(firstName) .build()); } @@ -79,7 +79,7 @@ private Validation validateLastName(String lastName) { return Invalid(FieldViolation.builder() .field(FIELD_LAST_NAME) - .message("last name length cannot be more than '%d'.".formatted(Account.MAX_LENGTH)) + .message("Last name length cannot exceed '%d' characters.".formatted(Account.MAX_LENGTH)) .rejectedValue(lastName) .build()); } @@ -88,7 +88,7 @@ private Validation validateEmailAddress(String emailAddr if (StringUtils.isBlank(emailAddress)) { return Invalid(FieldViolation.builder() .field(FIELD_EMAIL_ADDRESS) - .message("email address cannot be empty.") + .message("Email address cannot be empty.") .rejectedValue(emailAddress) .build()); } @@ -96,7 +96,7 @@ private Validation validateEmailAddress(String emailAddr if (Account.MAX_LENGTH < emailAddress.length()) { return Invalid(FieldViolation.builder() .field(FIELD_EMAIL_ADDRESS) - .message("email address length cannot be more than '%s'.".formatted(Account.MAX_LENGTH)) + .message("Email address length cannot exceed '%d' characters.".formatted(Account.MAX_LENGTH)) .rejectedValue(emailAddress) .build()); } @@ -104,7 +104,7 @@ private Validation validateEmailAddress(String emailAddr if (!Pattern.compile(Account.patternRFC5322).matcher(emailAddress).matches()) { return Invalid(FieldViolation.builder() .field(FIELD_EMAIL_ADDRESS) - .message("email address must follow RFC 5322 standard.") + .message("Email address must follow RFC 5322 standard.") .rejectedValue(emailAddress) .build()); } 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 538b55b..41a6c14 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 @@ -12,8 +12,7 @@ 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.http.ResponseEntity.*; import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; @RestController @@ -38,6 +37,12 @@ public ResponseEntity update(@PathVariable UUID categoryId, @RequestBody Expe .fold(apiFailureHandler::handle, category -> ok(toResource(category))); } + @DeleteMapping(path = "/{categoryId}", produces = APPLICATION_JSON_VALUE) + public ResponseEntity delete(@PathVariable UUID categoryId) { + return categoryService.delete(categoryId) + .fold(apiFailureHandler::handle, ok -> noContent().build()); + } + 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 fd1c445..5dc6702 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 @@ -22,7 +22,8 @@ public interface ExpenseCategoryControllerApi { - @Operation(summary = "Add a new expense category in the MyFinBox", description = "Add a new expense category in the MyFinBox", + @Operation(summary = "Add a new expense category in the MyFinBox", + description = "Add a new expense category in the MyFinBox", security = {@SecurityRequirement(name = "openId")}, tags = {TAG}) @ApiResponses(value = { @@ -40,7 +41,8 @@ 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", + @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 = { @@ -59,4 +61,21 @@ public interface ExpenseCategoryControllerApi { }) ResponseEntity update(@Parameter(description = "CategoryId to be updated", required = true) UUID categoryId, @RequestBody(description = "Expense Category Resource to be updated", required = true) ExpenseCategoryResource resource); + + @Operation(summary = "Delete an expense category in the MyFinBox", + description = "Delete an expense category in the MyFinBox, if there are no expenses using the category", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Successful Operation"), + @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 in-use", + 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 delete(@Parameter(description = "CategoryId to be deleted", required = true) UUID categoryId); } 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 bc42e01..de375f7 100644 --- a/server/src/main/java/io/myfinbox/expense/application/CategoryService.java +++ b/server/src/main/java/io/myfinbox/expense/application/CategoryService.java @@ -38,6 +38,14 @@ public interface CategoryService { */ Either update(UUID categoryId, CategoryCommand command); + /** + * Deletes a category based on the provided category ID. + * + * @param categoryId The ID of the category to delete. + * @return {@link Either} a {@link Failure} instance if the category deletion fails, or {@code null} if successful. + */ + Either delete(UUID categoryId); + record CategoryCommand(String name, UUID accountId) { public static final String FIELD_NAME = "name"; diff --git a/server/src/main/java/io/myfinbox/expense/application/CreateExpenseService.java b/server/src/main/java/io/myfinbox/expense/application/CreateExpenseService.java index a52a9b1..4b1f8fd 100644 --- a/server/src/main/java/io/myfinbox/expense/application/CreateExpenseService.java +++ b/server/src/main/java/io/myfinbox/expense/application/CreateExpenseService.java @@ -18,8 +18,8 @@ @Transactional class CreateExpenseService implements CreateExpenseUseCase { - static final String VALIDATION_FAILURE_MESSAGE = "The validation for the create expense request has failed."; - static final String CATEGORY_NOT_FOUND_MESSAGE = "Category for the provided account was not found."; + static final String VALIDATION_FAILURE_MESSAGE = "Validation failed for the create expense request."; + static final String CATEGORY_NOT_FOUND_MESSAGE = "Category not found for the provided account."; private final ExpenseCommandValidator validator = new ExpenseCommandValidator(); 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 8f90b5f..ac729e8 100644 --- a/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java +++ b/server/src/main/java/io/myfinbox/expense/application/DefaultCategoryService.java @@ -1,10 +1,7 @@ package io.myfinbox.expense.application; -import io.myfinbox.expense.domain.AccountIdentifier; -import io.myfinbox.expense.domain.Categories; -import io.myfinbox.expense.domain.Category; +import io.myfinbox.expense.domain.*; import io.myfinbox.expense.domain.Category.CategoryIdentifier; -import io.myfinbox.expense.domain.DefaultCategories; import io.myfinbox.shared.Failure; import io.myfinbox.shared.Failure.FieldViolation; import io.vavr.collection.Seq; @@ -30,14 +27,16 @@ @RequiredArgsConstructor 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."; + public static final String VALIDATION_CREATE_FAILURE_MESSAGE = "Validation failed for the create category expense request."; + public static final String VALIDATION_UPDATE_FAILURE_MESSAGE = "Validation failed for the update category expense request."; + public static final String CATEGORY_NAME_DUPLICATE_MESSAGE = "A category expense with the same name already exists."; + public static final String CATEGORY_NOT_FOUND_MESSAGE = "Category expense not found."; + public static final String CATEGORY_IN_USE_FAILURE_MESSAGE = "Category expense is currently in use."; private final CategoryCommandValidator validator = new CategoryCommandValidator(); private final Categories categories; + private final Expenses expenses; @Override @Transactional @@ -104,6 +103,27 @@ public Either update(UUID categoryId, CategoryCommand command return Either.right(category.get()); } + @Override + @Transactional + public Either delete(UUID categoryId) { + if (isNull(categoryId)) { + return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE)); + } + + var category = categories.findById(new CategoryIdentifier(categoryId)); + if (category.isEmpty()) { + return Either.left(Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE)); + } + + if (expenses.existsByCategory(category.get())) { + return Either.left(Failure.ofConflict(CATEGORY_IN_USE_FAILURE_MESSAGE)); + } + + categories.delete(category.get()); + + return Either.right(null); + } + private static final class CategoryCommandValidator { Validation, CategoryCommand> validate(CategoryCommand command) { @@ -127,7 +147,7 @@ private Validation validateName(String name) { if (!isBlank(name) && name.length() <= Category.NAME_MAX_LENGTH) return Valid(name); - var message = format("Name length cannot be more than {0}.", Category.NAME_MAX_LENGTH); + var message = format("Name length cannot exceed {0} characters.", Category.NAME_MAX_LENGTH); if (isBlank(name)) message = "Name cannot be empty."; diff --git a/server/src/main/java/io/myfinbox/expense/application/ExpenseCommandValidator.java b/server/src/main/java/io/myfinbox/expense/application/ExpenseCommandValidator.java index 2c07900..00bcbbe 100644 --- a/server/src/main/java/io/myfinbox/expense/application/ExpenseCommandValidator.java +++ b/server/src/main/java/io/myfinbox/expense/application/ExpenseCommandValidator.java @@ -69,7 +69,7 @@ private Validation validateAmount(BigDecimal amount) return Valid(amount); } - var message = "Amount must be positive value."; + var message = "Amount must be a positive value."; if (isNull(amount)) message = "Amount cannot be null."; diff --git a/server/src/main/java/io/myfinbox/expense/domain/Expenses.java b/server/src/main/java/io/myfinbox/expense/domain/Expenses.java index 979507f..ffa9f00 100644 --- a/server/src/main/java/io/myfinbox/expense/domain/Expenses.java +++ b/server/src/main/java/io/myfinbox/expense/domain/Expenses.java @@ -7,4 +7,7 @@ @Repository public interface Expenses extends JpaRepository { + + boolean existsByCategory(Category category); + } diff --git a/server/src/main/resources/db/migration/V4__expense_schema.sql b/server/src/main/resources/db/migration/V4__expense_schema.sql index 7cd40a6..1784c56 100644 --- a/server/src/main/resources/db/migration/V4__expense_schema.sql +++ b/server/src/main/resources/db/migration/V4__expense_schema.sql @@ -1,13 +1,13 @@ CREATE TABLE IF NOT EXISTS expenses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - account_id UUID NOT NULL, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL, creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, payment_type VARCHAR(20) NOT NULL, amount DECIMAL(19, 4) NOT NULL, currency VARCHAR(3) NOT NULL, expense_date DATE NOT NULL, description TEXT, - category_id UUID NOT NULL, + category_id UUID NOT NULL, FOREIGN KEY (category_id) REFERENCES expensecategory (id) ); diff --git a/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy index 75318ef..b13b3e2 100644 --- a/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy @@ -14,8 +14,8 @@ import static org.apache.commons.lang3.RandomStringUtils.random @Tag("unit") class CreateAccountServiceSpec extends Specification { - static EMAIL_EMPTY_FIELD_ERROR = "email address cannot be empty." - static RFC_EMAIL_FIELD_ERROR = "email address must follow RFC 5322 standard." + static EMAIL_EMPTY_FIELD_ERROR = "Email address cannot be empty." + static RFC_EMAIL_FIELD_ERROR = "Email address must follow RFC 5322 standard." Accounts accounts CreateAccountService service @@ -40,7 +40,7 @@ class CreateAccountServiceSpec extends Specification { assert either.getLeft() == Failure.ofValidation(CreateAccountService.ERROR_MESSAGE, [ Failure.FieldViolation.builder() .field('firstName') - .message("first name length cannot be more than '${Account.MAX_LENGTH}'.") + .message("First name length cannot exceed '${Account.MAX_LENGTH}' characters.") .rejectedValue(value) .build() ]) @@ -61,7 +61,7 @@ class CreateAccountServiceSpec extends Specification { assert either.getLeft() == Failure.ofValidation(CreateAccountService.ERROR_MESSAGE, [ Failure.FieldViolation.builder() .field('lastName') - .message("last name length cannot be more than '${Account.MAX_LENGTH}'.") + .message("Last name length cannot exceed '${Account.MAX_LENGTH}' characters.") .rejectedValue(value) .build() ]) @@ -106,7 +106,7 @@ class CreateAccountServiceSpec extends Specification { "a\"b(c)d,e:f;gi[j\\k]l@example.com" | RFC_EMAIL_FIELD_ERROR "this is\"not\\allowed@example.com" | RFC_EMAIL_FIELD_ERROR "this\\ still\\\"not\\\\allowed@example.com" | RFC_EMAIL_FIELD_ERROR - "%s@gmail.com".formatted(randString(256)) | "email address length cannot be more than '${Account.MAX_LENGTH}'." + "%s@gmail.com".formatted(randString(256)) | "Email address length cannot exceed '${Account.MAX_LENGTH}' characters." } def "should create an account"() { diff --git a/server/src/test/groovy/io/myfinbox/expense/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/expense/DataSamples.groovy index dc1d859..77ca65f 100644 --- a/server/src/test/groovy/io/myfinbox/expense/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/DataSamples.groovy @@ -19,7 +19,7 @@ class DataSamples { static accountId = "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca" static categoryId = "3b257779-a5db-4e87-9365-72c6f8d4977d" static timestamp = "2024-03-23T10:00:04.224870Z" - static expenseDate = "2023-10-13" + static expenseDate = "2024-03-23" static amount = 10.0 static currency = 'EUR' 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 3db2077..4cad6b8 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 @@ -22,6 +22,7 @@ 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.HttpMethod.DELETE import static org.springframework.http.HttpMethod.PUT import static org.springframework.http.HttpStatus.* import static org.springframework.http.MediaType.APPLICATION_JSON @@ -38,7 +39,7 @@ class ExpenseCategoryControllerSpec extends Specification { TestRestTemplate restTemplate def cleanup() { - JdbcTestUtils.deleteFromTables(jdbcTemplate, 'expensecategory') + JdbcTestUtils.deleteFromTables(jdbcTemplate, 'expenses', 'expensecategory') } def "Should create a new category expense"() { @@ -101,6 +102,27 @@ class ExpenseCategoryControllerSpec extends Specification { JSONAssert.assertEquals(expectUpdateNotFoundFailure(), response.getBody(), LENIENT) } + @Sql('/expense/web/expensecategory-create.sql') + def "should delete an expense category"() { + when: 'expense category is deleted' + def response = deleteExpenseCategory() + + then: 'response status is no content' + assert response.getStatusCode() == NO_CONTENT + } + + @Sql(['/expense/web/expensecategory-create.sql', '/expense/web/expense-create.sql']) + def "Should fail delete when expense category is in use"() { + when: 'expense category fails to delete' + def response = deleteExpenseCategory() + + then: 'response status is conflict' + assert response.getStatusCode() == CONFLICT + + and: 'response body contains conflict failure response' + JSONAssert.assertEquals(expectDeleteConflictFailure(), response.getBody(), LENIENT) + } + private postExpenseCategory(String request) { restTemplate.postForEntity('/expenses/category', entityRequest(request), String.class) } @@ -114,6 +136,15 @@ class ExpenseCategoryControllerSpec extends Specification { ) } + private deleteExpenseCategory() { + restTemplate.exchange( + "/expenses/category/${DataSamples.categoryId}", + DELETE, + entityRequest(null), + String.class + ) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) @@ -142,7 +173,15 @@ class ExpenseCategoryControllerSpec extends Specification { JsonOutput.toJson([ status : 404, errorCode: 'NOT_FOUND', - message : 'Category not found.' + message : 'Category expense not found.' + ]) + } + + def expectDeleteConflictFailure() { + JsonOutput.toJson([ + status : 409, + errorCode: 'CONFLICT', + message : 'Category expense is currently in use.' ]) } } diff --git a/server/src/test/groovy/io/myfinbox/expense/application/CreateExpenseServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/expense/application/CreateExpenseServiceSpec.groovy index 6d3f8f6..dba6863 100644 --- a/server/src/test/groovy/io/myfinbox/expense/application/CreateExpenseServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/application/CreateExpenseServiceSpec.groovy @@ -1,6 +1,5 @@ package io.myfinbox.expense.application - import io.myfinbox.expense.domain.AccountIdentifier import io.myfinbox.expense.domain.Categories import io.myfinbox.expense.domain.Expense @@ -10,6 +9,7 @@ import spock.lang.Specification import spock.lang.Tag import static io.myfinbox.expense.DataSamples.* +import static io.myfinbox.expense.application.CreateExpenseService.CATEGORY_NOT_FOUND_MESSAGE import static io.myfinbox.expense.application.CreateExpenseService.VALIDATION_FAILURE_MESSAGE import static io.myfinbox.expense.domain.Category.CategoryIdentifier @@ -27,16 +27,16 @@ class CreateExpenseServiceSpec extends Specification { } def "should fail expense creation when accountId is null"() { - given: 'new command with null accountId' + given: 'a new expense command with null accountId' def command = newSampleExpenseCommand(accountId: null) - when: 'expense fails to create' + when: 'attempting to create an expense with a null accountId' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() - and: 'validation failure on accountId field' + and: 'the failure message indicates validation failure for create expense request' assert either.getLeft() == Failure.ofValidation(VALIDATION_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('accountId') @@ -46,16 +46,16 @@ class CreateExpenseServiceSpec extends Specification { } def "should fail expense creation when categoryId is null"() { - given: 'new command with null categoryId' + given: 'a new expense command with null categoryId' def command = newSampleExpenseCommand(categoryId: null) - when: 'expense fails to create' + when: 'attempting to create an expense with a null categoryId' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() - and: 'validation failure on categoryId field' + and: 'the failure message indicates validation failure for create expense request' assert either.getLeft() == Failure.ofValidation(VALIDATION_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('categoryId') @@ -65,13 +65,13 @@ class CreateExpenseServiceSpec extends Specification { } def "should fail expense creation when paymentType is invalid"() { - given: 'new command with invalid payment type' + given: 'a new command with an invalid payment type' def command = newSampleExpenseCommand(paymentType: paymentType) - when: 'expense fails to create' + when: 'attempting to create an expense with an invalid paymentType' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() and: 'validation failure on paymentType field' @@ -91,13 +91,13 @@ class CreateExpenseServiceSpec extends Specification { } def "should fail expense creation when amount is invalid"() { - given: 'new command with invalid amount' + given: 'a new command with an invalid amount' def command = newSampleExpenseCommand(amount: value) - when: 'expense fails to create' + when: 'attempting to create an expense with an invalid amount' def either = service.create(command) - then: 'result is present' + then: 'a failure result is present' assert either.isLeft() and: 'validation failure on amount field' @@ -112,18 +112,18 @@ class CreateExpenseServiceSpec extends Specification { where: value | failMessage null | 'Amount cannot be null.' - 0.0 | 'Amount must be positive value.' - -25.56 | 'Amount must be positive value.' + 0.0 | 'Amount must be a positive value.' + -25.56 | 'Amount must be a positive value.' } def "should fail expense creation when currencyCode is invalid"() { - given: 'new command with invalid currencyCode' + given: 'a new command with an invalid currencyCode' def command = newSampleExpenseCommand(currencyCode: currencyCode) - when: 'expense fails to create' + when: 'attempting to create an expense with an invalid currencyCode' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() and: 'validation failure on currencyCode field' @@ -144,13 +144,13 @@ class CreateExpenseServiceSpec extends Specification { } def "should fail expense creation when expenseDate is null"() { - given: 'new command with null expenseDate' + given: 'a new command with a null expenseDate' def command = newSampleExpenseCommand(expenseDate: null) - when: 'expense fails to create' + when: 'attempting to create an expense with a null expenseDate' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() and: 'validation failure on expenseDate field' @@ -166,33 +166,33 @@ class CreateExpenseServiceSpec extends Specification { setup: 'repository mock behavior' 1 * categories.findByIdAndAccount(_ as CategoryIdentifier, _ as AccountIdentifier) >> Optional.empty() - when: 'expense fails to create' + when: 'attempting to create an expense with a non-existing category' def either = service.create(newSampleExpenseCommand()) - then: 'failure result is present' + then: 'a failure result is present' assert either.isLeft() - and: 'not found failure for provided category is available' - assert either.getLeft() == Failure.ofNotFound('Category for the provided account was not found.') + and: 'not found failure for the provided category is available' + assert either.getLeft() == Failure.ofNotFound(CATEGORY_NOT_FOUND_MESSAGE) } def "should create an expense"() { setup: 'repository mock behavior and interaction' 1 * categories.findByIdAndAccount(_ as CategoryIdentifier, _ as AccountIdentifier) >> Optional.of(newSampleCategory()) - when: 'new expense is created' + when: 'creating a new expense' def either = service.create(newSampleExpenseCommand()) then: 'expense value is present' assert either.isRight() - and: 'expense is build as expected' + and: 'expense is built as expected' assert either.get() == newSampleExpense([ id : [id: either.get().getId().toString()], creationTimestamp: either.get().getCreationTimestamp().toString(), ]) - and: 'expenses interaction was done' + and: 'expense is saved in the repository' 1 * expenses.save(_ as Expense) } } 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 df5d625..d76cbb6 100644 --- a/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/application/DefaultCategoryServiceSpec.groovy @@ -1,9 +1,6 @@ package io.myfinbox.expense.application -import io.myfinbox.expense.domain.AccountIdentifier -import io.myfinbox.expense.domain.Categories -import io.myfinbox.expense.domain.Category -import io.myfinbox.expense.domain.DefaultCategories +import io.myfinbox.expense.domain.* import io.myfinbox.shared.Failure import spock.lang.Specification import spock.lang.Tag @@ -19,48 +16,56 @@ import static org.codehaus.groovy.runtime.DefaultGroovyMethods.intersect class DefaultCategoryServiceSpec extends Specification { Categories categories + Expenses expenses CategoryService service def setup() { categories = Mock() - service = new DefaultCategoryService(categories) + expenses = Mock() + service = new DefaultCategoryService(categories, expenses) } - def "should fail create default categories when account is null"() { + def "should fail to create default categories when account is null"() { when: 'account is null' def either = service.createDefault(null) - then: 'validation failure is present' + then: 'validation failure should occur' assert either.isLeft() - and: 'failure message is account cannot be null' + and: 'failure message should indicate account cannot be null' assert either.getLeft() == Failure.ofValidation("AccountIdentifier cannot be null", List.of()) } - def "should create default categories"() { + def "should successfully create default categories for the provided account"() { + given: 'mock repository behavior and interaction' + def account = new AccountIdentifier(UUID.randomUUID()) + def expectedCategories = newSampleDefaultCategories(account) + // Define comparator to compare categories by name and account ID def comp = { a, b -> a.name <=> b.name ?: a.account.id() <=> b.account.id() } as Comparator - setup: 'repository mock behavior and interaction' - def account = new AccountIdentifier(UUID.randomUUID()) + // Mock saving default categories and verify if the correct categories are saved 1 * categories.saveAll { actual -> - intersect(asCollection(actual), newSampleDefaultCategories(account), comp).size() == DefaultCategories.values().size() + intersect(asCollection(actual), expectedCategories, comp).size() == DefaultCategories.values().size() } >> [] - expect: 'created default categories for provided accountId' - service.createDefault(account) + when: 'creating default categories' + def result = service.createDefault(account) + + then: 'default categories are created successfully' + assert result.isRight() } def "should fail category creation when accountId is null"() { - given: 'new command with null accountId' + given: 'a new command with a null accountId' def command = newSampleExpenseCategoryCommand(accountId: null) - when: 'expense category fails to create' + when: 'attempting to create an expense category' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'failure result contains accountId failure message' + and: 'the failure result should contain an error message about the null accountId' assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_CREATE_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('accountId') @@ -70,16 +75,16 @@ class DefaultCategoryServiceSpec extends Specification { } def "should fail category creation when name is not valid"() { - given: 'new command with invalid name' + given: 'a new command with an invalid name' def command = newSampleExpenseCategoryCommand(name: categoryName) - when: 'expense category fails to create' + when: 'attempting to create an expense category' def either = service.create(command) - then: 'failure result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'validation failure on name field' + and: 'validation failure should occur on the name field' assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_CREATE_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('name') @@ -93,54 +98,54 @@ class DefaultCategoryServiceSpec extends Specification { 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}." + randStr(256) | "Name length cannot exceed ${Category.NAME_MAX_LENGTH} characters." } - def "should fail category creation when name duplicate"() { - setup: 'repository mock behavior and interaction' + def "should fail category creation when name is duplicate"() { + setup: 'mock repository behavior to indicate name duplication' 1 * categories.existsByNameAndAccount(_ as String, _ as AccountIdentifier) >> TRUE - when: 'expense category fails to create' + when: 'attempting to create an expense category' def either = service.create(newSampleExpenseCategoryCommand()) - then: 'duplicate failure result is present' + then: 'a failure result indicating name duplication should occur' assert either.isLeft() - and: 'failure result contains category name exists message' + and: 'the failure result should contain a message about the duplicate category name' assert either.getLeft() == Failure.ofConflict(DefaultCategoryService.CATEGORY_NAME_DUPLICATE_MESSAGE) } - def "should create a new expense category"() { - setup: 'repository mock behavior and interaction' + def "should successfully create a new expense category"() { + setup: 'mock repository behavior to indicate no name duplication' 1 * categories.existsByNameAndAccount(_ as String, _ as AccountIdentifier) >> FALSE - when: 'new expense category is created' + when: 'creating a new expense category' def either = service.create(newSampleExpenseCategoryCommand()) - then: 'expense category is persisted' + then: 'the creation operation should succeed' assert either.isRight() - and: 'created value' + and: 'the created category should have correct attributes' assert either.get() == newSampleCategory( id: [id: either.get().getId().id()], creationTimestamp: either.get().getCreationTimestamp().toString() ) - and: 'repository interaction' + and: 'the category should be persisted in the repository' 1 * categories.save(_ as Category) } def "should fail category update when accountId is null"() { - given: 'new command with null accountId' + given: 'a new command with a null accountId' def command = newSampleExpenseCategoryCommand(accountId: null) - when: 'expense category fails to update' + when: 'attempting to update an expense category' def either = service.update(UUID.randomUUID(), command) - then: 'result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'failure result contains accountId failure message' + and: 'the failure result should contain an error message about the null accountId' assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_UPDATE_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('accountId') @@ -150,16 +155,16 @@ class DefaultCategoryServiceSpec extends Specification { } def "should fail category update when name is not valid"() { - given: 'new command with invalid name' + given: 'a new command with an invalid name' def command = newSampleExpenseCategoryCommand(name: categoryName) - when: 'expense category fails to update' + when: 'attempting to update an expense category' def either = service.update(UUID.randomUUID(), command) - then: 'failure result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'validation failure on name field' + and: 'validation failure should occur on the name field' assert either.getLeft() == Failure.ofValidation(DefaultCategoryService.VALIDATION_UPDATE_FAILURE_MESSAGE, [ Failure.FieldViolation.builder() .field('name') @@ -173,87 +178,145 @@ class DefaultCategoryServiceSpec extends Specification { 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}." + randStr(256) | "Name length cannot exceed ${Category.NAME_MAX_LENGTH} characters." } def "should fail category update when category not found, null categoryId"() { given: 'a valid command' def command = newSampleExpenseCategoryCommand() - when: 'expense category fails to update' + when: 'attempting to update an expense category with null categoryId' def either = service.update(null, command) - then: 'failure result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'not found failure result ' + and: 'the failure result should indicate category not found' 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' + setup: 'a non-existing category in the database' 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.empty() - when: 'expense category fails to update' + when: 'attempting to update an expense category' def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand()) - then: 'failure result is present' + then: 'a failure result is expected' assert either.isLeft() - and: 'not found failure result ' + and: 'the failure result should indicate category not found' 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' + setup: 'a non-existing category in the database' 1 * categories.findByIdAndAccount(_ as Category.CategoryIdentifier, _ as AccountIdentifier) >> Optional.of(newSampleCategory()) - when: 'expense category no update' + when: 'attempting to update an expense category without changing the name' def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand()) - then: 'result is present' + then: 'no update should occur' assert either.isRight() - and: 'same category returned' + and: 'the same category should be returned' assert either.get() == newSampleCategory() - and: 'no database save' + and: 'no database save operation should be performed' 0 * categories.save(_ as Category) } - def "should fail category update when name duplicate"() { + def "should fail category update when name is 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' + when: 'attempting to update an expense category with a duplicate name' def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand(name: 'Learning')) - then: 'duplicate failure result is present' + then: 'a failure result indicating name duplication should occur' assert either.isLeft() - and: 'failure result contains category name exists message' + and: 'the failure result should contain a message about the duplicate category name' 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' + def "should update an existing expense category with a new name"() { + 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' + when: 'attempting to update an expense category with a new name' def either = service.update(UUID.randomUUID(), newSampleExpenseCategoryCommand(name: 'Learning')) - then: 'expense category is updated' + then: 'the expense category should be updated successfully' assert either.isRight() - and: 'updated value' + and: 'the updated category should have the new name' assert either.get() == newSampleCategory(name: 'Learning') - and: 'repository interaction' + and: 'the updated category should be saved in the repository' 1 * categories.save(_ as Category) } + def "should fail to delete category when category not found, categoryId is null"() { + given: 'a null categoryId' + def categoryId = null + + when: 'attempting to delete a category with null categoryId' + def either = service.delete(categoryId) + + then: 'a failure result is expected' + assert either.isLeft() + + and: 'the failure result should indicate category not found' + assert either.getLeft() == Failure.ofNotFound(DefaultCategoryService.CATEGORY_NOT_FOUND_MESSAGE) + } + + def "should fail to delete category when category not found in the database"() { + setup: 'repository with no existing category' + 1 * categories.findById(_ as Category.CategoryIdentifier) >> Optional.empty() + + when: 'attempting to delete a category that does not exist in the database' + def either = service.delete(UUID.randomUUID()) + + then: 'a failure result is expected' + assert either.isLeft() + + and: 'the failure result should indicate category not found' + assert either.getLeft() == Failure.ofNotFound(DefaultCategoryService.CATEGORY_NOT_FOUND_MESSAGE) + } + + def "should fail to delete category when category is in use"() { + setup: 'repository with an existing category and existing expenses associated with it' + 1 * categories.findById(_ as Category.CategoryIdentifier) >> Optional.of(newSampleCategory()) + 1 * expenses.existsByCategory(_ as Category) >> TRUE + + when: 'attempting to delete a category that is currently in use' + def either = service.delete(UUID.randomUUID()) + + then: 'a failure result is expected' + assert either.isLeft() + + and: 'the failure result should indicate category in use' + assert either.getLeft() == Failure.ofConflict(DefaultCategoryService.CATEGORY_IN_USE_FAILURE_MESSAGE) + } + + def "should successfully delete a category when no expenses are associated with it"() { + setup: 'repository with an existing category and no associated expenses' + 1 * categories.findById(_ as Category.CategoryIdentifier) >> Optional.of(newSampleCategory()) + 1 * expenses.existsByCategory(_ as Category) >> FALSE + + when: 'attempting to delete a category with no associated expenses' + def either = service.delete(UUID.randomUUID()) + + then: 'no result is present' + assert either.isRight() + + and: 'the category should be deleted from the repository' + 1 * categories.delete(_ as Category) + } + static randStr(int len) { random(len, true, true) } diff --git a/server/src/test/resources/account/web/account-creation-failure-response.json b/server/src/test/resources/account/web/account-creation-failure-response.json index d25d61f..7fbb3df 100644 --- a/server/src/test/resources/account/web/account-creation-failure-response.json +++ b/server/src/test/resources/account/web/account-creation-failure-response.json @@ -1,11 +1,11 @@ { "status": 422, "errorCode": "UNPROCESSABLE_ENTITY", - "message": "validation failed on create account request.", + "message": "Validation failed for the create account request.", "errors": [ { "field": "emailAddress", - "message": "email address cannot be empty." + "message": "Email address cannot be empty." } ] } diff --git a/server/src/test/resources/expense/web/expense-category-creation-failure-response.json b/server/src/test/resources/expense/web/expense-category-creation-failure-response.json index e20f8cc..770e310 100644 --- a/server/src/test/resources/expense/web/expense-category-creation-failure-response.json +++ b/server/src/test/resources/expense/web/expense-category-creation-failure-response.json @@ -1,7 +1,7 @@ { "status": 422, "errorCode": "UNPROCESSABLE_ENTITY", - "message": "The validation for the create category expense request has failed.", + "message": "Validation failed for the create category expense request.", "errors": [ { "field": "accountId", diff --git a/server/src/test/resources/expense/web/expense-create.sql b/server/src/test/resources/expense/web/expense-create.sql new file mode 100644 index 0000000..28ef61c --- /dev/null +++ b/server/src/test/resources/expense/web/expense-create.sql @@ -0,0 +1,18 @@ +INSERT INTO server.expenses(id, + account_id, + creation_timestamp, + payment_type, + amount, + currency, + expense_date, + description, + category_id) +VALUES ('3b257779-a5db-4e87-9365-72c6f8d4977d', + 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + '2024-03-23T10:00:04.224870Z', + 'CARD', + '10.0', + 'EUR', + '2024-03-23', + 'Books buying', + '3b257779-a5db-4e87-9365-72c6f8d4977d'); diff --git a/server/src/test/resources/expense/web/expense-creation-failure-response.json b/server/src/test/resources/expense/web/expense-creation-failure-response.json index aeff2b3..fca0a09 100644 --- a/server/src/test/resources/expense/web/expense-creation-failure-response.json +++ b/server/src/test/resources/expense/web/expense-creation-failure-response.json @@ -1,7 +1,7 @@ { "status": 422, "errorCode": "UNPROCESSABLE_ENTITY", - "message": "The validation for the create expense request has failed.", + "message": "Validation failed for the create expense request.", "errors": [ { "field": "accountId", diff --git a/server/src/test/resources/expense/web/expensecategory-create.sql b/server/src/test/resources/expense/web/expensecategory-create.sql index e6165bd..20e11a0 100644 --- a/server/src/test/resources/expense/web/expensecategory-create.sql +++ b/server/src/test/resources/expense/web/expensecategory-create.sql @@ -1,2 +1,6 @@ -INSERT INTO expensecategory (id, account_id, name) -VALUES ('3b257779-a5db-4e87-9365-72c6f8d4977d', 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', 'Fun'); +INSERT INTO server.expensecategory (id, + account_id, + name) +VALUES ('3b257779-a5db-4e87-9365-72c6f8d4977d', + 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + 'Bills');