From f931e9a6e043dfe9685aacc1bc179e542b1619a1 Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Wed, 14 Aug 2024 20:19:10 +0300 Subject: [PATCH] Expose Query Jars API --- .../adapter/web/JarController.java | 28 ++++++++ .../spendingplan/adapter/web/JarsApi.java | 32 +++++++++ .../spendingplan/application/JarQuery.java | 49 +++++++++++++ .../application/JarQueryService.java | 70 ++++++++++++++++++ .../io/myfinbox/spendingplan/domain/Jars.java | 7 ++ .../myfinbox/spendingplan/DataSamples.groovy | 8 +++ .../adapter/web/JarControllerSpec.groovy | 71 +++++++++++++++++++ .../adapter/web/PlanControllerSpec.groovy | 2 +- .../application/JarQueryServiceSpec.groovy | 69 ++++++++++++++++++ 9 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/JarQuery.java create mode 100644 server/src/main/java/io/myfinbox/spendingplan/application/JarQueryService.java create mode 100644 server/src/test/groovy/io/myfinbox/spendingplan/application/JarQueryServiceSpec.groovy 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 e4c3fa3..5f362fc 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 @@ -3,10 +3,12 @@ import io.myfinbox.rest.JarCategoryModificationResource; 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.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; @@ -28,6 +30,7 @@ final class JarController implements JarsApi { private final CreateJarUseCase createJarUseCase; private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase; + private final JarQuery jarQuery; private final ApiFailureHandler apiFailureHandler; private final ConversionService conversionService; @@ -46,6 +49,31 @@ public ResponseEntity modifyExpenseCategories(@PathVariable UUID planId, .fold(apiFailureHandler::handle, ok -> ok().build()); } + @GetMapping(path = "/{planId}/jars/{jarId}", produces = APPLICATION_JSON_VALUE) + public ResponseEntity one(@PathVariable UUID planId, @PathVariable UUID jarId) { + var jars = jarQuery.search() + .withPlanId(planId) + .withJarId(jarId) + .list(); + + if (jars.isEmpty()) { + return apiFailureHandler.handle(Failure.ofNotFound("Jar with ID '%s' for plan ID '%s' was not found.".formatted(jarId, planId))); + } + + return ok().body(conversionService.convert(jars.getFirst(), JarResource.class)); + } + + @GetMapping(path = "/{planId}/jars", produces = APPLICATION_JSON_VALUE) + public ResponseEntity list(@PathVariable("planId") UUID planId) { + var jars = jarQuery.search() + .withPlanId(planId) + .list(); + + return ok().body(jars.stream() + .map(jar -> conversionService.convert(jar, JarResource.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 5415b6f..56554bf 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 @@ -6,6 +6,7 @@ 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.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -71,4 +72,35 @@ ResponseEntity modifyExpenseCategories( @Parameter(in = PATH, description = "ID of the spending jar to modify categories", required = true) UUID jarId, @RequestBody(description = "Resource containing categories to add or remove", required = true) JarCategoryModificationResource resource); + @Operation(summary = "Query a list of jars for a specified planId in the MyFinBox", + description = "Query a list of jars for a specified planId 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 selecting spending jars", required = true) UUID planId); + + @Operation(summary = "Query a spending jar for a specified jar ID and plan ID in the MyFinBox", + description = "Query a spending jar for a specified jar ID and plan ID 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 = "404", description = "Jar not found 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 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); + } diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/JarQuery.java b/server/src/main/java/io/myfinbox/spendingplan/application/JarQuery.java new file mode 100644 index 0000000..3914859 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/JarQuery.java @@ -0,0 +1,49 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.Jar; + +import java.util.List; +import java.util.UUID; + +/** + * Represents a query interface for searching and retrieving jars. + */ +public interface JarQuery { + + /** + * Initiates a search for jars. + * + * @return a builder to further customize the jar search. + */ + JarQueryBuilder search(); + + /** + * Builder interface for constructing and executing a jar query. + */ + interface JarQueryBuilder { + + /** + * Filters the jars by the specified plan ID. + * + * @param planId the unique identifier of the plan. + * @return the updated query builder. + */ + JarQueryBuilder withPlanId(UUID planId); + + /** + * Filters the jars by the specified jar ID. + * + * @param jarId the unique identifier of the jar. + * @return the updated query builder. + */ + JarQueryBuilder withJarId(UUID jarId); + + /** + * Executes the query and returns a list of jars matching the criteria. + * + * @return a list of jars matching the query criteria. + */ + List list(); + } +} + diff --git a/server/src/main/java/io/myfinbox/spendingplan/application/JarQueryService.java b/server/src/main/java/io/myfinbox/spendingplan/application/JarQueryService.java new file mode 100644 index 0000000..94af827 --- /dev/null +++ b/server/src/main/java/io/myfinbox/spendingplan/application/JarQueryService.java @@ -0,0 +1,70 @@ +package io.myfinbox.spendingplan.application; + +import io.myfinbox.spendingplan.domain.Jar; +import io.myfinbox.spendingplan.domain.JarIdentifier; +import io.myfinbox.spendingplan.domain.Jars; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static io.myfinbox.spendingplan.domain.Plan.*; +import static java.util.Collections.emptyList; +import static java.util.Objects.nonNull; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +class JarQueryService implements JarQuery { + + private final Jars jars; + + @Override + public JarQueryBuilder search() { + return new DefaultJarQueryBuilder(jars); + } + + @RequiredArgsConstructor + private static final class DefaultJarQueryBuilder implements JarQueryBuilder { + + private final Jars jars; + + private UUID planId; + private UUID jarId; + + @Override + public JarQueryBuilder withPlanId(UUID planId) { + this.planId = planId; + return this; + } + + @Override + public JarQueryBuilder withJarId(UUID jarId) { + this.jarId = jarId; + return this; + } + + @Override + public List list() { + if (nonNull(planId) && nonNull(jarId)) { + return jars.findByIdAndPlanId(new JarIdentifier(jarId), new PlanIdentifier(planId)) + .map(List::of) + .orElse(emptyList()); + } + + if (nonNull(jarId)) { + return jars.findById(new JarIdentifier(jarId)) + .map(List::of) + .orElse(emptyList()); + } + + if (nonNull(planId)) { + return jars.findByPlanId(new PlanIdentifier(planId)); + } + + return emptyList(); + } + } +} 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 f06b29b..046bc9e 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java +++ b/server/src/main/java/io/myfinbox/spendingplan/domain/Jars.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -18,4 +19,10 @@ public interface Jars extends JpaRepository { """) Optional findByIdAndPlanId(JarIdentifier jarId, PlanIdentifier planId); + @Query(value = """ + SELECT j FROM Jar j + WHERE j.plan.id = :planId + """) + List findByPlanId(PlanIdentifier planId); + } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy index 3e5a048..5cee9ec 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy @@ -248,4 +248,12 @@ class DataSamples { static newSampleExpenseDeletedEvent(map = [:]) { MAPPER.readValue(JsonOutput.toJson(EXPENSE_EVENT + map) as String, ExpenseDeleted.class) } + + static newSampleJarAsString() { + JsonOutput.toJson(JAR_PLAIN) + } + + static newSampleListJarAsString() { + JsonOutput.toJson([JAR_PLAIN]) + } } 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 10857ab..b89ab6b 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 @@ -13,12 +13,14 @@ import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod import org.springframework.http.ResponseEntity import org.springframework.jdbc.core.JdbcTemplate import org.springframework.modulith.test.ApplicationModuleTest import org.springframework.test.context.TestPropertySource import org.springframework.test.context.jdbc.Sql import org.springframework.test.jdbc.JdbcTestUtils +import org.springframework.web.util.UriComponentsBuilder import spock.lang.Specification import spock.lang.Tag @@ -136,6 +138,41 @@ class JarControllerSpec extends Specification { JSONAssert.assertEquals(expectedCategoriesModificationFailure(), response.getBody(), LENIENT) } + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql']) + def "should get one existing spending jar"() { + when: 'getting one jar' + def response = getOneJar(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(newSampleJarAsString(), response.getBody(), LENIENT) + } + + def "should get not found when spending jar was not found"() { + when: 'getting one jar' + def response = getOneJar(UUID.fromString(planId), UUID.fromString(jarId)) + + then: 'the response status is "NOT_FOUND"' + assert response.getStatusCode() == NOT_FOUND + + and: 'the response body contains the not found failure' + JSONAssert.assertEquals(expectedJarNotFoundFailure(), response.getBody(), LENIENT) + } + + @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql']) + def "should get a list with one existing spending jar for provided planId"() { + when: 'listing jars by planId' + def response = listJars(UUID.fromString(planId)) + + then: 'the response status is "OK"' + assert response.getStatusCode() == OK + + and: 'the response body contains the expected resource' + JSONAssert.assertEquals(newSampleListJarAsString(), response.getBody(), LENIENT) + } + def postJar(String req) { restTemplate.postForEntity("/v1/plans/${planId}/jars", entityRequest(req), String.class) } @@ -149,6 +186,32 @@ class JarControllerSpec extends Specification { ) } + def getOneJar(UUID planId, UUID jarId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}/jars/${jarId}") + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + + def listJars(UUID planId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}/jars") + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + def entityRequest(String req) { var headers = new HttpHeaders() headers.setContentType(APPLICATION_JSON) @@ -188,4 +251,12 @@ class JarControllerSpec extends Specification { message : "Spending plan jar was not found." ]) } + + def expectedJarNotFoundFailure() { + JsonOutput.toJson([ + status : 404, + errorCode: "NOT_FOUND", + message : "Jar with ID '${jarId}' for plan ID '${planId}' was not found." + ]) + } } diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy index a6255d0..2081f88 100644 --- a/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/spendingplan/adapter/web/PlanControllerSpec.groovy @@ -176,7 +176,7 @@ class PlanControllerSpec extends Specification { } @Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql']) - def "should list a list with one spending plan for provided account"() { + def "should get a list with one spending plan for provided account"() { when: 'listing plans by accountId' def response = listPlans(UUID.fromString(accountId)) diff --git a/server/src/test/groovy/io/myfinbox/spendingplan/application/JarQueryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/spendingplan/application/JarQueryServiceSpec.groovy new file mode 100644 index 0000000..74ec177 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/spendingplan/application/JarQueryServiceSpec.groovy @@ -0,0 +1,69 @@ +package io.myfinbox.spendingplan.application + +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.* + +@Tag("unit") +class JarQueryServiceSpec extends Specification { + + Jars jars + JarQueryService service + + def setup() { + jars = Mock() + service = new JarQueryService(jars) + } + + def "should get empty list when planId and jarId are not provided"() { + when: 'no planId or jarId is provided in the search query' + def plans = service.search().list() + + then: 'the service returns an empty list of jars' + assert plans.isEmpty() + } + + def "should get a list with one jar when planId and jarId are provided"() { + setup: 'stub the jars repository to return a jar when both planId and jarId are provided' + 1 * jars.findByIdAndPlanId(_ as JarIdentifier, _ as Plan.PlanIdentifier) >> Optional.of(newSampleJar()) + + when: 'search is performed with both planId and jarId' + def plans = service.search() + .withPlanId(UUID.fromString(planId)) + .withJarId(UUID.fromString(jarId)) + .list() + + then: 'the service returns a list containing one jar' + assert plans.size() == 1 + } + + def "should get a list with one jar when jarId is provided"() { + setup: 'stub the jars repository to return a jar when jarId is provided' + 1 * jars.findById(_ as JarIdentifier) >> Optional.of(newSampleJar()) + + when: 'search is performed with jarId' + def plans = service.search() + .withJarId(UUID.fromString(jarId)) + .list() + + then: 'the service returns a list containing one jar' + assert plans.size() == 1 + } + + def "should get a list with one jar when planId is provided"() { + setup: 'stub the jars repository to return a jar when jarId is provided' + 1 * jars.findByPlanId(_ as Plan.PlanIdentifier) >> [newSampleJar()] + + when: 'search is performed with planId' + def plans = service.search() + .withPlanId(UUID.fromString(planId)) + .list() + + then: 'the service returns a list containing one jar' + assert plans.size() == 1 + } +}