Skip to content

Commit

Permalink
Expose REST API for delete an expense category, improve the error mes…
Browse files Browse the repository at this point in the history
…sages and tests descriptions
  • Loading branch information
semotpan committed Mar 24, 2024
1 parent 2649936 commit 6bddb10
Show file tree
Hide file tree
Showing 19 changed files with 314 additions and 135 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,7 +36,7 @@ public Either<Failure, Account> 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()
Expand Down Expand Up @@ -66,7 +66,7 @@ private Validation<FieldViolation, String> 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());
}
Expand All @@ -79,7 +79,7 @@ private Validation<FieldViolation, String> 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());
}
Expand All @@ -88,23 +88,23 @@ private Validation<FieldViolation, String> 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());
}

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());
}

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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public interface CategoryService {
*/
Either<Failure, Category> 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<Failure, Void> delete(UUID categoryId);

record CategoryCommand(String name, UUID accountId) {

public static final String FIELD_NAME = "name";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -104,6 +103,27 @@ public Either<Failure, Category> update(UUID categoryId, CategoryCommand command
return Either.right(category.get());
}

@Override
@Transactional
public Either<Failure, Void> 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<Seq<FieldViolation>, CategoryCommand> validate(CategoryCommand command) {
Expand All @@ -127,7 +147,7 @@ private Validation<FieldViolation, String> 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.";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ private Validation<FieldViolation, BigDecimal> 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.";

Expand Down
3 changes: 3 additions & 0 deletions server/src/main/java/io/myfinbox/expense/domain/Expenses.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@

@Repository
public interface Expenses extends JpaRepository<Expense, ExpenseIdentifier> {

boolean existsByCategory(Category category);

}
6 changes: 3 additions & 3 deletions server/src/main/resources/db/migration/V4__expense_schema.sql
Original file line number Diff line number Diff line change
@@ -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)
);
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
])
Expand All @@ -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()
])
Expand Down Expand Up @@ -106,7 +106,7 @@ class CreateAccountServiceSpec extends Specification {
"a\"b(c)d,e:f;g<h>i[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"() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"() {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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.'
])
}
}
Loading

0 comments on commit 6bddb10

Please sign in to comment.