diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java index 5d9c95b..0821303 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarController.java @@ -1,14 +1,12 @@ package io.myfinbox.spendingplan.adapter.web; import io.myfinbox.rest.JarCategoryModificationResource; +import io.myfinbox.rest.JarExpenseCategoryResource; import io.myfinbox.rest.JarResource; import io.myfinbox.shared.ApiFailureHandler; import io.myfinbox.shared.Failure; -import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase; +import io.myfinbox.spendingplan.application.*; import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove; -import io.myfinbox.spendingplan.application.CreateJarUseCase; -import io.myfinbox.spendingplan.application.JarCommand; -import io.myfinbox.spendingplan.application.JarQuery; import lombok.RequiredArgsConstructor; import org.springframework.core.convert.ConversionService; import org.springframework.http.ResponseEntity; @@ -31,6 +29,7 @@ final class JarController implements JarsApi { private final CreateJarUseCase createJarUseCase; private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase; private final JarQuery jarQuery; + private final JarExpenseCategoryQuery jarExpenseCategoryQuery; private final ApiFailureHandler apiFailureHandler; private final ConversionService conversionService; @@ -74,6 +73,13 @@ public ResponseEntity list(@PathVariable("planId") UUID planId) { .toList()); } + @GetMapping(path = "/{planId}/jars/{jarId}/expense-categories", produces = APPLICATION_JSON_VALUE) + public ResponseEntity list(@PathVariable UUID planId, @PathVariable UUID jarId) { + return ok().body(jarExpenseCategoryQuery.search(planId, jarId).stream() + .map(expenseCategory -> conversionService.convert(expenseCategory, JarExpenseCategoryResource.class)) + .toList()); + } + private JarCommand toCommand(JarResource resource) { return JarCommand.builder() .name(resource.getName()) diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java index 56554bf..4f9dd66 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/JarsApi.java @@ -103,4 +103,19 @@ ResponseEntity modifyExpenseCategories( ResponseEntity one(@Parameter(in = PATH, description = "Plan Id to be used for searching", required = true) UUID planId, @Parameter(in = PATH, description = "Jar Id to be used for searching", required = true) UUID jarId); + @Operation(summary = "Query a list of attached expense categories to a jar in the MyFinBox", + description = "Query a list of attached expense categories to a jar in the MyFinBox", + security = {@SecurityRequirement(name = "openId")}, + tags = {TAG}) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful Operation", + content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = JarResource.class)))), + @ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))), + @ApiResponse(responseCode = "500", description = "Internal Server Error", + content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) + }) + ResponseEntity list(@Parameter(in = PATH, description = "Plan Id to be used for searching", required = true) UUID planId, + @Parameter(in = PATH, description = "Jar Id to be used for searching", required = true) UUID jarId); + } diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarExpenseCategoryToResourceConverter.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarExpenseCategoryToResourceConverter.java new file mode 100644 index 0000000..bfe3fc9 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/converters/JarExpenseCategoryToResourceConverter.java @@ -0,0 +1,19 @@ +package io.myfinbox.spendingplan.adapter.web.converters; + +import io.myfinbox.rest.JarExpenseCategoryResource; +import io.myfinbox.spendingplan.domain.JarExpenseCategory; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +final class JarExpenseCategoryToResourceConverter implements Converter { + + @Override + public JarExpenseCategoryResource convert(JarExpenseCategory category) { + return new JarExpenseCategoryResource() + .id(category.getId()) + .categoryId(category.getCategoryId().id()) + .categoryName(category.getCategoryName()) + .creationTimestamp(category.getCreationTimestamp().toString()); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java b/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java index 7efba36..f8b3b46 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java +++ b/server/src/main/java/io/myfinbox/spendingplan/application/CategoriesJarCommandValidator.java @@ -4,6 +4,7 @@ import io.vavr.control.Validation; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import static io.myfinbox.shared.Failure.FieldViolation; @@ -12,7 +13,6 @@ import static io.vavr.API.Invalid; import static io.vavr.API.Valid; import static java.util.Objects.isNull; -import static org.apache.commons.lang3.StringUtils.isBlank; final class CategoriesJarCommandValidator { @@ -26,12 +26,13 @@ Validation> validate(List isNull(category.categoryId()) || isBlank(category.categoryName())); + .map(JarCategoryToAddOrRemove::categoryId) + .anyMatch(Objects::isNull); if (anyNull) { return Invalid(Failure.FieldViolation.builder() .field(CATEGORIES_JAR_FIELD) - .message("Null categoryId or blank categoryName not allowed.") + .message("Null categoryId not allowed.") .rejectedValue(categories) .build()); } diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQuery.java b/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQuery.java new file mode 100644 index 0000000..9148826 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQuery.java @@ -0,0 +1,22 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.JarExpenseCategory; + +import java.util.List; +import java.util.UUID; + +/** + * Represents a query interface for searching and retrieving jar expense categories. + */ +public interface JarExpenseCategoryQuery { + + /** + * Searches for jar expense categories based on the specified plan ID and jar ID. + * + * @param planId the unique identifier of the plan. + * @param jarId the unique identifier of the jar. + * @return a list of jar expense categories matching the specified plan ID and jar ID. + */ + List search(UUID planId, UUID jarId); + +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryService.java b/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryService.java new file mode 100644 index 0000000..9c481f0 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryService.java @@ -0,0 +1,34 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static java.util.Collections.emptyList; +import static java.util.Objects.isNull; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +class JarExpenseCategoryQueryService implements JarExpenseCategoryQuery { + + private final JarExpenseCategories jarExpenseCategories; + private final Jars jars; + + @Override + public List search(UUID planId, UUID jarId) { + if (isNull(planId) || isNull(jarId)) { + return emptyList(); + } + + if (!jars.existsByIdAndPlan(new Plan.PlanIdentifier(planId), new JarIdentifier(jarId))) { + return emptyList(); + } + + return jarExpenseCategories.findByJarId(new JarIdentifier(jarId)); + } +} diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java index 297820a..3cf251f 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/JarExpenseCategory.java @@ -7,10 +7,11 @@ import java.util.ArrayList; import java.util.List; -import static io.myfinbox.shared.Guards.*; +import static io.myfinbox.shared.Guards.notNull; import static jakarta.persistence.CascadeType.ALL; import static jakarta.persistence.GenerationType.SEQUENCE; import static lombok.AccessLevel.PACKAGE; +import static org.apache.commons.lang3.StringUtils.isBlank; @Entity @Getter @@ -32,7 +33,7 @@ public class JarExpenseCategory { @AttributeOverride(name = "id", column = @Column(name = "category_id")) private final CategoryIdentifier categoryId; - @Column(name = "category_name", nullable = false) + @Column(name = "category_name") private String categoryName; @ManyToOne(fetch = FetchType.LAZY) @@ -46,7 +47,7 @@ public class JarExpenseCategory { public JarExpenseCategory(Jar jar, CategoryIdentifier categoryId, String categoryName) { this.jar = notNull(jar, "jar cannot be null."); this.categoryId = notNull(categoryId, "categoryId cannot be null."); - this.categoryName = notBlank(categoryName, "categoryName cannot be null."); + this.categoryName = !isBlank(categoryName) ? categoryName.trim() : null; this.creationTimestamp = Instant.now(); } } diff --git a/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java b/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java index 046bc9e..4f9795b 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java @@ -25,4 +25,9 @@ public interface Jars extends JpaRepository { """) List findByPlanId(PlanIdentifier planId); + @Query(value = """ + SELECT COUNT(j) > 0 FROM Jar j + WHERE j.id = :jarId AND j.plan.id = :planId + """) + boolean existsByIdAndPlan(PlanIdentifier planId, JarIdentifier jarId); } diff --git a/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql b/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql index ce2bde5..c9db199 100644 --- a/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql +++ b/server/src/main/resources/db/migration/V1_08__spending_jar_expense_category_schema.sql @@ -2,11 +2,11 @@ CREATE SEQUENCE IF NOT EXISTS sjec_seq_id START 1 INCREMENT 1; CREATE TABLE IF NOT EXISTS spending_jar_expense_category ( - id BIGINT PRIMARY KEY DEFAULT nextval('sjec_seq_id'), - jar_id UUID NOT NULL, - category_id UUID NOT NULL, - category_name VARCHAR(100) NOT NULL, - creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + id BIGINT PRIMARY KEY DEFAULT nextval('sjec_seq_id'), + jar_id UUID NOT NULL, + category_id UUID NOT NULL, + category_name VARCHAR(100), + creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (jar_id) REFERENCES spending_jars (id) ) ; diff --git a/server/src/main/resources/openapi.yml b/server/src/main/resources/openapi.yml index ea6073c..00fa919 100644 --- a/server/src/main/resources/openapi.yml +++ b/server/src/main/resources/openapi.yml @@ -314,3 +314,28 @@ components: example: MDL pattern: '^[A-Z]{3}$' description: The ISO 4217 currency code in uppercase (e.g., USD, EUR). + + JarExpenseCategoryResource: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + readOnly: true + description: The ID of the expense category. + categoryId: + type: string + format: uuid + readOnly: true + description: Unique identifier for the expense category. + categoryName: + type: string + readOnly: true + example: Clothing + description: The expense category name, it can be null. + creationTimestamp: + type: string + readOnly: true + example: 2024-03-23T10:00:04.224870Z + description: The timestamp when the expense category was created (read-only). diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy index 31a8197..7954f6b 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy @@ -125,9 +125,9 @@ class DataSamples { ] static JAR_CATEGORY_TO_ADD_OR_REMOVE = [ - categoryId: jarCategoryId, + categoryId : jarCategoryId, categoryName: categoryName, - toAdd : true + toAdd : true ] static EXPENSE_MODIFICATION_RECORD = [ @@ -158,8 +158,17 @@ class DataSamples { ] static JAR_EXPENSE_CATEGORY = [ - id : 1, - categoryId: [id: jarCategoryId] + id : 1, + categoryId : [id: jarCategoryId], + categoryName : categoryName, + creationTimestamp: timestamp, + ] + + static JAR_EXPENSE_CATEGORY_PLAIN = [ + id : 1, + categoryId : jarCategoryId, + categoryName : categoryName, + creationTimestamp: timestamp, ] static JAR_CATEGORIES_RESOURCE = [ @@ -258,4 +267,8 @@ class DataSamples { static newSampleListJarAsString() { JsonOutput.toJson([JAR_PLAIN]) } + + static newSampleListJarExpenseCategoriesAsString() { + JsonOutput.toJson([JAR_EXPENSE_CATEGORY_PLAIN, JAR_EXPENSE_CATEGORY_PLAIN + [id: 2, categoryId: jarCategoryId2]]) + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy index b89ab6b..315f997 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/JarControllerSpec.groovy @@ -173,6 +173,18 @@ class JarControllerSpec extends Specification { JSONAssert.assertEquals(newSampleListJarAsString(), response.getBody(), LENIENT) } + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql', '/spendingplan/web/jar_expense_category-create.sql']) + def "should get a list of expense categories"() { + when: 'listing expense categories for a planId by jarId' + def response = listJarExpenseCategories(UUID.fromString(planId), UUID.fromString(jarId)) + + then: 'the response status is "OK"' + assert response.getStatusCode() == OK + + and: 'the response body contains the expected resource' + JSONAssert.assertEquals(newSampleListJarExpenseCategoriesAsString(), response.getBody(), LENIENT) + } + def postJar(String req) { restTemplate.postForEntity("/v1/plans/${planId}/jars", entityRequest(req), String.class) } @@ -212,6 +224,19 @@ class JarControllerSpec extends Specification { ) } + def listJarExpenseCategories(UUID planId, UUID jarId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}/jars/${jarId}/expense-categories") + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy index 25c4341..a33275b 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/AddOrRemoveJarCategoryServiceSpec.groovy @@ -49,9 +49,7 @@ class AddOrRemoveJarCategoryServiceSpec extends Specification { categories | failMessage null | 'At least one category must be provided.' [] | 'At least one category must be provided.' - [newSampleJarCategoryToAddAsMap(categoryId: null)] | 'Null categoryId or blank categoryName not allowed.' - [newSampleJarCategoryToAddAsMap(categoryName: null)] | 'Null categoryId or blank categoryName not allowed.' - [newSampleJarCategoryToAddAsMap(categoryName: '')] | 'Null categoryId or blank categoryName not allowed.' + [newSampleJarCategoryToAddAsMap(categoryId: null)] | 'Null categoryId not allowed.' [newSampleJarCategoryToAddAsMap(), newSampleJarCategoryToAddAsMap()] | 'Duplicate category ids provided.' } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryServiceSpec.groovy new file mode 100644 index 0000000..866187f --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/JarExpenseCategoryQueryServiceSpec.groovy @@ -0,0 +1,64 @@ +package io.myfinbox.spendingplan.application + +import io.myfinbox.spendingplan.domain.JarExpenseCategories +import io.myfinbox.spendingplan.domain.JarIdentifier +import io.myfinbox.spendingplan.domain.Jars +import io.myfinbox.spendingplan.domain.Plan +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.spendingplan.DataSamples.newSampleJarExpenseCategory + +@Tag("unit") +class JarExpenseCategoryQueryServiceSpec extends Specification { + + JarExpenseCategories jarExpenseCategories + Jars jars + JarExpenseCategoryQueryService service + + def setup() { + jarExpenseCategories = Mock() + jars = Mock() + service = new JarExpenseCategoryQueryService(jarExpenseCategories, jars) + } + + def "should get empty list when planId is not provided"() { + when: 'no planId or jarId is provided in the search query' + def expenseCategories = service.search(null, null) + + then: 'the service returns an empty list of expense categories' + assert expenseCategories.isEmpty() + } + + def "should get empty list when jarId is not provided"() { + when: 'no jarId is provided in the search query' + def expenseCategories = service.search(UUID.randomUUID(), null) + + then: 'the service returns an empty list of expense categories' + assert expenseCategories.isEmpty() + } + + def "should get empty list when jarId for provided planId is not found"() { + setup: 'stub the jars repository to return a non existing Jar for provided plan' + 1 * jars.existsByIdAndPlan(_ as Plan.PlanIdentifier, _ as JarIdentifier) >> Boolean.FALSE + + when: 'no planId or jarId is provided in the search query' + def expenseCategories = service.search(UUID.randomUUID(), UUID.randomUUID()) + + then: 'the service returns an empty list of expense categories' + assert expenseCategories.isEmpty() + } + + def "should get a list with one jar expense category"() { + setup: 'stub the jars repository to return an existing Jar for provided plan' + 1 * jars.existsByIdAndPlan(_ as Plan.PlanIdentifier, _ as JarIdentifier) >> Boolean.TRUE + 1 * jarExpenseCategories.findByJarId(_ as JarIdentifier) >> [newSampleJarExpenseCategory()] + + + when: 'no planId or jarId is provided in the search query' + def expenseCategories = service.search(UUID.randomUUID(), UUID.randomUUID()) + + then: 'the service returns a list with one expense category' + assert expenseCategories.size() == 1 + } +}