diff --git a/server/src/main/java/io/myfinbox/income/adapter/web/IncomeSourceController.java b/server/src/main/java/io/myfinbox/income/adapter/web/IncomeSourceController.java index 6f94e28..446c862 100644 --- a/server/src/main/java/io/myfinbox/income/adapter/web/IncomeSourceController.java +++ b/server/src/main/java/io/myfinbox/income/adapter/web/IncomeSourceController.java @@ -1,5 +1,6 @@ package io.myfinbox.income.adapter.web; +import io.myfinbox.income.application.IncomeSourceQuery; import io.myfinbox.income.application.IncomeSourceService; import io.myfinbox.income.domain.IncomeSource; import io.myfinbox.rest.IncomeSourceResource; @@ -21,6 +22,7 @@ final class IncomeSourceController implements IncomesSourceApi { private final IncomeSourceService incomeSourceService; + private final IncomeSourceQuery incomeSourceQuery; private final ApiFailureHandler apiFailureHandler; @PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) @@ -43,6 +45,13 @@ public ResponseEntity delete(@PathVariable UUID incomeSourceId) { .fold(apiFailureHandler::handle, ok -> noContent().build()); } + @GetMapping + public ResponseEntity list(@RequestParam(value = "accountId") UUID accountId) { + return ok(incomeSourceQuery.search(accountId).stream() + .map(this::toResource) + .toList()); + } + private IncomeSourceResource toResource(IncomeSource incomeSource) { return new IncomeSourceResource() .incomeSourceId(incomeSource.getId().id()) diff --git a/server/src/main/java/io/myfinbox/income/adapter/web/IncomesSourceApi.java b/server/src/main/java/io/myfinbox/income/adapter/web/IncomesSourceApi.java index 1972abf..fb1fc62 100644 --- a/server/src/main/java/io/myfinbox/income/adapter/web/IncomesSourceApi.java +++ b/server/src/main/java/io/myfinbox/income/adapter/web/IncomesSourceApi.java @@ -5,6 +5,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; @@ -17,6 +18,7 @@ import java.util.UUID; import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.QUERY; import static org.springframework.http.HttpHeaders.LOCATION; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -81,4 +83,18 @@ ResponseEntity update(@Parameter(in = PATH, description = "Income Source ID t content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) }) ResponseEntity delete(@Parameter(in = PATH, description = "IncomeSourceID to be deleted", required = true) UUID incomeSourceId); + + @Operation(summary = "Query a list of income sources for a specified account in the MyFinBox", + description = "Query a list of income sources for a specified account 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 = IncomeSourceResource.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 = QUERY, description = "Account to be used for selecting income sources", required = true) UUID accountId); } diff --git a/server/src/main/java/io/myfinbox/income/application/IncomeSourceQuery.java b/server/src/main/java/io/myfinbox/income/application/IncomeSourceQuery.java new file mode 100644 index 0000000..4011d00 --- /dev/null +++ b/server/src/main/java/io/myfinbox/income/application/IncomeSourceQuery.java @@ -0,0 +1,21 @@ +package io.myfinbox.income.application; + +import io.myfinbox.income.domain.IncomeSource; + +import java.util.List; +import java.util.UUID; + +/** + * Interface for querying income source information. + */ +public interface IncomeSourceQuery { + + /** + * Searches for income sources associated with a specific account. + * + * @param accountId the unique identifier of the account to search income sources for + * @return a list of {@link IncomeSource} objects associated with the specified account + */ + List search(UUID accountId); + +} diff --git a/server/src/main/java/io/myfinbox/income/application/IncomeSourceQueryService.java b/server/src/main/java/io/myfinbox/income/application/IncomeSourceQueryService.java new file mode 100644 index 0000000..9dc7527 --- /dev/null +++ b/server/src/main/java/io/myfinbox/income/application/IncomeSourceQueryService.java @@ -0,0 +1,31 @@ +package io.myfinbox.income.application; + +import io.myfinbox.income.domain.AccountIdentifier; +import io.myfinbox.income.domain.IncomeSource; +import io.myfinbox.income.domain.IncomeSources; +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 IncomeSourceQueryService implements IncomeSourceQuery { + + private final IncomeSources incomeSources; + + @Override + public List search(UUID accountId) { + if (isNull(accountId)) { + return emptyList(); + } + + return incomeSources.findByAccount(new AccountIdentifier(accountId)); + } +} 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 bcc047f..00ae810 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 @@ -128,8 +128,8 @@ class ExpenseCategoryControllerSpec extends Specification { @Sql('/expense/web/expensecategory-create.sql') def "should get a list with two expense category"() { - when: 'list expense category' - def response = getExpenses(UUID.fromString(DataSamples.accountId)) + when: 'list expense categories' + def response = listExpenseCategories(UUID.fromString(DataSamples.accountId)) then: 'response status is OK' assert response.getStatusCode() == OK @@ -160,7 +160,7 @@ class ExpenseCategoryControllerSpec extends Specification { ) } - def getExpenses(UUID accountId) { + def listExpenseCategories(UUID accountId) { def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/expenses/category") .queryParam("accountId", accountId) .build() diff --git a/server/src/test/groovy/io/myfinbox/expense/application/CategoryQueryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/expense/application/CategoryQueryServiceSpec.groovy index 335e27e..25fae09 100644 --- a/server/src/test/groovy/io/myfinbox/expense/application/CategoryQueryServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/application/CategoryQueryServiceSpec.groovy @@ -44,10 +44,10 @@ class CategoryQueryServiceSpec extends Specification { setup: 'mock the repository to return a list with a sample category for any account identifier' 1 * categories.findByAccount(_ as AccountIdentifier) >> [newSampleCategory()] - when: 'Searching for categories with a random account ID' + when: 'searching for categories with a random account ID' def categoryList = service.search(UUID.randomUUID()) then: 'the result should be a list with one category' - assert categoryList.size() == 1 + assert categoryList.containsAll(newSampleCategory()) } } diff --git a/server/src/test/groovy/io/myfinbox/income/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/income/DataSamples.groovy index 02dea6a..e9d8c6f 100644 --- a/server/src/test/groovy/io/myfinbox/income/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/income/DataSamples.groovy @@ -108,6 +108,10 @@ class DataSamples { JsonOutput.toJson(INCOME_RESOURCE + map) as String } + static newValidIncomeSourceResourceList() { + JsonOutput.toJson([INCOME_SOURCE_RESOURCE, INCOME_SOURCE_RESOURCE + [incomeSourceId: incomeSourceId2, name: 'Other']]) as String + } + static newValidIncomeCommand(map = [:]) { MAPPER.readValue(JsonOutput.toJson(INCOME_COMMAND + map) as String, IncomeCommand.class) } diff --git a/server/src/test/groovy/io/myfinbox/income/adapter/web/IncomeSourceControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/income/adapter/web/IncomeSourceControllerSpec.groovy index be3d710..990326c 100644 --- a/server/src/test/groovy/io/myfinbox/income/adapter/web/IncomeSourceControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/income/adapter/web/IncomeSourceControllerSpec.groovy @@ -3,7 +3,6 @@ package io.myfinbox.income.adapter.web import groovy.json.JsonOutput import groovy.json.JsonSlurper import io.myfinbox.TestServerApplication -import io.myfinbox.income.DataSamples import org.skyscreamer.jsonassert.JSONAssert import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -11,15 +10,17 @@ 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.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 -import static io.myfinbox.income.DataSamples.newValidIncomeSourceResource +import static io.myfinbox.income.DataSamples.* 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 @@ -112,7 +113,7 @@ class IncomeSourceControllerSpec extends Specification { } @Sql(['/income/web/incomesource-create.sql', '/income/web/income-create.sql']) - def "Should fail delete when income source is in use"() { + def "should fail delete when income source is in use"() { when: 'income source fails to delete' def response = deleteIncomeSource() @@ -123,13 +124,25 @@ class IncomeSourceControllerSpec extends Specification { JSONAssert.assertEquals(expectDeleteConflictFailure(), response.getBody(), LENIENT) } + @Sql('/income/web/incomesource-create.sql') + def "should get a list with two income source"() { + when: 'list income sources' + def response = listIncomeSources(UUID.fromString(accountId)) + + then: 'response status is OK' + assert response.getStatusCode() == OK + + and: 'a list of two income sources is present' + JSONAssert.assertEquals(newValidIncomeSourceResourceList(), response.getBody(), LENIENT) + } + private postIncomeSource(String request) { restTemplate.postForEntity('/v1/incomes/income-source', entityRequest(request), String.class) } private putIncomeSource(String request) { restTemplate.exchange( - "/v1/incomes/income-source/${DataSamples.incomeSourceId}", + "/v1/incomes/income-source/${incomeSourceId}", PUT, entityRequest(request), String.class @@ -138,13 +151,27 @@ class IncomeSourceControllerSpec extends Specification { private deleteIncomeSource() { restTemplate.exchange( - "/v1/incomes/income-source/${DataSamples.incomeSourceId}", + "/v1/incomes/income-source/${incomeSourceId}", DELETE, entityRequest(null), String.class ) } + def listIncomeSources(UUID accountId) { + def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/incomes/income-source") + .queryParam("accountId", accountId) + .build() + .toUri() + + restTemplate.exchange( + uri, + HttpMethod.GET, + null, + String.class + ) + } + def expectedCreatedResource(ResponseEntity response) { newValidIncomeSourceResource( incomeSourceId: idFromLocation(response.getHeaders().getLocation()) diff --git a/server/src/test/groovy/io/myfinbox/income/application/IncomeSourceQueryServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/income/application/IncomeSourceQueryServiceSpec.groovy new file mode 100644 index 0000000..9731354 --- /dev/null +++ b/server/src/test/groovy/io/myfinbox/income/application/IncomeSourceQueryServiceSpec.groovy @@ -0,0 +1,53 @@ +package io.myfinbox.income.application + +import io.myfinbox.income.domain.AccountIdentifier +import io.myfinbox.income.domain.IncomeSources +import spock.lang.Specification +import spock.lang.Tag + +import static io.myfinbox.income.DataSamples.* + +@Tag("unit") +class IncomeSourceQueryServiceSpec extends Specification { + + IncomeSources incomeSources + IncomeSourceQueryService service + + def setup() { + incomeSources = Mock() + service = new IncomeSourceQueryService(incomeSources) + } + + def "should get empty list when income sources for provided account id not found"() { + setup: 'mock the repository to return an empty list for any account identifier' + 1 * incomeSources.findByAccount(_ as AccountIdentifier) >> [] + + when: 'searching for income sources with a non-exiting account ID' + def incomeSourcesList = service.search(UUID.randomUUID()) + + then: 'the result should be an empty list' + assert incomeSourcesList.isEmpty() + } + + def "should get empty list when account id is null"() { + when: 'searching for income sources with a null account ID' + def incomeSourcesList = service.search(null) + + then: 'the result should be an empty list' + assert incomeSourcesList.isEmpty() + + and: 'the repository should not be queried' + 0 * incomeSources.findByAccount(_ as AccountIdentifier) + } + + def "should get a list of income sources"() { + setup: 'mock the repository to return an empty list for any account identifier' + 1 * incomeSources.findByAccount(_ as AccountIdentifier) >> [newSampleIncomeSource()] + + when: 'searching for income sources with a random account ID' + def incomeSourcesList = service.search(UUID.randomUUID()) + + then: 'the result should be a list with one income source' + assert incomeSourcesList.containsAll(newSampleIncomeSource()) + } +} diff --git a/server/src/test/resources/income/web/incomesource-create.sql b/server/src/test/resources/income/web/incomesource-create.sql index bd2bb39..a6263b3 100644 --- a/server/src/test/resources/income/web/incomesource-create.sql +++ b/server/src/test/resources/income/web/incomesource-create.sql @@ -1,9 +1,12 @@ INSERT INTO server.income_source (id, account_id, + creation_timestamp, name) VALUES ('3b257779-a5db-4e87-9365-72c6f8d4977d', 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', - 'Bills'), + '2024-03-23T10:00:04.224870Z', + 'Business'), ('e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', 'e2709aa2-7907-4f78-98b6-0f36a0c1b5ca', + '2024-03-23T10:00:04.224870Z', 'Other');