diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountJpaEntity.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountJpaEntity.java new file mode 100644 index 0000000..bd91ee1 --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountJpaEntity.java @@ -0,0 +1,23 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "account") +@Data +@AllArgsConstructor +@NoArgsConstructor +class AccountJpaEntity { + + @Id + @GeneratedValue + private Long id; + +} diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountMapper.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountMapper.java new file mode 100644 index 0000000..7454c7d --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountMapper.java @@ -0,0 +1,50 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import java.util.ArrayList; +import java.util.List; + +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Activity; +import io.reflectoring.cashpal.domain.Activity.ActivityId; +import io.reflectoring.cashpal.domain.ActivityWindow; +import io.reflectoring.cashpal.domain.Money; +import org.springframework.stereotype.Component; + +@Component +class AccountMapper { + + Account mapToDomainEntity( + AccountJpaEntity account, + List activities, + Long withdrawalBalance, + Long depositBalance) { + + Money baselineBalance = Money.subtract( + Money.of(depositBalance), + Money.of(withdrawalBalance)); + + return Account.withId( + new AccountId(account.getId()), + baselineBalance, + mapToActivityWindow(activities)); + + } + + ActivityWindow mapToActivityWindow(List activities) { + List mappedActivities = new ArrayList<>(); + + for (ActivityJpaEntity activity : activities) { + mappedActivities.add(new Activity( + new ActivityId(activity.getId()), + new AccountId(activity.getOwnerAccountId()), + new AccountId(activity.getSourceAccountId()), + new AccountId(activity.getTargetAccountId()), + activity.getTimestamp(), + Money.of(activity.getAmount()))); + } + + return new ActivityWindow(mappedActivities); + } + +} diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapter.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapter.java new file mode 100644 index 0000000..cc8e7a2 --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapter.java @@ -0,0 +1,79 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import javax.persistence.EntityNotFoundException; + +import java.time.LocalDateTime; +import java.util.List; + +import io.reflectoring.cashpal.application.port.out.LoadAccountPort; +import io.reflectoring.cashpal.application.port.out.UpdateAccountStatePort; +import io.reflectoring.cashpal.testdata.PersistenceAdapter; +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Activity; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@PersistenceAdapter +class AccountPersistenceAdapter implements + LoadAccountPort, + UpdateAccountStatePort { + + private final AccountRepository accountRepository; + private final ActivityRepository activityRepository; + private final AccountMapper accountMapper; + + @Override + public Account loadAccount(AccountId accountId, LocalDateTime baselineDate) { + + AccountJpaEntity account = + accountRepository.findById(accountId.getValue()) + .orElseThrow(EntityNotFoundException::new); + + List activities = + activityRepository.findByOwnerSince( + accountId.getValue(), + baselineDate); + + Long withdrawalBalance = orZero(activityRepository + .getWithdrawalBalanceUntil( + accountId.getValue(), + baselineDate)); + + Long depositBalance = orZero(activityRepository + .getDepositBalanceUntil( + accountId.getValue(), + baselineDate)); + + return accountMapper.mapToDomainEntity( + account, + activities, + withdrawalBalance, + depositBalance); + + } + + private Long orZero(Long value){ + return value == null ? 0L : value; + } + + + @Override + public void updateActivities(Account account) { + for (Activity activity : account.getActivityWindow().getActivities()) { + if (activity.getId() == null) { + activityRepository.save(mapToJpa(activity)); + } + } + } + + private ActivityJpaEntity mapToJpa(Activity activity) { + return new ActivityJpaEntity( + activity.getId() == null ? null : activity.getId().getValue(), + activity.getTimestamp(), + activity.getOwnerAccountId().getValue(), + activity.getSourceAccountId().getValue(), + activity.getTargetAccountId().getValue(), + activity.getMoney().getAmount().longValue()); + } +} diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountRepository.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountRepository.java new file mode 100644 index 0000000..824815e --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/AccountRepository.java @@ -0,0 +1,6 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface AccountRepository extends JpaRepository { +} diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityJpaEntity.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityJpaEntity.java new file mode 100644 index 0000000..7201295 --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityJpaEntity.java @@ -0,0 +1,41 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "activity") +@Data +@AllArgsConstructor +@NoArgsConstructor +class ActivityJpaEntity { + + @Id + @GeneratedValue + private Long id; + + @Column + private LocalDateTime timestamp; + + @Column + private Long ownerAccountId; + + @Column + private Long sourceAccountId; + + @Column + private Long targetAccountId; + + @Column + private Long amount; + +} diff --git a/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityRepository.java b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityRepository.java new file mode 100644 index 0000000..136d640 --- /dev/null +++ b/adapters/cashpal-persistence/src/main/java/io/reflectoring/cashpal/adapter/persistence/ActivityRepository.java @@ -0,0 +1,35 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface ActivityRepository extends JpaRepository { + + @Query("select a from ActivityJpaEntity a " + + "where a.ownerAccountId = :ownerAccountId " + + "and a.timestamp >= :since") + List findByOwnerSince( + @Param("ownerAccountId") Long ownerAccountId, + @Param("since") LocalDateTime since); + + @Query("select sum(a.amount) from ActivityJpaEntity a " + + "where a.targetAccountId = :accountId " + + "and a.ownerAccountId = :accountId " + + "and a.timestamp < :until") + Long getDepositBalanceUntil( + @Param("accountId") Long accountId, + @Param("until") LocalDateTime until); + + @Query("select sum(a.amount) from ActivityJpaEntity a " + + "where a.sourceAccountId = :accountId " + + "and a.ownerAccountId = :accountId " + + "and a.timestamp < :until") + Long getWithdrawalBalanceUntil( + @Param("accountId") Long accountId, + @Param("until") LocalDateTime until); + +} diff --git a/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.java b/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.java new file mode 100644 index 0000000..0eaf7f6 --- /dev/null +++ b/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.java @@ -0,0 +1,55 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.ActivityWindow; +import io.reflectoring.cashpal.domain.Money; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import static io.reflectoring.cashpal.testdata.AccountTestData.*; +import static io.reflectoring.cashpal.testdata.ActivityTestData.*; +import static org.assertj.core.api.Assertions.*; + +@DataJpaTest +@Import({AccountPersistenceAdapter.class, AccountMapper.class}) +class AccountPersistenceAdapterTest { + + @Autowired + private AccountPersistenceAdapter adapterUnderTest; + + @Autowired + private ActivityRepository activityRepository; + + @Test + @Sql("AccountPersistenceAdapterTest.sql") + void loadsAccount() { + Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0)); + + assertThat(account.getActivityWindow().getActivities()).hasSize(2); + assertThat(account.calculateBalance()).isEqualTo(Money.of(500)); + } + + @Test + void updatesActivities() { + Account account = defaultAccount() + .withBaselineBalance(Money.of(555L)) + .withActivityWindow(new ActivityWindow( + defaultActivity() + .withId(null) + .withMoney(Money.of(1L)).build())) + .build(); + + adapterUnderTest.updateActivities(account); + + assertThat(activityRepository.count()).isEqualTo(1); + + ActivityJpaEntity savedActivity = activityRepository.findAll().get(0); + assertThat(savedActivity.getAmount()).isEqualTo(1L); + } + +} \ No newline at end of file diff --git a/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/TestApplication.java b/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/TestApplication.java new file mode 100644 index 0000000..624bdbb --- /dev/null +++ b/adapters/cashpal-persistence/src/test/java/io/reflectoring/cashpal/adapter/persistence/TestApplication.java @@ -0,0 +1,12 @@ +package io.reflectoring.cashpal.adapter.persistence; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/adapters/cashpal-persistence/src/test/resources/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.sql b/adapters/cashpal-persistence/src/test/resources/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.sql new file mode 100644 index 0000000..3698724 --- /dev/null +++ b/adapters/cashpal-persistence/src/test/resources/io/reflectoring/cashpal/adapter/persistence/AccountPersistenceAdapterTest.sql @@ -0,0 +1,26 @@ +insert into account (id) values (1); +insert into account (id) values (2); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1, '2018-08-08 08:00:00.0', 1, 1, 2, 500); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (2, '2018-08-08 08:00:00.0', 2, 1, 2, 500); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (3, '2018-08-09 10:00:00.0', 1, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (4, '2018-08-09 10:00:00.0', 2, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (5, '2019-08-09 09:00:00.0', 1, 1, 2, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (6, '2019-08-09 09:00:00.0', 2, 1, 2, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (7, '2019-08-09 10:00:00.0', 1, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (8, '2019-08-09 10:00:00.0', 2, 2, 1, 1000); \ No newline at end of file diff --git a/adapters/web/build.gradle b/adapters/cashpal-web/build.gradle similarity index 84% rename from adapters/web/build.gradle rename to adapters/cashpal-web/build.gradle index bf547a7..de06bc0 100644 --- a/adapters/web/build.gradle +++ b/adapters/cashpal-web/build.gradle @@ -1,7 +1,8 @@ dependencies { implementation project(':common') - implementation project(':application') - api 'org.springframework.boot:spring-boot-starter-web' + implementation project(':cashpal-application') + + implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/adapters/cashpal-web/build/tmp/jar/MANIFEST.MF b/adapters/cashpal-web/build/tmp/jar/MANIFEST.MF new file mode 100644 index 0000000..59499bc --- /dev/null +++ b/adapters/cashpal-web/build/tmp/jar/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/adapters/cashpal-web/src/main/java/io/reflectoring/cashpal/adapter/web/SendMoneyController.java b/adapters/cashpal-web/src/main/java/io/reflectoring/cashpal/adapter/web/SendMoneyController.java new file mode 100644 index 0000000..adc2bb9 --- /dev/null +++ b/adapters/cashpal-web/src/main/java/io/reflectoring/cashpal/adapter/web/SendMoneyController.java @@ -0,0 +1,41 @@ +package io.reflectoring.cashpal.adapter.web; + +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase; +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase.SendMoneyCommand; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Money; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class SendMoneyController { + + private final SendMoneyUseCase sendMoneyUseCase; + + @PostMapping(path = "/sendMoney") + void sendMoney(@RequestBody SendMoneyForm form) { + + SendMoneyCommand command = new SendMoneyCommand( + new AccountId(form.sourceAccountId), + new AccountId(form.targetAccountId), + Money.of(form.amount)); + + sendMoneyUseCase.sendMoney(command); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class SendMoneyForm { + private Long sourceAccountId; + private Long targetAccountId; + private Long amount; + } + +} diff --git a/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/SendMoneyControllerTest.java b/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/SendMoneyControllerTest.java new file mode 100644 index 0000000..5575923 --- /dev/null +++ b/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/SendMoneyControllerTest.java @@ -0,0 +1,44 @@ +package io.reflectoring.cashpal.adapter.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.reflectoring.cashpal.adapter.web.SendMoneyController.SendMoneyForm; +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase; +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase.SendMoneyCommand; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = SendMoneyController.class) +class SendMoneyControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SendMoneyUseCase sendMoneyUseCase; + + private ObjectMapper jsonMapper = new ObjectMapper(); + + @Test + void testSendMoney() throws Exception { + + SendMoneyForm form = new SendMoneyForm( + 41L, + 42L, + 500L + ); + + mockMvc.perform(post("/sendMoney") + .header("Content-Type", "application/json") + .content(jsonMapper.writeValueAsString(form))) + .andExpect(status().isOk()); + + then(sendMoneyUseCase).should().sendMoney(any(SendMoneyCommand.class)); + } + +} \ No newline at end of file diff --git a/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/TestApplication.java b/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/TestApplication.java new file mode 100644 index 0000000..5f37986 --- /dev/null +++ b/adapters/cashpal-web/src/test/java/io/reflectoring/cashpal/adapter/web/TestApplication.java @@ -0,0 +1,7 @@ +package io.reflectoring.cashpal.adapter.web; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApplication { +} diff --git a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapter.java b/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapter.java deleted file mode 100644 index 716cb61..0000000 --- a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapter.java +++ /dev/null @@ -1,21 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.application.port.out.FindAuthorByIdPort; -import io.reflectoring.reviewapp.domain.Author; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -class AuthorPersistenceAdapter implements FindAuthorByIdPort { - - private final AuthorRepository authorRepository; - - @Override - public Author findAuthorById(Long authorId) { - return authorRepository.findById(authorId) - .orElseThrow(() -> new AuthorNotFoundException(authorId)); - } - - -} diff --git a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorRepository.java b/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorRepository.java deleted file mode 100644 index 151bfe1..0000000 --- a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/AuthorRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.domain.Author; -import org.springframework.data.repository.CrudRepository; - -interface AuthorRepository extends CrudRepository { -} diff --git a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapter.java b/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapter.java deleted file mode 100644 index 20dc5b8..0000000 --- a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapter.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.application.port.out.FindBookByTitlePort; -import io.reflectoring.reviewapp.application.port.out.PersistBookPort; -import io.reflectoring.reviewapp.common.PersistenceAdapter; -import io.reflectoring.reviewapp.domain.Book; -import lombok.RequiredArgsConstructor; - -import java.util.Optional; - -@PersistenceAdapter -@RequiredArgsConstructor -class BookPersistenceAdapter implements FindBookByTitlePort, PersistBookPort { - - private final BookRepository bookRepository; - - @Override - public Optional findBookByTitle(String title) { - return bookRepository.findByTitle(title); - } - - @Override - public Book saveBook(Book book) { - return bookRepository.save(book); - } -} diff --git a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookRepository.java b/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookRepository.java deleted file mode 100644 index 3890b3c..0000000 --- a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/BookRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.domain.Book; -import org.springframework.data.jdbc.repository.query.Query; -import org.springframework.data.repository.CrudRepository; -import org.springframework.data.repository.query.Param; - -import java.util.Optional; - -interface BookRepository extends CrudRepository { - - @Query("select b.* from Book b where b.title = :title") - Optional findByTitle(@Param("title") String title); - -} diff --git a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterConfiguration.java b/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterConfiguration.java deleted file mode 100644 index fc2f3e2..0000000 --- a/adapters/persistence/src/main/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterConfiguration.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import javax.sql.DataSource; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; -import org.springframework.data.jdbc.repository.config.JdbcConfiguration; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -@Configuration -@EnableJdbcRepositories -@ComponentScan -class PersistenceAdapterConfiguration extends JdbcConfiguration { - - @Bean - NamedParameterJdbcOperations namedParameterJdbcOperations(DataSource dataSource) { - return new NamedParameterJdbcTemplate(dataSource); - } - -} diff --git a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapterTest.java b/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapterTest.java deleted file mode 100644 index 7d10142..0000000 --- a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/AuthorPersistenceAdapterTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.domain.Author; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.Sql.ExecutionPhase; -import org.springframework.test.context.jdbc.SqlGroup; - -import static org.assertj.core.api.Assertions.*; - -@DataJdbcTest -class AuthorPersistenceAdapterTest { - - @Autowired - private AuthorPersistenceAdapter authorPersistenceAdapter; - - @Test - @SqlGroup({ - @Sql(scripts = "single-author.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), - @Sql(scripts = "single-author-reset.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)}) - void findByAuthorId() { - Author author = authorPersistenceAdapter.findAuthorById(42L); - assertThat(author).isNotNull(); - } - -} \ No newline at end of file diff --git a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapterTest.java b/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapterTest.java deleted file mode 100644 index c1985d1..0000000 --- a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/BookPersistenceAdapterTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import io.reflectoring.reviewapp.domain.Book; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.jdbc.Sql; -import org.springframework.test.context.jdbc.Sql.ExecutionPhase; -import org.springframework.test.context.jdbc.SqlGroup; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; - -@DataJdbcTest -class BookPersistenceAdapterTest { - - @Autowired - private BookPersistenceAdapter bookPersistenceAdapter; - - @Autowired - private BookRepository bookRepository; - - @Test - @SqlGroup({ - @Sql(scripts = "single-book.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), - @Sql(scripts = "single-book-reset.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)}) - void findBookByTitle() { - Optional optionalBook = bookPersistenceAdapter.findBookByTitle("Get Your Hands Dirty on Clean Architecture"); - assertThat(optionalBook).isPresent(); - } - - @Test - @SqlGroup({ - @Sql(scripts = "single-book.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), - @Sql(scripts = "single-book-reset.sql", executionPhase = ExecutionPhase.AFTER_TEST_METHOD)}) - void savesBook() { - Book book = new Book(null, "A Hitchhiker's Guide to the Galaxy", 42L); - Book savedBook = bookPersistenceAdapter.saveBook(book); - assertThat(bookRepository.findById(savedBook.getId())).isPresent(); - } - -} \ No newline at end of file diff --git a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterTestConfiguration.java b/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterTestConfiguration.java deleted file mode 100644 index c7a5be6..0000000 --- a/adapters/persistence/src/test/java/io/reflectoring/reviewapp/adapter/persistence/PersistenceAdapterTestConfiguration.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.reflectoring.reviewapp.adapter.persistence; - -import org.springframework.boot.SpringBootConfiguration; -import org.springframework.context.annotation.Import; - -@SpringBootConfiguration -@Import(PersistenceAdapterConfiguration.class) -class PersistenceAdapterTestConfiguration { -} diff --git a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author-reset.sql b/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author-reset.sql deleted file mode 100644 index c029384..0000000 --- a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author-reset.sql +++ /dev/null @@ -1,3 +0,0 @@ -DELETE -FROM AUTHOR -WHERE ID = 42; \ No newline at end of file diff --git a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author.sql b/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author.sql deleted file mode 100644 index 750d269..0000000 --- a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-author.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT INTO AUTHOR (ID, NAME) -VALUES (42, 'Tom'); \ No newline at end of file diff --git a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book-reset.sql b/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book-reset.sql deleted file mode 100644 index 364763c..0000000 --- a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book-reset.sql +++ /dev/null @@ -1,2 +0,0 @@ -DELETE FROM AUTHOR WHERE ID = 42; -DELETE FROM BOOK WHERE ID = 1; \ No newline at end of file diff --git a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book.sql b/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book.sql deleted file mode 100644 index 96a84bf..0000000 --- a/adapters/persistence/src/test/resources/io/reflectoring/reviewapp/adapter/persistence/single-book.sql +++ /dev/null @@ -1,5 +0,0 @@ -INSERT INTO AUTHOR (ID, NAME) -VALUES (42, 'Tom'); - -INSERT INTO BOOK (ID, TITLE, AUTHOR_ID) -VALUES (1, 'Get Your Hands Dirty on Clean Architecture', 42); \ No newline at end of file diff --git a/adapters/web/src/main/java/io/reflectoring/reviewapp/adapter/web/RegisterBookController.java b/adapters/web/src/main/java/io/reflectoring/reviewapp/adapter/web/RegisterBookController.java deleted file mode 100644 index 8237b90..0000000 --- a/adapters/web/src/main/java/io/reflectoring/reviewapp/adapter/web/RegisterBookController.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.reflectoring.reviewapp.adapter.web; - -import io.reflectoring.reviewapp.application.port.in.RegisterBookUseCase; -import io.reflectoring.reviewapp.application.port.in.RegisterBookUseCase.RegisterBookCommand; -import io.reflectoring.reviewapp.domain.Book; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -class RegisterBookController { - - private final RegisterBookUseCase registerBookUseCase; - - @PostMapping(path = "/books/register") - void registerBook(@RequestBody Book book) { - RegisterBookCommand command = new RegisterBookCommand(book.getTitle(), book.getAuthorId()); - registerBookUseCase.registerBook(command); - } - -} diff --git a/application/build.gradle b/application/build.gradle deleted file mode 100644 index 665d367..0000000 --- a/application/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -dependencies { - implementation project(':common') - - implementation 'org.springframework:spring-context' - implementation 'org.springframework:spring-core' - implementation 'javax.validation:validation-api' - - // Needed for Spring Data annotations on domain entities. - // This is a shortcut to avoid a mapping step between domain and persistence! - implementation 'org.springframework.data:spring-data-commons' - - implementation 'org.springframework.boot:spring-boot-starter-validation' - - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - testImplementation('org.springframework.boot:spring-boot-starter-test') { - exclude group: 'junit' // excluding junit 4 - } - testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.0.1' - testImplementation 'org.mockito:mockito-junit-jupiter:2.23.0' - testImplementation 'com.tngtech.archunit:archunit:0.9.3' - testImplementation 'de.adesso:junit-insights:1.1.0' - testImplementation 'org.junit.platform:junit-platform-launcher:1.4.2' -} - -test { - useJUnitPlatform() - systemProperty 'de.adesso.junitinsights.enabled', 'true' -} - diff --git a/application/src/main/java/io/reflectoring/reviewapp/application/port/in/RegisterBookUseCase.java b/application/src/main/java/io/reflectoring/reviewapp/application/port/in/RegisterBookUseCase.java deleted file mode 100644 index ce800a0..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/application/port/in/RegisterBookUseCase.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.reflectoring.reviewapp.application.port.in; - -import io.reflectoring.reviewapp.common.SelfValidating; -import lombok.Getter; - -import javax.validation.constraints.NotEmpty; -import javax.validation.constraints.NotNull; - -public interface RegisterBookUseCase { - - /** - * Allows an author to register a new book to be reviewed. - */ - void registerBook(RegisterBookCommand command) throws NonUniqueBookTitleException; - - - @Getter - final class RegisterBookCommand extends SelfValidating { - - @NotEmpty - private final String bookTitle; - - @NotNull - private final Long authorId; - - public RegisterBookCommand(String bookTitle, Long authorId) { - this.bookTitle = bookTitle; - this.authorId = authorId; - validateSelf(); - } - } - - final class NonUniqueBookTitleException extends RuntimeException { - - public NonUniqueBookTitleException(String bookTitle) { - super(String.format("A book title must be unique (book title in question: '%s').", bookTitle)); - } - - } - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindAuthorByIdPort.java b/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindAuthorByIdPort.java deleted file mode 100644 index 6b8eef5..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindAuthorByIdPort.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.reflectoring.reviewapp.application.port.out; - -import io.reflectoring.reviewapp.domain.Author; - -public interface FindAuthorByIdPort { - - Author findAuthorById(Long authorId) throws AuthorNotFoundException; - - class AuthorNotFoundException extends RuntimeException { - public AuthorNotFoundException(Long authorId) { - super(String.format("Author with ID '%d' does not exist!", authorId)); - } - } - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindBookByTitlePort.java b/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindBookByTitlePort.java deleted file mode 100644 index 86c8585..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/FindBookByTitlePort.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.reflectoring.reviewapp.application.port.out; - -import io.reflectoring.reviewapp.domain.Book; - -import java.util.Optional; - -public interface FindBookByTitlePort { - - Optional findBookByTitle(String title); - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/PersistBookPort.java b/application/src/main/java/io/reflectoring/reviewapp/application/port/out/PersistBookPort.java deleted file mode 100644 index a11c1e5..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/application/port/out/PersistBookPort.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.reflectoring.reviewapp.application.port.out; - -import io.reflectoring.reviewapp.domain.Book; - -public interface PersistBookPort { - - Book saveBook(Book book); - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/application/service/RegisterBookService.java b/application/src/main/java/io/reflectoring/reviewapp/application/service/RegisterBookService.java deleted file mode 100644 index e1010d3..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/application/service/RegisterBookService.java +++ /dev/null @@ -1,44 +0,0 @@ -package io.reflectoring.reviewapp.application.service; - -import io.reflectoring.reviewapp.application.port.in.RegisterBookUseCase; -import io.reflectoring.reviewapp.application.port.out.FindAuthorByIdPort; -import io.reflectoring.reviewapp.application.port.out.FindBookByTitlePort; -import io.reflectoring.reviewapp.application.port.out.PersistBookPort; -import io.reflectoring.reviewapp.domain.Author; -import io.reflectoring.reviewapp.domain.Book; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RegisterBookService implements RegisterBookUseCase { - - private final FindAuthorByIdPort findAuthorByIdPort; - private final FindBookByTitlePort findBookByTitlePort; - private final PersistBookPort saveBookPort; - - @Override - public void registerBook(RegisterBookCommand command) { - - Author author = findAuthorByIdPort.findAuthorById(command.getAuthorId()); - - requireUniqueTitle(command.getBookTitle()); - requireAuthorHasPremiumAccount(author); - - // more business validations ... - - Book book = new Book(null, command.getBookTitle(), author.getId()); - saveBookPort.saveBook(book); - } - - private void requireAuthorHasPremiumAccount(Author author) { - // some business validation ... - } - - private void requireUniqueTitle(String bookTitle) { - if (findBookByTitlePort.findBookByTitle(bookTitle).isPresent()) { - throw new NonUniqueBookTitleException(bookTitle); - } - } - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/domain/Author.java b/application/src/main/java/io/reflectoring/reviewapp/domain/Author.java deleted file mode 100644 index 34300d3..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/domain/Author.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.reflectoring.reviewapp.domain; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.annotation.Id; - -@Getter -@AllArgsConstructor -public class Author { - - @Id - private Long id; - private String name; - - // imagine some insanely complex business logic methods ... - -} diff --git a/application/src/main/java/io/reflectoring/reviewapp/domain/Book.java b/application/src/main/java/io/reflectoring/reviewapp/domain/Book.java deleted file mode 100644 index c4a00ce..0000000 --- a/application/src/main/java/io/reflectoring/reviewapp/domain/Book.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.reflectoring.reviewapp.domain; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.data.annotation.Id; - -@Getter -@AllArgsConstructor -public class Book { - - @Id - private Long id; - private String title; - private Long authorId; - - // imagine some insanely complex business logic methods ... - -} diff --git a/application/src/main/resources/application.properties b/application/src/main/resources/application.properties deleted file mode 100644 index 813de42..0000000 --- a/application/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG \ No newline at end of file diff --git a/application/src/main/resources/schema.sql b/application/src/main/resources/schema.sql deleted file mode 100644 index ac059cb..0000000 --- a/application/src/main/resources/schema.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE BOOK -( - id BIGINT auto_increment NOT NULL, - title VARCHAR(50) NOT NULL, - author_id BIGINT NOT NULL -); - -CREATE TABLE AUTHOR -( - id BIGINT auto_increment NOT NULL, - name VARCHAR(50) NOT NULL -); \ No newline at end of file diff --git a/application/src/test/java/io/reflectoring/reviewapp/application/port/in/RegisterBookCommandTests.java b/application/src/test/java/io/reflectoring/reviewapp/application/port/in/RegisterBookCommandTests.java deleted file mode 100644 index beb36b4..0000000 --- a/application/src/test/java/io/reflectoring/reviewapp/application/port/in/RegisterBookCommandTests.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.reflectoring.reviewapp.application.port.in; - -import io.reflectoring.reviewapp.application.port.in.RegisterBookUseCase.RegisterBookCommand; -import org.junit.jupiter.api.Test; - -import javax.validation.ConstraintViolationException; - -import static org.assertj.core.api.AssertionsForClassTypes.*; - -class RegisterBookCommandTests { - - @Test - void acceptsValidParameters() { - new RegisterBookCommand("Get Your Hands Dirty on Clean Architecture", 42L); - // no exception - } - - @Test - void rejectsEmptyTitle() { - assertThatThrownBy(() -> new RegisterBookCommand("", 42L)) - .isInstanceOf(ConstraintViolationException.class); - assertThatThrownBy(() -> new RegisterBookCommand(null, 42L)) - .isInstanceOf(ConstraintViolationException.class); - } - - @Test - void rejectsEmptyAuthor() { - assertThatThrownBy(() -> new RegisterBookCommand("Get Your Hands Dirty on Clean Architecture", null)) - .isInstanceOf(ConstraintViolationException.class); - } - -} \ No newline at end of file diff --git a/adapters/persistence/build.gradle b/cashpal-application/build.gradle similarity index 74% rename from adapters/persistence/build.gradle rename to cashpal-application/build.gradle index e2c0b41..eeb86c0 100644 --- a/adapters/persistence/build.gradle +++ b/cashpal-application/build.gradle @@ -1,12 +1,14 @@ dependencies { implementation project(':common') - implementation project(':application') - api 'org.springframework.boot:spring-boot-starter-data-jdbc' - + compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'javax.validation:validation-api' + implementation 'javax.transaction:javax.transaction-api' + testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'junit' // excluding junit 4 } @@ -15,9 +17,11 @@ dependencies { testImplementation 'com.tngtech.archunit:archunit:0.9.3' testImplementation 'de.adesso:junit-insights:1.1.0' testImplementation 'org.junit.platform:junit-platform-launcher:1.4.2' + testImplementation project(':cashpal-testdata') } test { useJUnitPlatform() systemProperty 'de.adesso.junitinsights.enabled', 'true' } + diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/GetAccountBalanceQuery.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/GetAccountBalanceQuery.java new file mode 100644 index 0000000..be882f1 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/GetAccountBalanceQuery.java @@ -0,0 +1,10 @@ +package io.reflectoring.cashpal.application.port.in; + +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Money; + +public interface GetAccountBalanceQuery { + + Money getAccountBalance(AccountId accountId); + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/SendMoneyUseCase.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/SendMoneyUseCase.java new file mode 100644 index 0000000..e9be53a --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/in/SendMoneyUseCase.java @@ -0,0 +1,36 @@ +package io.reflectoring.cashpal.application.port.in; + +import javax.validation.constraints.NotNull; + +import io.reflectoring.cashpal.testdata.SelfValidating; +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Money; +import lombok.EqualsAndHashCode; +import lombok.Value; + +public interface SendMoneyUseCase { + + boolean sendMoney(SendMoneyCommand command); + + @Value + @EqualsAndHashCode(callSuper = false) + class SendMoneyCommand extends SelfValidating { + + @NotNull + private Account.AccountId sourceAccountId; + + @NotNull + private Account.AccountId targetAccountId; + + @NotNull + private Money money; + + public SendMoneyCommand(Account.AccountId sourceAccountId, Account.AccountId targetAccountId, Money money) { + this.sourceAccountId = sourceAccountId; + this.targetAccountId = targetAccountId; + this.money = money; + this.validateSelf(); + } + } + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/AccountLock.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/AccountLock.java new file mode 100644 index 0000000..bf12102 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/AccountLock.java @@ -0,0 +1,11 @@ +package io.reflectoring.cashpal.application.port.out; + +import io.reflectoring.cashpal.domain.Account; + +public interface AccountLock { + + void lockAccount(Account.AccountId accountId); + + void releaseAccount(Account.AccountId accountId); + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/LoadAccountPort.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/LoadAccountPort.java new file mode 100644 index 0000000..5a80c12 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/LoadAccountPort.java @@ -0,0 +1,11 @@ +package io.reflectoring.cashpal.application.port.out; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; + +public interface LoadAccountPort { + + Account loadAccount(AccountId accountId, LocalDateTime baselineDate); +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/UpdateAccountStatePort.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/UpdateAccountStatePort.java new file mode 100644 index 0000000..3d6a2cd --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/port/out/UpdateAccountStatePort.java @@ -0,0 +1,9 @@ +package io.reflectoring.cashpal.application.port.out; + +import io.reflectoring.cashpal.domain.Account; + +public interface UpdateAccountStatePort { + + void updateActivities(Account account); + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/GetAccountBalanceService.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/GetAccountBalanceService.java new file mode 100644 index 0000000..573d63c --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/GetAccountBalanceService.java @@ -0,0 +1,21 @@ +package io.reflectoring.cashpal.application.service; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.application.port.in.GetAccountBalanceQuery; +import io.reflectoring.cashpal.application.port.out.LoadAccountPort; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Money; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +class GetAccountBalanceService implements GetAccountBalanceQuery { + + private final LoadAccountPort loadAccountPort; + + @Override + public Money getAccountBalance(AccountId accountId) { + return loadAccountPort.loadAccount(accountId, LocalDateTime.now()) + .calculateBalance(); + } +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/NoOpAccountLock.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/NoOpAccountLock.java new file mode 100644 index 0000000..1f9d2c0 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/NoOpAccountLock.java @@ -0,0 +1,20 @@ +package io.reflectoring.cashpal.application.service; + +import io.reflectoring.cashpal.application.port.out.AccountLock; +import io.reflectoring.cashpal.domain.Account.AccountId; +import org.springframework.stereotype.Component; + +@Component +class NoOpAccountLock implements AccountLock { + + @Override + public void lockAccount(AccountId accountId) { + // do nothing + } + + @Override + public void releaseAccount(AccountId accountId) { + // do nothing + } + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/SendMoneyService.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/SendMoneyService.java new file mode 100644 index 0000000..e300ff1 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/application/service/SendMoneyService.java @@ -0,0 +1,57 @@ +package io.reflectoring.cashpal.application.service; + +import javax.transaction.Transactional; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase; +import io.reflectoring.cashpal.application.port.out.AccountLock; +import io.reflectoring.cashpal.application.port.out.LoadAccountPort; +import io.reflectoring.cashpal.application.port.out.UpdateAccountStatePort; +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.testdata.UseCase; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@UseCase +@Transactional +public class SendMoneyService implements SendMoneyUseCase { + + private final LoadAccountPort loadAccountPort; + private final AccountLock accountLock; + private final UpdateAccountStatePort updateAccountStatePort; + + @Override + public boolean sendMoney(SendMoneyCommand command) { + LocalDateTime baselineDate = LocalDateTime.now().minusDays(10); + + Account sourceAccount = loadAccountPort.loadAccount( + command.getSourceAccountId(), + baselineDate); + + Account targetAccount = loadAccountPort.loadAccount( + command.getTargetAccountId(), + baselineDate); + + accountLock.lockAccount(sourceAccount.getId()); + if (!sourceAccount.withdraw(command.getMoney(), targetAccount.getId())) { + accountLock.releaseAccount(sourceAccount.getId()); + return false; + } + + accountLock.lockAccount(targetAccount.getId()); + if (!targetAccount.deposit(command.getMoney(), sourceAccount.getId())) { + accountLock.releaseAccount(sourceAccount.getId()); + accountLock.releaseAccount(targetAccount.getId()); + return false; + } + + updateAccountStatePort.updateActivities(sourceAccount); + updateAccountStatePort.updateActivities(targetAccount); + + accountLock.releaseAccount(sourceAccount.getId()); + accountLock.releaseAccount(targetAccount.getId()); + return true; + } + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Account.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Account.java new file mode 100644 index 0000000..84b2c53 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Account.java @@ -0,0 +1,91 @@ +package io.reflectoring.cashpal.domain; + +import java.time.LocalDateTime; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; + +/** + * An account that holds a certain amount of money. An {@link Account} object only + * contains a window of the latest account activities. The total balance of the account is + * the sum of a baseline balance that was valid before the first activity in the + * window and the sum of the activity values. + */ +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Account { + + /** + * The unique ID of the account. + */ + @Getter + private AccountId id; + + /** + * The baseline balance of the account. This was the balance of the account before the first + * activity in the activityWindow. + */ + private Money baselineBalance; + + /** + * The window of latest activities on this account. + */ + @Getter + private ActivityWindow activityWindow; + + /** + * Creates an {@link Account} entity without an ID. Use to create a new entity that is not yet + * persisted. + */ + public static Account withoutId(Money baselineBalance, ActivityWindow activityWindow) { + return new Account(null, baselineBalance, activityWindow); + } + + /** + * Creates an {@link Account} entity with an ID. Use to reconstitute a persisted entity. + */ + public static Account withId(AccountId accountId, Money baselineBalance, ActivityWindow activityWindow) { + return new Account(accountId, baselineBalance, activityWindow); + } + + /** + * Calculates the total balance of the account by adding the activity values to the baseline balance. + */ + public Money calculateBalance() { + return Money.add(this.activityWindow.calculateBalance(this.id), this.baselineBalance); + } + + /** + * Tries to withdraw a certain amount of money from this account. + * If successful, creates a new activity with a negative value. + * @return true if the withdrawal was successful, false if not. + */ + public boolean withdraw(Money money, AccountId targetAccountId) { + + if(Money.add(this.calculateBalance(), money.negate()).isNegative()){ + return false; + } + + Activity withdrawal = new Activity(null, this.id, this.id, targetAccountId, LocalDateTime.now(), money); + this.activityWindow.addActivity(withdrawal); + return true; + } + + /** + * Tries to deposit a certain amount of money to this account. + * If sucessful, creates a new activity with a positive value. + * @return true if the deposit was successful, false if not. + */ + public boolean deposit(Money money, AccountId sourceAccountId) { + Activity deposit = new Activity(null, this.id, sourceAccountId, this.id, LocalDateTime.now(), money); + this.activityWindow.addActivity(deposit); + return true; + } + + @Value + public static class AccountId { + private Long value; + } + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Activity.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Activity.java new file mode 100644 index 0000000..42cdb1c --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Activity.java @@ -0,0 +1,58 @@ +package io.reflectoring.cashpal.domain; + +import java.time.LocalDateTime; + +import lombok.Getter; +import lombok.NonNull; +import lombok.Value; + +/** + * A money transfer activity between {@link Account}s. + */ +@Value +public class Activity { + + @Getter + private final ActivityId id; + + /** + * The account that owns this activity. + */ + @Getter + @NonNull + private final Account.AccountId ownerAccountId; + + /** + * The debited account. + */ + @Getter + @NonNull + private final Account.AccountId sourceAccountId; + + /** + * The credited account. + */ + @Getter + @NonNull + private final Account.AccountId targetAccountId; + + /** + * The timestamp of the activity. + */ + @Getter + @NonNull + private final LocalDateTime timestamp; + + /** + * The money that was transferred between the accounts. + */ + @Getter + @NonNull + private final Money money; + + @Value + public static class ActivityId { + private final Long value; + } + +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/ActivityWindow.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/ActivityWindow.java new file mode 100644 index 0000000..c55e3c9 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/ActivityWindow.java @@ -0,0 +1,75 @@ +package io.reflectoring.cashpal.domain; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import io.reflectoring.cashpal.domain.Account.AccountId; +import lombok.NonNull; + +/** + * A window of account activities. + */ +public class ActivityWindow { + + /** + * The list of account activities within this window. + */ + private List activities; + + /** + * The timestamp of the first activity within this window. + */ + public LocalDateTime getStartTimestamp() { + return activities.stream() + .min(Comparator.comparing(Activity::getTimestamp)) + .orElseThrow(IllegalStateException::new) + .getTimestamp(); + } + + /** + * The timestamp of the last activity within this window. + * @return + */ + public LocalDateTime getEndTimestamp() { + return activities.stream() + .max(Comparator.comparing(Activity::getTimestamp)) + .orElseThrow(IllegalStateException::new) + .getTimestamp(); + } + + /** + * Calculates the balance by summing up the values of all activities within this window. + */ + public Money calculateBalance(AccountId accountId) { + Money depositBalance = activities.stream() + .filter(a -> a.getTargetAccountId().equals(accountId)) + .map(Activity::getMoney) + .reduce(Money.ZERO, Money::add); + + Money withdrawalBalance = activities.stream() + .filter(a -> a.getSourceAccountId().equals(accountId)) + .map(Activity::getMoney) + .reduce(Money.ZERO, Money::add); + + return Money.add(depositBalance, withdrawalBalance.negate()); + } + + public ActivityWindow(@NonNull List activities) { + this.activities = activities; + } + + public ActivityWindow(@NonNull Activity... activities) { + this.activities = new ArrayList<>(Arrays.asList(activities)); + } + + public List getActivities() { + return new ArrayList<>(this.activities); + } + + public void addActivity(Activity activity) { + this.activities.add(activity); + } +} diff --git a/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Money.java b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Money.java new file mode 100644 index 0000000..683fe27 --- /dev/null +++ b/cashpal-application/src/main/java/io/reflectoring/cashpal/domain/Money.java @@ -0,0 +1,48 @@ +package io.reflectoring.cashpal.domain; + +import java.math.BigInteger; + +import lombok.NonNull; +import lombok.Value; + +@Value +public class Money { + + public static Money ZERO = Money.of(0L); + + @NonNull + private final BigInteger amount; + + public boolean isPositiveOrZero(){ + return this.amount.compareTo(BigInteger.ZERO) >= 0; + } + + public boolean isNegative(){ + return this.amount.compareTo(BigInteger.ZERO) < 0; + } + + public boolean isPositive(){ + return this.amount.compareTo(BigInteger.ZERO) > 0; + } + + public boolean isGreaterThanOrEqualTo(Money money){ + return this.amount.compareTo(money.amount) >= 0; + } + + public static Money of(long value) { + return new Money(BigInteger.valueOf(value)); + } + + public static Money add(Money a, Money b) { + return new Money(a.amount.add(b.amount)); + } + + public static Money subtract(Money a, Money b) { + return new Money(a.amount.subtract(b.amount)); + } + + public Money negate(){ + return new Money(this.amount.negate()); + } + +} diff --git a/cashpal-application/src/test/java/io/reflectoring/cashpal/application/service/SendMoneyServiceTest.java b/cashpal-application/src/test/java/io/reflectoring/cashpal/application/service/SendMoneyServiceTest.java new file mode 100644 index 0000000..a70be93 --- /dev/null +++ b/cashpal-application/src/test/java/io/reflectoring/cashpal/application/service/SendMoneyServiceTest.java @@ -0,0 +1,133 @@ +package io.reflectoring.cashpal.application.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import io.reflectoring.cashpal.application.port.in.SendMoneyUseCase.SendMoneyCommand; +import io.reflectoring.cashpal.application.port.out.AccountLock; +import io.reflectoring.cashpal.application.port.out.LoadAccountPort; +import io.reflectoring.cashpal.application.port.out.UpdateAccountStatePort; +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Money; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +class SendMoneyServiceTest { + + private final LoadAccountPort loadAccountPort = + Mockito.mock(LoadAccountPort.class); + + private final AccountLock accountLock = + Mockito.mock(AccountLock.class); + + private final UpdateAccountStatePort updateAccountStatePort = + Mockito.mock(UpdateAccountStatePort.class); + + private final SendMoneyService sendMoneyService = + new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort); + + @Test + void givenWithdrawalFails_thenOnlySourceAccountIsLockedAndReleased() { + + AccountId sourceAccountId = new AccountId(41L); + Account sourceAccount = givenAnAccountWithId(sourceAccountId); + + AccountId targetAccountId = new AccountId(42L); + Account targetAccount = givenAnAccountWithId(targetAccountId); + + givenWithdrawalWillFail(sourceAccount); + givenDepositWillSucceed(targetAccount); + + SendMoneyCommand command = new SendMoneyCommand( + sourceAccountId, + targetAccountId, + Money.of(300L)); + + boolean success = sendMoneyService.sendMoney(command); + + assertThat(success).isFalse(); + + then(accountLock).should().lockAccount(eq(sourceAccountId)); + then(accountLock).should().releaseAccount(eq(sourceAccountId)); + then(accountLock).should(times(0)).lockAccount(eq(targetAccountId)); + } + + @Test + void transactionSucceeds() { + + AccountId sourceAccountId = new AccountId(41L); + Account sourceAccount = givenAnAccountWithId(sourceAccountId); + + AccountId targetAccountId = new AccountId(42L); + Account targetAccount = givenAnAccountWithId(targetAccountId); + + givenWithdrawalWillSucceed(sourceAccount); + givenDepositWillSucceed(targetAccount); + + Money money = Money.of(500L); + + SendMoneyCommand command = new SendMoneyCommand( + sourceAccountId, + targetAccountId, + money); + + boolean success = sendMoneyService.sendMoney(command); + + assertThat(success).isTrue(); + + then(accountLock).should().lockAccount(eq(sourceAccountId)); + then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId)); + then(accountLock).should().releaseAccount(eq(sourceAccountId)); + + then(accountLock).should().lockAccount(eq(targetAccountId)); + then(targetAccount).should().deposit(eq(money), eq(sourceAccountId)); + then(accountLock).should().releaseAccount(eq(targetAccountId)); + + thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId); + } + + private void thenAccountsHaveBeenUpdated(AccountId... accountIds){ + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(Account.class); + then(updateAccountStatePort).should(times(accountIds.length)) + .updateActivities(accountCaptor.capture()); + + List updatedAccountIds = accountCaptor.getAllValues() + .stream() + .map(Account::getId) + .collect(Collectors.toList()); + + for(AccountId accountId : accountIds){ + assertThat(updatedAccountIds).contains(accountId); + } + } + + private void givenDepositWillSucceed(Account account) { + given(account.deposit(any(Money.class), any(AccountId.class))) + .willReturn(true); + } + + private void givenWithdrawalWillFail(Account account) { + given(account.withdraw(any(Money.class), any(AccountId.class))) + .willReturn(false); + } + + private void givenWithdrawalWillSucceed(Account account) { + given(account.withdraw(any(Money.class), any(AccountId.class))) + .willReturn(true); + } + + private Account givenAnAccountWithId(AccountId id) { + Account account = Mockito.mock(Account.class); + given(account.getId()) + .willReturn(id); + given(loadAccountPort.loadAccount(eq(account.getId()), any(LocalDateTime.class))) + .willReturn(account); + return account; + } + +} \ No newline at end of file diff --git a/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/AccountTest.java b/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/AccountTest.java new file mode 100644 index 0000000..1a27ab7 --- /dev/null +++ b/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/AccountTest.java @@ -0,0 +1,97 @@ +package io.reflectoring.cashpal.domain; + +import io.reflectoring.cashpal.domain.Account.AccountId; +import org.junit.jupiter.api.Test; +import static io.reflectoring.cashpal.testdata.AccountTestData.*; +import static io.reflectoring.cashpal.testdata.ActivityTestData.*; +import static org.assertj.core.api.Assertions.*; + +class AccountTest { + + @Test + void calculatesBalance() { + AccountId accountId = new AccountId(1L); + Account account = defaultAccount() + .withAccountId(accountId) + .withBaselineBalance(Money.of(555L)) + .withActivityWindow(new ActivityWindow( + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(999L)).build(), + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(1L)).build())) + .build(); + + Money balance = account.calculateBalance(); + + assertThat(balance).isEqualTo(Money.of(1555L)); + } + + @Test + void withdrawalSucceeds() { + AccountId accountId = new AccountId(1L); + Account account = defaultAccount() + .withAccountId(accountId) + .withBaselineBalance(Money.of(555L)) + .withActivityWindow(new ActivityWindow( + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(999L)).build(), + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(1L)).build())) + .build(); + + boolean success = account.withdraw(Money.of(555L), new AccountId(99L)); + + assertThat(success).isTrue(); + assertThat(account.getActivityWindow().getActivities()).hasSize(3); + assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L)); + } + + @Test + void withdrawalFailure() { + AccountId accountId = new AccountId(1L); + Account account = defaultAccount() + .withAccountId(accountId) + .withBaselineBalance(Money.of(555L)) + .withActivityWindow(new ActivityWindow( + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(999L)).build(), + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(1L)).build())) + .build(); + + boolean success = account.withdraw(Money.of(1556L), new AccountId(99L)); + + assertThat(success).isFalse(); + assertThat(account.getActivityWindow().getActivities()).hasSize(2); + assertThat(account.calculateBalance()).isEqualTo(Money.of(1555L)); + } + + @Test + void depositSuccess() { + AccountId accountId = new AccountId(1L); + Account account = defaultAccount() + .withAccountId(accountId) + .withBaselineBalance(Money.of(555L)) + .withActivityWindow(new ActivityWindow( + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(999L)).build(), + defaultActivity() + .withTargetAccount(accountId) + .withMoney(Money.of(1L)).build())) + .build(); + + boolean success = account.deposit(Money.of(445L), new AccountId(99L)); + + assertThat(success).isTrue(); + assertThat(account.getActivityWindow().getActivities()).hasSize(3); + assertThat(account.calculateBalance()).isEqualTo(Money.of(2000L)); + } + +} \ No newline at end of file diff --git a/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/ActivityWindowTest.java b/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/ActivityWindowTest.java new file mode 100644 index 0000000..8e0049a --- /dev/null +++ b/cashpal-application/src/test/java/io/reflectoring/cashpal/domain/ActivityWindowTest.java @@ -0,0 +1,68 @@ +package io.reflectoring.cashpal.domain; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.domain.Account.AccountId; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import static io.reflectoring.cashpal.testdata.ActivityTestData.*; + +class ActivityWindowTest { + + @Test + void calculatesStartTimestamp() { + ActivityWindow window = new ActivityWindow( + defaultActivity().withTimestamp(startDate()).build(), + defaultActivity().withTimestamp(inBetweenDate()).build(), + defaultActivity().withTimestamp(endDate()).build()); + + Assertions.assertThat(window.getStartTimestamp()).isEqualTo(startDate()); + } + + @Test + void calculatesEndTimestamp() { + ActivityWindow window = new ActivityWindow( + defaultActivity().withTimestamp(startDate()).build(), + defaultActivity().withTimestamp(inBetweenDate()).build(), + defaultActivity().withTimestamp(endDate()).build()); + + Assertions.assertThat(window.getEndTimestamp()).isEqualTo(endDate()); + } + + @Test + void calculatesBalance() { + + AccountId account1 = new AccountId(1L); + AccountId account2 = new AccountId(2L); + + ActivityWindow window = new ActivityWindow( + defaultActivity() + .withSourceAccount(account1) + .withTargetAccount(account2) + .withMoney(Money.of(999)).build(), + defaultActivity() + .withSourceAccount(account1) + .withTargetAccount(account2) + .withMoney(Money.of(1)).build(), + defaultActivity() + .withSourceAccount(account2) + .withTargetAccount(account1) + .withMoney(Money.of(500)).build()); + + Assertions.assertThat(window.calculateBalance(account1)).isEqualTo(Money.of(-500)); + Assertions.assertThat(window.calculateBalance(account2)).isEqualTo(Money.of(500)); + } + + private LocalDateTime startDate() { + return LocalDateTime.of(2019, 8, 3, 0, 0); + } + + private LocalDateTime inBetweenDate() { + return LocalDateTime.of(2019, 8, 4, 0, 0); + } + + private LocalDateTime endDate() { + return LocalDateTime.of(2019, 8, 5, 0, 0); + } + +} \ No newline at end of file diff --git a/configuration/build.gradle b/cashpal-configuration/build.gradle similarity index 64% rename from configuration/build.gradle rename to cashpal-configuration/build.gradle index 1082838..797940a 100644 --- a/configuration/build.gradle +++ b/cashpal-configuration/build.gradle @@ -5,9 +5,10 @@ plugins { dependencies { implementation project(':common') - implementation project(':application') - implementation project(':adapters:persistence') - implementation project(':adapters:web') + implementation project(':cashpal-application') + implementation project(':adapters:cashpal-persistence') + implementation project(':adapters:cashpal-web') + implementation ('org.springframework.boot:spring-boot-starter-web') testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'junit' // excluding junit 4 @@ -19,3 +20,9 @@ dependencies { testImplementation 'org.junit.platform:junit-platform-launcher:1.4.2' } +test { + useJUnitPlatform() + systemProperty 'de.adesso.junitinsights.enabled', 'true' +} + + diff --git a/configuration/src/main/java/io/reflectoring/reviewapp/BookReviewerApplication.java b/cashpal-configuration/src/main/java/io/reflectoring/cashpal/CashpalApplication.java similarity index 58% rename from configuration/src/main/java/io/reflectoring/reviewapp/BookReviewerApplication.java rename to cashpal-configuration/src/main/java/io/reflectoring/cashpal/CashpalApplication.java index 4f3d137..cdcdf3b 100644 --- a/configuration/src/main/java/io/reflectoring/reviewapp/BookReviewerApplication.java +++ b/cashpal-configuration/src/main/java/io/reflectoring/cashpal/CashpalApplication.java @@ -1,13 +1,13 @@ -package io.reflectoring.reviewapp; +package io.reflectoring.cashpal; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class BookReviewerApplication { +public class CashpalApplication { public static void main(String[] args) { - SpringApplication.run(BookReviewerApplication.class, args); + SpringApplication.run(CashpalApplication.class, args); } } diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/BookReviewerApplicationTests.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/CashpalApplicationTests.java similarity index 81% rename from configuration/src/test/java/io/reflectoring/reviewapp/BookReviewerApplicationTests.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/CashpalApplicationTests.java index c6e63e3..d874cb5 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/BookReviewerApplicationTests.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/CashpalApplicationTests.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp; +package io.reflectoring.cashpal; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -7,7 +7,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; @ExtendWith(SpringExtension.class) @SpringBootTest -class BookReviewerApplicationTests { +class CashpalApplicationTests { @Test void contextLoads() { diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/DependencyRuleTests.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/DependencyRuleTests.java similarity index 80% rename from configuration/src/test/java/io/reflectoring/reviewapp/DependencyRuleTests.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/DependencyRuleTests.java index 88c8569..6049876 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/DependencyRuleTests.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/DependencyRuleTests.java @@ -1,7 +1,7 @@ -package io.reflectoring.reviewapp; +package io.reflectoring.cashpal; import com.tngtech.archunit.core.importer.ClassFileImporter; -import io.reflectoring.reviewapp.archunit.HexagonalArchitecture; +import io.reflectoring.cashpal.archunit.HexagonalArchitecture; import org.junit.jupiter.api.Test; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; @@ -9,7 +9,7 @@ class DependencyRuleTests { @Test void validateRegistrationContextArchitecture() { - HexagonalArchitecture.boundedContext("io.reflectoring.reviewapp") + HexagonalArchitecture.boundedContext("io.reflectoring.cashpal") .withDomainLayer("domain") @@ -26,7 +26,7 @@ class DependencyRuleTests { .withConfiguration("configuration") .check(new ClassFileImporter() - .importPackages("io.reflectoring.reviewapp..")); + .importPackages("io.reflectoring.cashpal..")); } @Test diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/Adapters.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/Adapters.java similarity index 97% rename from configuration/src/test/java/io/reflectoring/reviewapp/archunit/Adapters.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/Adapters.java index 73a487c..80a186e 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/Adapters.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/Adapters.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.archunit; +package io.reflectoring.cashpal.archunit; import java.util.ArrayList; import java.util.List; diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/ApplicationLayer.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ApplicationLayer.java similarity index 97% rename from configuration/src/test/java/io/reflectoring/reviewapp/archunit/ApplicationLayer.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ApplicationLayer.java index 133cb93..5655a63 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/ApplicationLayer.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ApplicationLayer.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.archunit; +package io.reflectoring.cashpal.archunit; import java.util.ArrayList; import java.util.List; diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/ArchitectureElement.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ArchitectureElement.java similarity index 97% rename from configuration/src/test/java/io/reflectoring/reviewapp/archunit/ArchitectureElement.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ArchitectureElement.java index 12e074f..fecbedd 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/ArchitectureElement.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/ArchitectureElement.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.archunit; +package io.reflectoring.cashpal.archunit; import java.util.List; diff --git a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/HexagonalArchitecture.java b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/HexagonalArchitecture.java similarity index 98% rename from configuration/src/test/java/io/reflectoring/reviewapp/archunit/HexagonalArchitecture.java rename to cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/HexagonalArchitecture.java index 3a1efe8..53e7c8d 100644 --- a/configuration/src/test/java/io/reflectoring/reviewapp/archunit/HexagonalArchitecture.java +++ b/cashpal-configuration/src/test/java/io/reflectoring/cashpal/archunit/HexagonalArchitecture.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.archunit; +package io.reflectoring.cashpal.archunit; import java.util.ArrayList; import java.util.Collections; diff --git a/cashpal-configuration/src/test/resources/io/reflectoring/cashpal/SendMoneySystemTest.sql b/cashpal-configuration/src/test/resources/io/reflectoring/cashpal/SendMoneySystemTest.sql new file mode 100644 index 0000000..7973596 --- /dev/null +++ b/cashpal-configuration/src/test/resources/io/reflectoring/cashpal/SendMoneySystemTest.sql @@ -0,0 +1,26 @@ +insert into account (id) values (1); +insert into account (id) values (2); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1001, '2018-08-08 08:00:00.0', 1, 1, 2, 500); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1002, '2018-08-08 08:00:00.0', 2, 1, 2, 500); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1003, '2018-08-09 10:00:00.0', 1, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1004, '2018-08-09 10:00:00.0', 2, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1005, '2019-08-09 09:00:00.0', 1, 1, 2, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1006, '2019-08-09 09:00:00.0', 2, 1, 2, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1007, '2019-08-09 10:00:00.0', 1, 2, 1, 1000); + +insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount) +values (1008, '2019-08-09 10:00:00.0', 2, 2, 1, 1000); \ No newline at end of file diff --git a/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/AccountTestData.java b/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/AccountTestData.java new file mode 100644 index 0000000..76eca0d --- /dev/null +++ b/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/AccountTestData.java @@ -0,0 +1,48 @@ +package io.reflectoring.cashpal.testdata; + +import io.reflectoring.cashpal.domain.Account; +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.ActivityWindow; +import io.reflectoring.cashpal.domain.Money; + +public class AccountTestData { + + public static AccountBuilder defaultAccount() { + return new AccountBuilder() + .withAccountId(new AccountId(42L)) + .withBaselineBalance(Money.of(999L)) + .withActivityWindow(new ActivityWindow( + ActivityTestData.defaultActivity().build(), + ActivityTestData.defaultActivity().build())); + } + + + public static class AccountBuilder { + + private AccountId accountId; + private Money baselineBalance; + private ActivityWindow activityWindow; + + public AccountBuilder withAccountId(AccountId accountId) { + this.accountId = accountId; + return this; + } + + public AccountBuilder withBaselineBalance(Money baselineBalance) { + this.baselineBalance = baselineBalance; + return this; + } + + public AccountBuilder withActivityWindow(ActivityWindow activityWindow) { + this.activityWindow = activityWindow; + return this; + } + + public Account build() { + return Account.withId(this.accountId, this.baselineBalance, this.activityWindow); + } + + } + + +} diff --git a/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/ActivityTestData.java b/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/ActivityTestData.java new file mode 100644 index 0000000..1e14d49 --- /dev/null +++ b/cashpal-testdata/src/main/java/io/reflectoring/cashpal/testdata/ActivityTestData.java @@ -0,0 +1,69 @@ +package io.reflectoring.cashpal.testdata; + +import java.time.LocalDateTime; + +import io.reflectoring.cashpal.domain.Account.AccountId; +import io.reflectoring.cashpal.domain.Activity; +import io.reflectoring.cashpal.domain.Activity.ActivityId; +import io.reflectoring.cashpal.domain.Money; + +public class ActivityTestData { + + public static ActivityBuilder defaultActivity(){ + return new ActivityBuilder() + .withOwnerAccount(new AccountId(42L)) + .withSourceAccount(new AccountId(42L)) + .withTargetAccount(new AccountId(41L)) + .withTimestamp(LocalDateTime.now()) + .withMoney(Money.of(999L)); + } + + public static class ActivityBuilder { + private ActivityId id; + private AccountId ownerAccountId; + private AccountId sourceAccountId; + private AccountId targetAccountId; + private LocalDateTime timestamp; + private Money money; + + public ActivityBuilder withId(ActivityId id) { + this.id = id; + return this; + } + + public ActivityBuilder withOwnerAccount(AccountId accountId) { + this.ownerAccountId = accountId; + return this; + } + + public ActivityBuilder withSourceAccount(AccountId accountId) { + this.sourceAccountId = accountId; + return this; + } + + public ActivityBuilder withTargetAccount(AccountId accountId) { + this.targetAccountId = accountId; + return this; + } + + public ActivityBuilder withTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + return this; + } + + public ActivityBuilder withMoney(Money money) { + this.money = money; + return this; + } + + public Activity build() { + return new Activity( + this.id, + this.ownerAccountId, + this.sourceAccountId, + this.targetAccountId, + this.timestamp, + this.money); + } + } +} diff --git a/common/src/main/java/io/reflectoring/reviewapp/common/OutputAdapter.java b/common/src/main/java/io/reflectoring/cashpal/testdata/OutputAdapter.java similarity index 94% rename from common/src/main/java/io/reflectoring/reviewapp/common/OutputAdapter.java rename to common/src/main/java/io/reflectoring/cashpal/testdata/OutputAdapter.java index 4553c59..e9600ea 100644 --- a/common/src/main/java/io/reflectoring/reviewapp/common/OutputAdapter.java +++ b/common/src/main/java/io/reflectoring/cashpal/testdata/OutputAdapter.java @@ -1,7 +1,4 @@ -package io.reflectoring.reviewapp.common; - -import org.springframework.core.annotation.AliasFor; -import org.springframework.stereotype.Component; +package io.reflectoring.cashpal.testdata; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -9,6 +6,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/common/src/main/java/io/reflectoring/reviewapp/common/PersistenceAdapter.java b/common/src/main/java/io/reflectoring/cashpal/testdata/PersistenceAdapter.java similarity index 88% rename from common/src/main/java/io/reflectoring/reviewapp/common/PersistenceAdapter.java rename to common/src/main/java/io/reflectoring/cashpal/testdata/PersistenceAdapter.java index 6fb6701..fc046f7 100644 --- a/common/src/main/java/io/reflectoring/reviewapp/common/PersistenceAdapter.java +++ b/common/src/main/java/io/reflectoring/cashpal/testdata/PersistenceAdapter.java @@ -1,8 +1,4 @@ -package io.reflectoring.reviewapp.common; - -import org.springframework.core.annotation.AliasFor; -import org.springframework.stereotype.Component; -import org.springframework.stereotype.Repository; +package io.reflectoring.cashpal.testdata; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -10,6 +6,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/common/src/main/java/io/reflectoring/reviewapp/common/SelfValidating.java b/common/src/main/java/io/reflectoring/cashpal/testdata/SelfValidating.java similarity index 94% rename from common/src/main/java/io/reflectoring/reviewapp/common/SelfValidating.java rename to common/src/main/java/io/reflectoring/cashpal/testdata/SelfValidating.java index b75c1f0..4cfc62c 100644 --- a/common/src/main/java/io/reflectoring/reviewapp/common/SelfValidating.java +++ b/common/src/main/java/io/reflectoring/cashpal/testdata/SelfValidating.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.common; +package io.reflectoring.cashpal.testdata; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; diff --git a/common/src/main/java/io/reflectoring/reviewapp/common/UseCase.java b/common/src/main/java/io/reflectoring/cashpal/testdata/UseCase.java similarity index 94% rename from common/src/main/java/io/reflectoring/reviewapp/common/UseCase.java rename to common/src/main/java/io/reflectoring/cashpal/testdata/UseCase.java index d9abec1..14dc188 100644 --- a/common/src/main/java/io/reflectoring/reviewapp/common/UseCase.java +++ b/common/src/main/java/io/reflectoring/cashpal/testdata/UseCase.java @@ -1,7 +1,4 @@ -package io.reflectoring.reviewapp.common; - -import org.springframework.core.annotation.AliasFor; -import org.springframework.stereotype.Component; +package io.reflectoring.cashpal.testdata; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -9,6 +6,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/common/src/main/java/io/reflectoring/reviewapp/common/WebAdapter.java b/common/src/main/java/io/reflectoring/cashpal/testdata/WebAdapter.java similarity index 94% rename from common/src/main/java/io/reflectoring/reviewapp/common/WebAdapter.java rename to common/src/main/java/io/reflectoring/cashpal/testdata/WebAdapter.java index 879f2e9..97efee1 100644 --- a/common/src/main/java/io/reflectoring/reviewapp/common/WebAdapter.java +++ b/common/src/main/java/io/reflectoring/cashpal/testdata/WebAdapter.java @@ -1,4 +1,4 @@ -package io.reflectoring.reviewapp.common; +package io.reflectoring.cashpal.testdata; import org.springframework.core.annotation.AliasFor; import org.springframework.stereotype.Component; diff --git a/settings.gradle b/settings.gradle index 8e5b971..d682820 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,7 @@ include 'common' -include 'application' -include 'adapters:persistence' -include 'adapters:web' -include 'configuration' +include 'cashpal-configuration' + +include 'adapters:cashpal-web' +include 'adapters:cashpal-persistence' +include 'cashpal-application' +include 'cashpal-testdata' \ No newline at end of file