Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose Income Sources query API #40

Merged
merged 1 commit into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<IncomeSource> search(UUID accountId);

}
Original file line number Diff line number Diff line change
@@ -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<IncomeSource> search(UUID accountId) {
if (isNull(accountId)) {
return emptyList();
}

return incomeSources.findByAccount(new AccountIdentifier(accountId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
4 changes: 4 additions & 0 deletions server/src/test/groovy/io/myfinbox/income/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@ 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
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
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
5 changes: 4 additions & 1 deletion server/src/test/resources/income/web/incomesource-create.sql
Original file line number Diff line number Diff line change
@@ -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');
Loading