From 74ffd1ef67f97af62845987b0e23008a2d59f2bb Mon Sep 17 00:00:00 2001 From: Serghei Motpan Date: Sun, 4 Aug 2024 10:13:37 +0300 Subject: [PATCH] Enable account locale resolving and add account preference --- .../io/myfinbox/account/AccountCreated.java | 10 +- .../adapter/web/AccountController.java | 34 +++++-- .../account/adapter/web/AccountsApi.java | 6 +- .../application/CreateAccountService.java | 48 ++++++++-- .../application/CreateAccountUseCase.java | 6 +- .../io/myfinbox/account/domain/Account.java | 58 +++++------- .../account/domain/AccountDetails.java | 42 +++++++++ .../io/myfinbox/account/domain/Accounts.java | 1 - .../myfinbox/account/domain/EmailAddress.java | 27 ++++++ .../myfinbox/account/domain/Preference.java | 38 ++++++++ .../application/CreateIncomeService.java | 1 - .../adapter/web/PlanController.java | 2 +- ...10__alter_accounts_add_zoneid_currency.sql | 5 + server/src/main/resources/openapi.yml | 9 ++ .../io/myfinbox/account/DataSamples.groovy | 21 ++--- .../adapter/web/AccountControllerSpec.groovy | 18 +--- .../CreateAccountServiceSpec.groovy | 94 +++++++++++++++---- .../AccountEventsListenerSpec.groovy | 10 +- .../AccountEventsListenerSpec.groovy | 10 +- .../account-creation-failure-response.json | 4 + 20 files changed, 344 insertions(+), 100 deletions(-) create mode 100644 server/src/main/java/io/myfinbox/account/domain/AccountDetails.java create mode 100644 server/src/main/java/io/myfinbox/account/domain/EmailAddress.java create mode 100644 server/src/main/java/io/myfinbox/account/domain/Preference.java create mode 100644 server/src/main/resources/db/migration/V1_10__alter_accounts_add_zoneid_currency.sql diff --git a/server/src/main/java/io/myfinbox/account/AccountCreated.java b/server/src/main/java/io/myfinbox/account/AccountCreated.java index c5a819b..f934aad 100644 --- a/server/src/main/java/io/myfinbox/account/AccountCreated.java +++ b/server/src/main/java/io/myfinbox/account/AccountCreated.java @@ -3,6 +3,8 @@ import io.myfinbox.shared.DomainEvent; import lombok.Builder; +import java.time.ZoneId; +import java.util.Currency; import java.util.UUID; import static io.myfinbox.shared.Guards.notBlank; @@ -18,7 +20,9 @@ public record AccountCreated(UUID accountId, String emailAddress, String firstName, - String lastName) implements DomainEvent { + String lastName, + Currency currency, + ZoneId zoneId) implements DomainEvent { /** * Constructor for the AccountCreated record. @@ -27,9 +31,13 @@ public record AccountCreated(UUID accountId, * @param emailAddress The email address associated with the account. * @param firstName The first name of the account holder. * @param lastName The last name of the account holder. + * @param currency The currency of the account holder. + * @param zoneId The zoneId of the account holder. */ public AccountCreated { notNull(accountId, "accountIdentifier cannot be null"); notBlank(emailAddress, "emailAddress cannot be blank"); + notNull(currency, "currency cannot be null"); + notNull(zoneId, "zoneId cannot be null"); } } diff --git a/server/src/main/java/io/myfinbox/account/adapter/web/AccountController.java b/server/src/main/java/io/myfinbox/account/adapter/web/AccountController.java index fdf1922..840dbab 100644 --- a/server/src/main/java/io/myfinbox/account/adapter/web/AccountController.java +++ b/server/src/main/java/io/myfinbox/account/adapter/web/AccountController.java @@ -6,11 +6,13 @@ import io.myfinbox.shared.ApiFailureHandler; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import java.util.Currency; +import java.util.Locale; + +import static java.util.Objects.isNull; +import static org.springframework.http.HttpHeaders.ACCEPT_LANGUAGE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static org.springframework.http.ResponseEntity.created; import static org.springframework.web.servlet.support.ServletUriComponentsBuilder.fromCurrentRequest; @@ -20,19 +22,39 @@ @RequiredArgsConstructor final class AccountController implements AccountsApi { + static final Locale defaultLocale = Locale.of("en", "MD"); + private final CreateAccountUseCase createAccountUseCase; private final ApiFailureHandler apiFailureHandler; @PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE) - public ResponseEntity create(@RequestBody AccountCreateResource resource) { + public ResponseEntity create(@RequestHeader(name = ACCEPT_LANGUAGE, required = false) Locale locale, + @RequestBody AccountCreateResource resource) { var command = CreateAccountCommand.builder() .firstName(resource.getFirstName()) .lastName(resource.getLastName()) .emailAddress(resource.getEmailAddress()) + .currency(Currency.getInstance(resolve(locale)).getCurrencyCode()) + .zoneId(resource.getZoneId()) .build(); return createAccountUseCase.create(command).fold(apiFailureHandler::handle, account -> created(fromCurrentRequest().path("/{id}").build(account.getId().toString())) - .body(resource.accountId(account.getId().id()))); + .body(resource.accountId(account.getId().id()) + .currency(command.currency()) + .zoneId(resource.getZoneId()) + )); + } + + private Locale resolve(Locale locale) { + try { + if (isNull(Currency.getInstance(locale).getCurrencyCode())) { + return defaultLocale; + } + } catch (NullPointerException | IllegalArgumentException e) { + return defaultLocale; + } + + return locale; } } diff --git a/server/src/main/java/io/myfinbox/account/adapter/web/AccountsApi.java b/server/src/main/java/io/myfinbox/account/adapter/web/AccountsApi.java index 8731cef..b7294ff 100644 --- a/server/src/main/java/io/myfinbox/account/adapter/web/AccountsApi.java +++ b/server/src/main/java/io/myfinbox/account/adapter/web/AccountsApi.java @@ -3,6 +3,7 @@ import io.myfinbox.rest.AccountCreateResource; import io.myfinbox.shared.ApiErrorResponse; 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.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -13,7 +14,9 @@ import org.springframework.http.ResponseEntity; import java.net.URI; +import java.util.Locale; +import static io.swagger.v3.oas.annotations.enums.ParameterIn.HEADER; import static org.springframework.http.HttpHeaders.LOCATION; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -37,6 +40,7 @@ public interface AccountsApi { @ApiResponse(responseCode = "500", description = "Internal Server Error", content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))) }) - ResponseEntity create(@RequestBody(description = "AccountResource to be created", required = true) AccountCreateResource resource); + ResponseEntity create(@Parameter(in = HEADER) Locale locale, + @RequestBody(description = "AccountResource to be created", required = true) AccountCreateResource resource); } diff --git a/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java b/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java index 0f7628c..db56026 100644 --- a/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java +++ b/server/src/main/java/io/myfinbox/account/application/CreateAccountService.java @@ -1,7 +1,6 @@ package io.myfinbox.account.application; -import io.myfinbox.account.domain.Account; -import io.myfinbox.account.domain.Accounts; +import io.myfinbox.account.domain.*; import io.myfinbox.shared.Failure; import io.myfinbox.shared.Failure.FieldViolation; import io.vavr.collection.Seq; @@ -13,6 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.Currency; import java.util.regex.Pattern; import static io.myfinbox.account.application.CreateAccountUseCase.CreateAccountCommand.*; @@ -37,14 +39,14 @@ public Either create(CreateAccountCommand cmd) { return Either.left(Failure.ofValidation(ERROR_MESSAGE, validation.getError().toJavaList())); } - if (accounts.existsByEmailAddress(new Account.EmailAddress(cmd.emailAddress()))) { + if (accounts.existsByEmailAddress(new EmailAddress(cmd.emailAddress()))) { return Either.left(Failure.ofConflict("Email address '%s' already exists.".formatted(cmd.emailAddress()))); } var account = Account.builder() - .firstName(cmd.firstName()) - .lastName(cmd.lastName()) - .emailAddress(new Account.EmailAddress(cmd.emailAddress())) + .accountDetails(new AccountDetails(cmd.firstName(), cmd.lastName())) + .emailAddress(new EmailAddress(cmd.emailAddress())) + .preference(new Preference(Currency.getInstance(cmd.currency()), ZoneId.of(cmd.zoneId()))) .build(); accounts.save(account); @@ -58,8 +60,10 @@ Validation, CreateAccountCommand> validate(CreateAccountComm return Validation.combine( validateFirstName(cmd.firstName()), validateLastName(cmd.lastName()), - validateEmailAddress(cmd.emailAddress()) - ).ap((firstName, lastName, emailAddress) -> cmd); + validateEmailAddress(cmd.emailAddress()), + validateCurrency(cmd.currency()), + validateZoneId(cmd.zoneId()) + ).ap((firstName, lastName, emailAddress, currency, zoneId) -> cmd); } private Validation validateFirstName(String firstName) { @@ -114,5 +118,33 @@ private Validation validateEmailAddress(String emailAddr return Valid(emailAddress); } + + private Validation validateCurrency(String currencyCode) { + try { + Currency.getInstance(currencyCode); + } catch (NullPointerException | IllegalArgumentException e) { + return Invalid(FieldViolation.builder() + .field(FIELD_CURRENCY) + .message("Currency '%s' is invalid.".formatted(currencyCode)) + .rejectedValue(currencyCode) + .build()); + } + + return Valid(currencyCode); + } + + private Validation validateZoneId(String zoneId) { + try { + ZoneId.of(zoneId); + } catch (DateTimeException | NullPointerException e) { + return Invalid(FieldViolation.builder() + .field(FIELD_ZONE_ID) + .message("ZoneId '%s' is invalid.".formatted(zoneId)) + .rejectedValue(zoneId) + .build()); + } + + return Valid(zoneId); + } } } diff --git a/server/src/main/java/io/myfinbox/account/application/CreateAccountUseCase.java b/server/src/main/java/io/myfinbox/account/application/CreateAccountUseCase.java index 6690131..26d3029 100644 --- a/server/src/main/java/io/myfinbox/account/application/CreateAccountUseCase.java +++ b/server/src/main/java/io/myfinbox/account/application/CreateAccountUseCase.java @@ -21,11 +21,15 @@ public interface CreateAccountUseCase { @Builder record CreateAccountCommand(String firstName, String lastName, - String emailAddress) { + String emailAddress, + String currency, + String zoneId) { public static final String FIELD_FIRST_NAME = "firstName"; public static final String FIELD_LAST_NAME = "lastName"; public static final String FIELD_EMAIL_ADDRESS = "emailAddress"; + public static final String FIELD_CURRENCY = "currency"; + public static final String FIELD_ZONE_ID = "zoneId"; } } diff --git a/server/src/main/java/io/myfinbox/account/domain/Account.java b/server/src/main/java/io/myfinbox/account/domain/Account.java index aa230b2..8d71bcd 100644 --- a/server/src/main/java/io/myfinbox/account/domain/Account.java +++ b/server/src/main/java/io/myfinbox/account/domain/Account.java @@ -3,7 +3,6 @@ import io.myfinbox.account.AccountCreated; import jakarta.persistence.*; import lombok.*; -import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.AbstractAggregateRoot; import java.io.Serializable; @@ -11,7 +10,7 @@ import java.util.UUID; import java.util.regex.Pattern; -import static io.myfinbox.shared.Guards.*; +import static io.myfinbox.shared.Guards.notNull; import static lombok.AccessLevel.PRIVATE; @Entity @@ -27,28 +26,38 @@ public class Account extends AbstractAggregateRoot { public static final String patternRFC5322 = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$"; static final Pattern pattern = Pattern.compile(patternRFC5322); - private @EmbeddedId AccountIdentifier id; - private @Embedded EmailAddress emailAddress; - private String firstName; - private String lastName; - private Instant creationDate; + @EmbeddedId + private final AccountIdentifier id; + private final Instant creationDate; - @Builder - public Account(String firstName, String lastName, EmailAddress emailAddress) { - this.emailAddress = notNull(emailAddress, "emailAddress cannot be null"); + @Embedded + private EmailAddress emailAddress; - if (!StringUtils.isBlank(firstName)) { - this.firstName = doesNotOverflow(firstName.trim(), MAX_LENGTH, "firstName overflow, max length allowed '%d'".formatted(MAX_LENGTH)); - } + @Embedded + private AccountDetails accountDetails; - if (!StringUtils.isBlank(lastName)) { - this.lastName = doesNotOverflow(lastName.trim(), MAX_LENGTH, "lastName overflow, max length allowed '%d'".formatted(MAX_LENGTH)); - } + @Embedded + private Preference preference; + + @Builder + public Account(AccountDetails accountDetails, + EmailAddress emailAddress, + Preference preference) { + this.accountDetails = notNull(accountDetails, "accountDetails cannot be null"); + this.emailAddress = notNull(emailAddress, "emailAddress cannot be null"); + this.preference = notNull(preference, "preference cannot be null"); this.id = new AccountIdentifier(UUID.randomUUID()); this.creationDate = Instant.now(); - registerEvent(new AccountCreated(this.id.id(), this.emailAddress.emailAddress(), firstName, lastName)); + registerEvent(AccountCreated.builder() + .accountId(this.id.id()) + .emailAddress(this.emailAddress.emailAddress()) + .firstName(this.accountDetails.firstName()) + .lastName(this.accountDetails.lastName()) + .currency(this.preference.currency()) + .zoneId(this.preference.zoneId()) + .build()); } @Embeddable @@ -63,19 +72,4 @@ public String toString() { return id.toString(); } } - - @Embeddable - public record EmailAddress(String emailAddress) implements Serializable { - - public EmailAddress { - notBlank(emailAddress, "emailAddress cannot be blank"); - doesNotOverflow(emailAddress.trim(), MAX_LENGTH, "emailAddress max length must be '%d'".formatted(MAX_LENGTH)); - matches(emailAddress, pattern, "emailAddress must match '%s'".formatted(patternRFC5322)); - } - - @Override - public String toString() { - return emailAddress; - } - } } diff --git a/server/src/main/java/io/myfinbox/account/domain/AccountDetails.java b/server/src/main/java/io/myfinbox/account/domain/AccountDetails.java new file mode 100644 index 0000000..8644d6e --- /dev/null +++ b/server/src/main/java/io/myfinbox/account/domain/AccountDetails.java @@ -0,0 +1,42 @@ +package io.myfinbox.account.domain; + +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.apache.commons.lang3.StringUtils; + +import java.io.Serializable; + +import static io.myfinbox.shared.Guards.doesNotOverflow; +import static lombok.AccessLevel.PACKAGE; + +@ToString +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = PACKAGE, force = true) +public final class AccountDetails implements Serializable { + + public static final int MAX_LENGTH = 255; + + private String firstName; + private String lastName; + + public AccountDetails(String firstName, String lastName) { + if (!StringUtils.isBlank(firstName)) { + this.firstName = doesNotOverflow(firstName.trim(), MAX_LENGTH, "firstName overflow, max length allowed '%d'".formatted(MAX_LENGTH)); + } + + if (!StringUtils.isBlank(lastName)) { + this.lastName = doesNotOverflow(lastName.trim(), MAX_LENGTH, "lastName overflow, max length allowed '%d'".formatted(MAX_LENGTH)); + } + } + + public String firstName() { + return firstName; + } + + public String lastName() { + return lastName; + } +} diff --git a/server/src/main/java/io/myfinbox/account/domain/Accounts.java b/server/src/main/java/io/myfinbox/account/domain/Accounts.java index b2cd7ea..f1b6fb2 100644 --- a/server/src/main/java/io/myfinbox/account/domain/Accounts.java +++ b/server/src/main/java/io/myfinbox/account/domain/Accounts.java @@ -1,7 +1,6 @@ package io.myfinbox.account.domain; import io.myfinbox.account.domain.Account.AccountIdentifier; -import io.myfinbox.account.domain.Account.EmailAddress; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; diff --git a/server/src/main/java/io/myfinbox/account/domain/EmailAddress.java b/server/src/main/java/io/myfinbox/account/domain/EmailAddress.java new file mode 100644 index 0000000..1f944c7 --- /dev/null +++ b/server/src/main/java/io/myfinbox/account/domain/EmailAddress.java @@ -0,0 +1,27 @@ +package io.myfinbox.account.domain; + +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.regex.Pattern; + +import static io.myfinbox.shared.Guards.*; + +@Embeddable +public record EmailAddress(String emailAddress) implements Serializable { + + static final String patternRFC5322 = "^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$"; + static final Pattern pattern = Pattern.compile(patternRFC5322); + static final int MAX_LENGTH = 255; + + public EmailAddress { + notBlank(emailAddress, "emailAddress cannot be blank"); + doesNotOverflow(emailAddress.trim(), MAX_LENGTH, "emailAddress max length must be '%d'".formatted(MAX_LENGTH)); + matches(emailAddress, pattern, "emailAddress must match '%s'".formatted(patternRFC5322)); + } + + @Override + public String toString() { + return emailAddress; + } +} diff --git a/server/src/main/java/io/myfinbox/account/domain/Preference.java b/server/src/main/java/io/myfinbox/account/domain/Preference.java new file mode 100644 index 0000000..1889bd4 --- /dev/null +++ b/server/src/main/java/io/myfinbox/account/domain/Preference.java @@ -0,0 +1,38 @@ +package io.myfinbox.account.domain; + +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.io.Serializable; +import java.time.ZoneId; +import java.util.Currency; + +import static io.myfinbox.shared.Guards.notNull; +import static lombok.AccessLevel.PACKAGE; + +@ToString +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = PACKAGE, force = true) +public final class Preference implements Serializable { + + private final String currency; + private final String zoneId; + + public Preference(Currency currency, ZoneId zoneId) { + notNull(currency, "currency cannot be null."); + notNull(zoneId, "zoneId cannot be null."); + this.currency = currency.getCurrencyCode(); + this.zoneId = zoneId.getId(); + } + + public Currency currency() { + return Currency.getInstance(currency); + } + + public ZoneId zoneId() { + return ZoneId.of(zoneId); + } +} diff --git a/server/src/main/java/io/myfinbox/income/application/CreateIncomeService.java b/server/src/main/java/io/myfinbox/income/application/CreateIncomeService.java index 8105809..53e763a 100644 --- a/server/src/main/java/io/myfinbox/income/application/CreateIncomeService.java +++ b/server/src/main/java/io/myfinbox/income/application/CreateIncomeService.java @@ -1,6 +1,5 @@ package io.myfinbox.income.application; -import io.myfinbox.expense.domain.Expense; import io.myfinbox.income.domain.AccountIdentifier; import io.myfinbox.income.domain.Income; import io.myfinbox.income.domain.IncomeSource.IncomeSourceIdentifier; diff --git a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java index 65674ca..c55e0c5 100644 --- a/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java +++ b/server/src/main/java/io/myfinbox/spendingplan/adapter/web/PlanController.java @@ -4,10 +4,10 @@ import io.myfinbox.rest.PlanResource; import io.myfinbox.shared.ApiFailureHandler; import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase; -import io.myfinbox.spendingplan.application.UpdatePlanUseCase; import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand; import io.myfinbox.spendingplan.application.CreatePlanUseCase; import io.myfinbox.spendingplan.application.PlanCommand; +import io.myfinbox.spendingplan.application.UpdatePlanUseCase; import io.myfinbox.spendingplan.domain.Plan; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/server/src/main/resources/db/migration/V1_10__alter_accounts_add_zoneid_currency.sql b/server/src/main/resources/db/migration/V1_10__alter_accounts_add_zoneid_currency.sql new file mode 100644 index 0000000..1297f7b --- /dev/null +++ b/server/src/main/resources/db/migration/V1_10__alter_accounts_add_zoneid_currency.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS accounts + ADD COLUMN zone_id VARCHAR(100) NOT NULL DEFAULT 'Europe/Chisinau'; + +ALTER TABLE IF EXISTS accounts + ADD COLUMN currency VARCHAR(3) NOT NULL DEFAULT 'MDL'; diff --git a/server/src/main/resources/openapi.yml b/server/src/main/resources/openapi.yml index 75bf3ce..6080ea3 100644 --- a/server/src/main/resources/openapi.yml +++ b/server/src/main/resources/openapi.yml @@ -37,6 +37,15 @@ components: type: string example: Doe description: The last name of the account holder. + zoneId: + type: string + example: Europe/Paris + description: The user's time zone taken from browser, default 'Europe/Chisinau' + currency: + type: string + example: USD + description: The user's currency taken from locale, default MDL + readOnly: true emailAddress: type: string example: jondoe@email.com diff --git a/server/src/test/groovy/io/myfinbox/account/DataSamples.groovy b/server/src/test/groovy/io/myfinbox/account/DataSamples.groovy index 08897e2..4f25b09 100644 --- a/server/src/test/groovy/io/myfinbox/account/DataSamples.groovy +++ b/server/src/test/groovy/io/myfinbox/account/DataSamples.groovy @@ -2,7 +2,6 @@ package io.myfinbox.account import com.fasterxml.jackson.databind.json.JsonMapper import groovy.json.JsonOutput -import io.myfinbox.account.domain.Account import static io.myfinbox.account.application.CreateAccountUseCase.CreateAccountCommand @@ -12,35 +11,27 @@ class DataSamples { .findAndAddModules() .build() - static ACCOUNT = [ - id : ["id": "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca"], - emailAddress: ["emailAddress": "jonsnow@gmail.com"], - firstName : "Jon", - lastName : "Snow", - creationDate: "2024-03-17T15:15:04.224870Z" - ] - static CREATE_ACCOUNT_RESOURCE = [ firstName : "Jon", lastName : "Snow", - emailAddress: "jonsnow@gmail.com" + emailAddress: "jonsnow@gmail.com", + zoneId : "Europe/Chisinau", + currency : "MDL" ] static ACCOUNT_CREATED_EVENT = [ accountId : "e2709aa2-7907-4f78-98b6-0f36a0c1b5ca", emailAddress: "jonsnow@gmail.com", firstName : "Jon", - lastName : "Snow" + lastName : "Snow", + currency : "MDL", + zoneId : "Europe/Chisinau" ] static newSampleCreateAccountCommand(map = [:]) { MAPPER.readValue(JsonOutput.toJson(CREATE_ACCOUNT_RESOURCE + map) as String, CreateAccountCommand.class) } - static newSampleAccount(map = [:]) { - MAPPER.readValue(JsonOutput.toJson(ACCOUNT + map) as String, Account.class) - } - static newSampleAccountEvent(map = [:]) { MAPPER.readValue(JsonOutput.toJson(ACCOUNT_CREATED_EVENT + map) as String, AccountCreated.class) } diff --git a/server/src/test/groovy/io/myfinbox/account/adapter/web/AccountControllerSpec.groovy b/server/src/test/groovy/io/myfinbox/account/adapter/web/AccountControllerSpec.groovy index e96b193..252b9f6 100644 --- a/server/src/test/groovy/io/myfinbox/account/adapter/web/AccountControllerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/account/adapter/web/AccountControllerSpec.groovy @@ -19,6 +19,7 @@ import org.springframework.test.jdbc.JdbcTestUtils import spock.lang.Specification import spock.lang.Tag +import static io.myfinbox.account.DataSamples.CREATE_ACCOUNT_RESOURCE import static io.myfinbox.account.DataSamples.newSampleAccountEvent import static org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT @@ -45,13 +46,9 @@ class AccountControllerSpec extends Specification { JdbcTestUtils.deleteFromTables(jdbcTemplate, 'accounts') } - def "should create an new account"() { + def "should create an new account with default locale"() { given: 'user wants to create an account' - var request = JsonOutput.toJson([ - firstName : 'Jon', - lastName : 'Snow', - emailAddress: 'jonsnow@gmail.com' - ]) + var request = JsonOutput.toJson(CREATE_ACCOUNT_RESOURCE) when: 'account is created' var response = postNewAccount(request) @@ -63,7 +60,7 @@ class AccountControllerSpec extends Specification { assert response.getHeaders().getLocation() != null and: 'body contains created resource' - assert response.getBody() == accountResource(idFromLocation(response.getHeaders().getLocation())) + JSONAssert.assertEquals(accountResource(idFromLocation(response.getHeaders().getLocation())), response.getBody(), LENIENT) and: 'account created event raised' assert events.ofType(AccountCreated.class).contains( @@ -108,11 +105,6 @@ class AccountControllerSpec extends Specification { } def accountResource(UUID id) { - JsonOutput.toJson([ - accountId : id.toString(), - firstName : 'Jon', - lastName : 'Snow', - emailAddress: 'jonsnow@gmail.com' - ]) + JsonOutput.toJson(CREATE_ACCOUNT_RESOURCE + [accountId: id.toString()]) } } diff --git a/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy b/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy index b13b3e2..947b914 100644 --- a/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/account/application/CreateAccountServiceSpec.groovy @@ -1,14 +1,15 @@ package io.myfinbox.account.application - -import io.myfinbox.account.domain.Account -import io.myfinbox.account.domain.Accounts +import io.myfinbox.account.domain.* import io.myfinbox.shared.Failure import spock.lang.Specification import spock.lang.Tag -import static io.myfinbox.account.DataSamples.newSampleAccount +import java.time.Instant +import java.time.ZoneId + import static io.myfinbox.account.DataSamples.newSampleCreateAccountCommand +import static io.myfinbox.account.application.CreateAccountService.* import static org.apache.commons.lang3.RandomStringUtils.random @Tag("unit") @@ -25,9 +26,9 @@ class CreateAccountServiceSpec extends Specification { service = new CreateAccountService(accounts) } - def "should fail account creation when firstName length overflow"() { + def "should fail account creation when firstName length exceeds limit"() { given: 'new command with invalid first name' - def value = randString(256) + def value = randomString(256) def command = newSampleCreateAccountCommand(firstName: value) when: 'account fails to create' @@ -37,7 +38,7 @@ class CreateAccountServiceSpec extends Specification { assert either.isLeft() and: 'validation failure on firstName field' - assert either.getLeft() == Failure.ofValidation(CreateAccountService.ERROR_MESSAGE, [ + assert either.getLeft() == Failure.ofValidation(ERROR_MESSAGE, [ Failure.FieldViolation.builder() .field('firstName') .message("First name length cannot exceed '${Account.MAX_LENGTH}' characters.") @@ -46,9 +47,9 @@ class CreateAccountServiceSpec extends Specification { ]) } - def "should fail account creation when lastName length overflow"() { + def "should fail account creation when lastName length exceeds limit"() { given: 'new command with invalid last name' - def value = randString(256) + def value = randomString(256) def command = newSampleCreateAccountCommand(lastName: value) when: 'account fails to create' @@ -58,7 +59,7 @@ class CreateAccountServiceSpec extends Specification { assert either.isLeft() and: 'validation failure on lastName field' - assert either.getLeft() == Failure.ofValidation(CreateAccountService.ERROR_MESSAGE, [ + assert either.getLeft() == Failure.ofValidation(ERROR_MESSAGE, [ Failure.FieldViolation.builder() .field('lastName') .message("Last name length cannot exceed '${Account.MAX_LENGTH}' characters.") @@ -78,7 +79,7 @@ class CreateAccountServiceSpec extends Specification { assert either.isLeft() and: 'validation failure error on emailAddress field' - assert either.getLeft() == Failure.ofValidation(CreateAccountService.ERROR_MESSAGE, [ + assert either.getLeft() == Failure.ofValidation(ERROR_MESSAGE, [ Failure.FieldViolation.builder() .field("emailAddress") .message(errorMessage) @@ -106,10 +107,64 @@ class CreateAccountServiceSpec extends Specification { "a\"b(c)d,e:f;gi[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 exceed '${Account.MAX_LENGTH}' characters." + "%s@gmail.com".formatted(randomString(256)) | "Email address length cannot exceed '${Account.MAX_LENGTH}' characters." + } + + def "should fail account creation when currency is invalid with message: '#errorMessage'"() { + given: 'new command with invalid currency' + def command = newSampleCreateAccountCommand(currency: currency) + + when: 'account fails to create' + def either = service.create(command) + + then: 'validation failure result is present' + assert either.isLeft() + + and: 'validation failure error on currency field' + assert either.getLeft() == Failure.ofValidation(ERROR_MESSAGE, [ + Failure.FieldViolation.builder() + .field("currency") + .message(errorMessage) + .rejectedValue(currency) + .build() + ]) + + where: + currency | errorMessage + null | "Currency 'null' is invalid." + '' | "Currency '' is invalid." + ' ' | "Currency ' ' is invalid." + 'MD' | "Currency 'MD' is invalid." + } + + def "should fail account creation when zoneId is invalid with message: '#errorMessage'"() { + given: 'new command with invalid zoneId' + def command = newSampleCreateAccountCommand(zoneId: zoneId) + + when: 'account fails to create' + def either = service.create(command) + + then: 'validation failure result is present' + assert either.isLeft() + + and: 'validation failure error on zoneId field' + assert either.getLeft() == Failure.ofValidation(ERROR_MESSAGE, [ + Failure.FieldViolation.builder() + .field("zoneId") + .message(errorMessage) + .rejectedValue(zoneId) + .build() + ]) + + where: + zoneId | errorMessage + null | "ZoneId 'null' is invalid." + '' | "ZoneId '' is invalid." + ' ' | "ZoneId ' ' is invalid." + 'Europe/Chissinau' | "ZoneId 'Europe/Chissinau' is invalid." } - def "should create an account"() { + def "should create an account successfully"() { setup: "accounts persist behavior" 1 * accounts.save(_ as Account) >> _ @@ -120,13 +175,16 @@ class CreateAccountServiceSpec extends Specification { assert either.isRight() and: 'ensure account is build as expected' - assert either.get() == newSampleAccount( - id: [id: either.get().getId().id()], - creationDate: either.get().getCreationDate().toString() - ) + assert either.get().getId().id() != null + assert either.get().getId().id() instanceof UUID + assert either.get().getCreationDate() != null + assert either.get().getCreationDate() instanceof Instant + assert either.get().getAccountDetails() == new AccountDetails("Jon", "Snow") + assert either.get().getEmailAddress() == new EmailAddress("jonsnow@gmail.com") + assert either.get().getPreference() == new Preference(Currency.getInstance("MDL"), ZoneId.of("Europe/Chisinau")) } - static randString(int len) { + static randomString(int len) { random(len, true, true) } } diff --git a/server/src/test/groovy/io/myfinbox/expense/adapter/messaging/AccountEventsListenerSpec.groovy b/server/src/test/groovy/io/myfinbox/expense/adapter/messaging/AccountEventsListenerSpec.groovy index 21ee8c2..d0584af 100644 --- a/server/src/test/groovy/io/myfinbox/expense/adapter/messaging/AccountEventsListenerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/expense/adapter/messaging/AccountEventsListenerSpec.groovy @@ -19,6 +19,7 @@ import spock.lang.Specification import spock.lang.Tag import java.time.Duration +import java.time.ZoneId import static io.myfinbox.expense.DataSamples.newSampleDefaultCategories import static java.util.UUID.randomUUID @@ -68,7 +69,14 @@ class AccountEventsListenerSpec extends Specification { @Override @Transactional void run() { - eventPublisher.publishEvent(new AccountCreated(accountId, "email@email.com", "Jon", "Snow")) + eventPublisher.publishEvent(AccountCreated.builder() + .accountId(accountId) + .emailAddress("email@email.com") + .firstName("Jon") + .lastName("Snow") + .currency(Currency.getInstance("MDL")) + .zoneId(ZoneId.of("Europe/Chisinau")) + .build()) } } } diff --git a/server/src/test/groovy/io/myfinbox/income/adapter/messaging/AccountEventsListenerSpec.groovy b/server/src/test/groovy/io/myfinbox/income/adapter/messaging/AccountEventsListenerSpec.groovy index b813fe5..25b0d28 100644 --- a/server/src/test/groovy/io/myfinbox/income/adapter/messaging/AccountEventsListenerSpec.groovy +++ b/server/src/test/groovy/io/myfinbox/income/adapter/messaging/AccountEventsListenerSpec.groovy @@ -19,6 +19,7 @@ import spock.lang.Specification import spock.lang.Tag import java.time.Duration +import java.time.ZoneId import static io.myfinbox.income.DataSamples.newSampleDefaultSources import static java.util.UUID.randomUUID @@ -68,7 +69,14 @@ class AccountEventsListenerSpec extends Specification { @Override @Transactional void run() { - eventPublisher.publishEvent(new AccountCreated(accountId, "email@email.com", "Jon", "Snow")) + eventPublisher.publishEvent(AccountCreated.builder() + .accountId(accountId) + .emailAddress("email@email.com") + .firstName("Jon") + .lastName("Snow") + .currency(Currency.getInstance("MDL")) + .zoneId(ZoneId.of("Europe/Chisinau")) + .build()) } } } diff --git a/server/src/test/resources/account/web/account-creation-failure-response.json b/server/src/test/resources/account/web/account-creation-failure-response.json index 7fbb3df..945a7d3 100644 --- a/server/src/test/resources/account/web/account-creation-failure-response.json +++ b/server/src/test/resources/account/web/account-creation-failure-response.json @@ -6,6 +6,10 @@ { "field": "emailAddress", "message": "Email address cannot be empty." + }, + { + "field": "zoneId", + "message": "ZoneId 'null' is invalid." } ] }