diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java index 1b0a233..1038e7a 100644 --- a/src/main/java/com/devexperts/account/AccountKey.java +++ b/src/main/java/com/devexperts/account/AccountKey.java @@ -6,8 +6,8 @@ *

* NOTE: we suspect that later {@link #accountId} is not going to be uniquely identifying an account, * as we might add human-readable account representation and some clearing codes for partners. - * */ -public class AccountKey { + */ +public class AccountKey implements Comparable { private final long accountId; private AccountKey(long accountId) { @@ -17,4 +17,25 @@ private AccountKey(long accountId) { public static AccountKey valueOf(long accountId) { return new AccountKey(accountId); } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof AccountKey)) + return false; + AccountKey accountKey = (AccountKey) obj; + return accountId == accountKey.accountId; + } + + @Override + public int hashCode() { + return Long.hashCode(accountId); + } + + @Override + public int compareTo(AccountKey accountKey) { + return Long.compare(accountId, accountKey.accountId); + } } diff --git a/src/main/java/com/devexperts/config/PopulationAccountService.java b/src/main/java/com/devexperts/config/PopulationAccountService.java new file mode 100644 index 0000000..f52c052 --- /dev/null +++ b/src/main/java/com/devexperts/config/PopulationAccountService.java @@ -0,0 +1,20 @@ +package com.devexperts.config; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.AccountService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class PopulationAccountService { + @Bean + CommandLineRunner initClientDatabase(AccountService accountService) { + return args -> { + accountService.createAccount(new Account(AccountKey.valueOf(1L), "Ivan", "Ivanov", 500.0)); + accountService.createAccount(new Account(AccountKey.valueOf(2L), "Petr", "Petrov", 350.0)); + accountService.createAccount(new Account(AccountKey.valueOf(3L), "Igor", "Sidorov", 750.0)); + }; + } +} diff --git a/src/main/java/com/devexperts/rest/AbstractAccountController.java b/src/main/java/com/devexperts/rest/AbstractAccountController.java index dea5a3c..7b1c4a4 100644 --- a/src/main/java/com/devexperts/rest/AbstractAccountController.java +++ b/src/main/java/com/devexperts/rest/AbstractAccountController.java @@ -1,7 +1,8 @@ package com.devexperts.rest; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; public abstract class AbstractAccountController { - abstract ResponseEntity transfer(long sourceId, long targetId, double amount); + abstract ResponseEntity transfer(@PathVariable Long sourceId, @PathVariable Long targetId, @PathVariable Double amount); } diff --git a/src/main/java/com/devexperts/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java index b300282..52f24c6 100644 --- a/src/main/java/com/devexperts/rest/AccountController.java +++ b/src/main/java/com/devexperts/rest/AccountController.java @@ -1,14 +1,38 @@ package com.devexperts.rest; +import com.devexperts.account.Account; +import com.devexperts.service.AccountService; +import com.devexperts.service.exception.NotFoundAccountException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api") public class AccountController extends AbstractAccountController { + private final AccountService accountService; - public ResponseEntity transfer(long sourceId, long targetId, double amount) { - return null; + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("/operations/transfer") + public ResponseEntity transfer(@RequestParam Long sourceId, @RequestParam Long targetId, @RequestParam Double amount) { + if (sourceId == null || targetId == null || amount == null || amount <= 0) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + try { + Account source = accountService.getAccount(sourceId); + Account target = accountService.getAccount(targetId); + accountService.transfer(source, target, amount); + return new ResponseEntity<>(HttpStatus.OK); + } catch (NotFoundAccountException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } } } diff --git a/src/main/java/com/devexperts/service/AccountServiceImpl.java b/src/main/java/com/devexperts/service/AccountServiceImpl.java index 91261ba..04e4dc6 100644 --- a/src/main/java/com/devexperts/service/AccountServiceImpl.java +++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java @@ -2,6 +2,8 @@ import com.devexperts.account.Account; import com.devexperts.account.AccountKey; +import com.devexperts.service.exception.InvalidAmountException; +import com.devexperts.service.exception.NotFoundAccountException; import org.springframework.stereotype.Service; import java.util.ArrayList; @@ -25,13 +27,39 @@ public void createAccount(Account account) { @Override public Account getAccount(long id) { return accounts.stream() - .filter(account -> account.getAccountKey() == AccountKey.valueOf(id)) + .filter(account -> account.getAccountKey().equals(AccountKey.valueOf(id))) .findAny() - .orElse(null); + .orElseThrow(() -> new NotFoundAccountException(id)); } @Override public void transfer(Account source, Account target, double amount) { - //do nothing for now + if (source.getAccountKey().compareTo(target.getAccountKey()) >= 0) { + synchronized (source) { + synchronized (target) { + doTransfer(source, target, amount); + } + } + } else { + synchronized (target) { + synchronized (source) { + doTransfer(source, target, amount); + } + } + } + } + + private void doTransfer(Account source, Account target, double amount) { + if (!accounts.contains(source)) { + throw new NotFoundAccountException(source); + } + if (!accounts.contains(target)) { + throw new NotFoundAccountException(target); + } + if (source.getBalance() < amount) { + throw new InvalidAmountException(); + } + source.setBalance(source.getBalance() - amount); + target.setBalance(target.getBalance() + amount); } } diff --git a/src/main/java/com/devexperts/service/exception/InvalidAmountException.java b/src/main/java/com/devexperts/service/exception/InvalidAmountException.java new file mode 100644 index 0000000..6bdd45f --- /dev/null +++ b/src/main/java/com/devexperts/service/exception/InvalidAmountException.java @@ -0,0 +1,4 @@ +package com.devexperts.service.exception; + +public class InvalidAmountException extends RuntimeException { +} diff --git a/src/main/java/com/devexperts/service/exception/NotFoundAccountException.java b/src/main/java/com/devexperts/service/exception/NotFoundAccountException.java new file mode 100644 index 0000000..2f72c98 --- /dev/null +++ b/src/main/java/com/devexperts/service/exception/NotFoundAccountException.java @@ -0,0 +1,13 @@ +package com.devexperts.service.exception; + +import com.devexperts.account.Account; + +public class NotFoundAccountException extends RuntimeException { + public NotFoundAccountException(Account account) { + super("Not found account " + account + " at AccountService"); + } + + public NotFoundAccountException(Long id) { + super("Not found account with id = " + id + " at AccountService"); + } +} diff --git a/src/main/resources/sql/data/accounts.sql b/src/main/resources/sql/data/accounts.sql new file mode 100644 index 0000000..1f07a0a --- /dev/null +++ b/src/main/resources/sql/data/accounts.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS accounts; + +CREATE TABLE IF NOT EXISTS accounts +( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + FIRST_NAME VARCHAR(255) NOT NULL, + LAST_NAME VARCHAR(255) NOT NULL, + BALANCE BIGINT NOT NULL +); \ No newline at end of file diff --git a/src/main/resources/sql/data/select.sql b/src/main/resources/sql/data/select.sql new file mode 100644 index 0000000..d1b5579 --- /dev/null +++ b/src/main/resources/sql/data/select.sql @@ -0,0 +1 @@ +SELECT SOURCE_ID FROM transfers WHERE TRANSFER_TIME > '2019-01-01'::timestamp GROUP BY SOURCE_ID HAVING sum(AMOUNT) > 1000; \ No newline at end of file diff --git a/src/main/resources/sql/data/transfers.sql b/src/main/resources/sql/data/transfers.sql new file mode 100644 index 0000000..d3728be --- /dev/null +++ b/src/main/resources/sql/data/transfers.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS transfers; + +CREATE TABLE IF NOT EXISTS transfers +( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + SOURCE_ID BIGINT NOT NULL, + TARGET_ID BIGINT NOT NULL, + AMOUNT BIGINT NOT NULL, + TRANSFER_TIME TIMESTAMP(6) NOT NULL, + FOREIGN KEY (SOURCE_ID) REFERENCES accounts (ID) ON UPDATE CASCADE ON DELETE SET NULL, + FOREIGN KEY (TARGET_ID) REFERENCES accounts (ID) ON UPDATE CASCADE ON DELETE SET NULL +); \ No newline at end of file diff --git a/src/test/java/com/devexperts/service/AccountServiceImplTest.java b/src/test/java/com/devexperts/service/AccountServiceImplTest.java new file mode 100644 index 0000000..3e92839 --- /dev/null +++ b/src/test/java/com/devexperts/service/AccountServiceImplTest.java @@ -0,0 +1,94 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.exception.InvalidAmountException; +import com.devexperts.service.exception.NotFoundAccountException; +import org.junit.Assert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +class AccountServiceImplTest { + private static final double DELTA = 0.00001; + + private AccountServiceImpl accountService = new AccountServiceImpl(); + private volatile Account account1; + private volatile Account account2; + private Account account3; + + @BeforeEach + void init() { + account1 = new Account(AccountKey.valueOf(1L), "Ivan", "Ivanov", 500.0); + account2 = new Account(AccountKey.valueOf(2L), "Petr", "Petrov", 350.0); + account3 = new Account(AccountKey.valueOf(3L), "Igor", "Sidorov", 750.0); + accountService.createAccount(account1); + accountService.createAccount(account2); + accountService.createAccount(account3); + } + + @org.junit.jupiter.api.Test + void getAccountFromAccounts() { + Account account = accountService.getAccount(1L); + Assert.assertNotNull(account); + Assert.assertEquals("Ivan", account.getFirstName()); + Assert.assertEquals("Ivanov", account.getLastName()); + Assert.assertEquals(500.0, account.getBalance(), DELTA); + } + + @org.junit.jupiter.api.Test + void getAccountNotFromAccounts() { + NotFoundAccountException exception = Assertions.assertThrows(NotFoundAccountException.class, () -> { + accountService.getAccount(5L); + }); + Assert.assertEquals(exception.getMessage(), "Not found account with id = 5 at AccountService"); + } + + @org.junit.jupiter.api.Test + void validTransfer() { + accountService.transfer(account1, account2, 200); + Assert.assertEquals(300, account1.getBalance(), DELTA); + Assert.assertEquals(550, account2.getBalance(), DELTA); + } + + @org.junit.jupiter.api.Test + void invalidAmountTransfer() { + Assertions.assertThrows(InvalidAmountException.class, () -> { + accountService.transfer(account2, account3, 500); + }); + } + + @org.junit.jupiter.api.Test + void transferFromAccountNotFromAccounts() { + Assertions.assertThrows(NotFoundAccountException.class, () -> { + accountService.transfer(new Account(AccountKey.valueOf(8L), "NoName", "NoSurname", 1500.0), account3, 500); + }); + } + + @org.junit.jupiter.api.Test + void transferMultiThread() throws InterruptedException { + int countTransfer = 5; + CountDownLatch countDownLatch = new CountDownLatch(countTransfer); + Runnable task = () -> { + try { + accountService.transfer(account1, account2, 100); + } finally { + countDownLatch.countDown(); + } + }; + for (int i = 0; i < countTransfer; i++) { + new Thread(task).start(); + } + countDownLatch.await(5, TimeUnit.SECONDS); + Assert.assertEquals(0, account1.getBalance(), DELTA); + Assert.assertEquals(850, account2.getBalance(), DELTA); + } + + @AfterEach + void tearDown() { + accountService.clear(); + } +} \ No newline at end of file