diff --git a/src/main/java/com/devexperts/account/Account.java b/src/main/java/com/devexperts/account/Account.java index fb2a3af..61a5f64 100644 --- a/src/main/java/com/devexperts/account/Account.java +++ b/src/main/java/com/devexperts/account/Account.java @@ -1,5 +1,7 @@ package com.devexperts.account; +import java.util.Objects; + public class Account { private final AccountKey accountKey; private final String firstName; @@ -32,4 +34,20 @@ public Double getBalance() { public void setBalance(Double balance) { this.balance = balance; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Account account = (Account) o; + return Objects.equals(accountKey, account.accountKey) && + Objects.equals(firstName, account.firstName) && + Objects.equals(lastName, account.lastName) && + Objects.equals(balance, account.balance); + } + + @Override + public int hashCode() { + return Objects.hash(accountKey, firstName, lastName, balance); + } } diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java index 1b0a233..9d2f437 100644 --- a/src/main/java/com/devexperts/account/AccountKey.java +++ b/src/main/java/com/devexperts/account/AccountKey.java @@ -1,12 +1,14 @@ package com.devexperts.account; +import java.util.Objects; + /** * Unique Account identifier * *

* 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 { private final long accountId; @@ -17,4 +19,17 @@ private AccountKey(long accountId) { public static AccountKey valueOf(long accountId) { return new AccountKey(accountId); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AccountKey that = (AccountKey) o; + return accountId == that.accountId; + } + + @Override + public int hashCode() { + return Objects.hash(accountId); + } } diff --git a/src/main/java/com/devexperts/exception/AccountNotFoundException.java b/src/main/java/com/devexperts/exception/AccountNotFoundException.java new file mode 100644 index 0000000..f9967ad --- /dev/null +++ b/src/main/java/com/devexperts/exception/AccountNotFoundException.java @@ -0,0 +1,7 @@ +package com.devexperts.exception; + +public class AccountNotFoundException extends RuntimeException { + public AccountNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devexperts/exception/InsufficientAccountBalanceException.java b/src/main/java/com/devexperts/exception/InsufficientAccountBalanceException.java new file mode 100644 index 0000000..4dfdff5 --- /dev/null +++ b/src/main/java/com/devexperts/exception/InsufficientAccountBalanceException.java @@ -0,0 +1,7 @@ +package com.devexperts.exception; + +public class InsufficientAccountBalanceException extends RuntimeException { + public InsufficientAccountBalanceException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devexperts/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java index b300282..9500a55 100644 --- a/src/main/java/com/devexperts/rest/AccountController.java +++ b/src/main/java/com/devexperts/rest/AccountController.java @@ -1,14 +1,39 @@ package com.devexperts.rest; +import com.devexperts.exception.AccountNotFoundException; +import com.devexperts.exception.InsufficientAccountBalanceException; +import com.devexperts.service.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +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; + @Autowired + public AccountController(AccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("operations/transfer") + public ResponseEntity transfer(@RequestParam long sourceId, + @RequestParam long targetId, + @RequestParam double amount) { + try { + accountService.transfer(accountService.getAccount(sourceId), accountService.getAccount(targetId), amount); + return new ResponseEntity<>(HttpStatus.OK); + } catch (IllegalArgumentException e) { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } catch (AccountNotFoundException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (InsufficientAccountBalanceException 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..b59b901 100644 --- a/src/main/java/com/devexperts/service/AccountServiceImpl.java +++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java @@ -2,15 +2,20 @@ import com.devexperts.account.Account; import com.devexperts.account.AccountKey; +import com.devexperts.exception.AccountNotFoundException; +import com.devexperts.exception.InsufficientAccountBalanceException; +import org + .springframework.lang.NonNull; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Service public class AccountServiceImpl implements AccountService { - private final List accounts = new ArrayList<>(); + + private final Map accounts = new ConcurrentHashMap<>(); @Override public void clear() { @@ -18,20 +23,54 @@ public void clear() { } @Override - public void createAccount(Account account) { - accounts.add(account); + public void createAccount(@NonNull Account account) { + if (account.getAccountKey() != null) { + if (accounts.putIfAbsent(account.getAccountKey(), account) != null) { + throw new IllegalArgumentException("Account already exists"); + } + } else { + throw new IllegalArgumentException("AccountKey cannot be null"); + } } @Override public Account getAccount(long id) { - return accounts.stream() - .filter(account -> account.getAccountKey() == AccountKey.valueOf(id)) - .findAny() - .orElse(null); + return accounts.get(AccountKey.valueOf(id)); } + @Override public void transfer(Account source, Account target, double amount) { - //do nothing for now + if (amount <= 0) { + throw new IllegalArgumentException("Amount must be greater than zero"); + } + + if (source == null || target == null) { + throw new AccountNotFoundException("One of the accounts was not found"); + } + + AccountKey sourceAccountKey = source.getAccountKey(); + AccountKey targetAccountKey = target.getAccountKey(); + + + AccountKey lock1 = sourceAccountKey.hashCode() < targetAccountKey.hashCode() ? sourceAccountKey : targetAccountKey; + AccountKey lock2 = sourceAccountKey.hashCode() < targetAccountKey.hashCode() ? targetAccountKey : sourceAccountKey; + + synchronized (lock1) { + synchronized (lock2) { + Account sourceFromAccounts = accounts.get(sourceAccountKey); + Account targetFromAccounts = accounts.get(targetAccountKey); + + if (sourceFromAccounts != null && targetFromAccounts != null) { + if (sourceFromAccounts.getBalance() < amount) { + throw new InsufficientAccountBalanceException("The source balance cannot be less than amount"); + } + sourceFromAccounts.setBalance(sourceFromAccounts.getBalance() - amount); + targetFromAccounts.setBalance(targetFromAccounts.getBalance() + amount); + } else { + throw new AccountNotFoundException("One of the accounts was not found"); + } + } + } } } diff --git a/src/main/resources/sql/data/accounts.sql b/src/main/resources/sql/data/accounts.sql new file mode 100644 index 0000000..f142477 --- /dev/null +++ b/src/main/resources/sql/data/accounts.sql @@ -0,0 +1,8 @@ +create table accounts +( + id int8 not null, + first_name varchar(255) not null, + last_name varchar(255) not null, + balance int8 not null, + primary key (id) +); \ 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..fd268a8 --- /dev/null +++ b/src/main/resources/sql/data/select.sql @@ -0,0 +1,5 @@ +select source_id +from transfers +where transfer_time > '2019-01-01' :: timestamp +group by source_id +having sum(ammount) > 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..aa34f6a --- /dev/null +++ b/src/main/resources/sql/data/transfers.sql @@ -0,0 +1,17 @@ +create table transfers +( + id int8 not null, + source_id int8 not null, + target_id int8 not null, + amount int8 not null, + transfer_time timestamp not null, + primary key (id) +); + +alter table if exists transfers + add constraint transfers_source_fk + foreign key (source_id) references accounts; + +alter table if exists transfers + add constraint transfers_target_fk + foreign key (target_id) references accounts; \ No newline at end of file diff --git a/src/test/java/com/devexperts/rest/AccountControllerTest.java b/src/test/java/com/devexperts/rest/AccountControllerTest.java new file mode 100644 index 0000000..ee5ab8c --- /dev/null +++ b/src/test/java/com/devexperts/rest/AccountControllerTest.java @@ -0,0 +1,87 @@ +package com.devexperts.rest; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.AccountService; +import org.junit.BeforeClass; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@AutoConfigureMockMvc +@SpringBootTest +class AccountControllerTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccountService accountService; + + private final String URL_TEMPLATE = "/api/operations/transfer"; + + @BeforeEach + public void createAccounts() { + accountService.createAccount( + new Account(AccountKey.valueOf(1), "John", "Statham", (double) 10000000)); + accountService.createAccount( + new Account(AccountKey.valueOf(2), "Domenik", "Toretto", (double) 0)); + } + + @AfterEach + public void deleteAccounts() { + accountService.clear(); + } + + @Test + void transferFromOneAccountToAnotherThenStatusIsOK() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders + .post(URL_TEMPLATE) + .param("sourceId", "1") + .param("targetId", "2") + .param("amount", "1337")) + .andDo(print()) + .andExpect(status().isOk()); + } + + @Test + void transferFromOneAccountToAnotherWhenAmountIsInvalidThenStatusIsBadRequest() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders + .post(URL_TEMPLATE) + .param("sourceId", "1") + .param("targetId", "2") + .param("amount", "-228")) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + + @Test + void transferFromOneAccountToAnotherWhenAccountNotExistThenStatusIsNotFound() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders + .post(URL_TEMPLATE) + .param("sourceId", "1") + .param("targetId", "3") + .param("amount", "1337")) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + void transferFromOneAccountToAnotherWhenInsufficientAccountBalanceThenStatusIsInternalServerError() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders + .post(URL_TEMPLATE) + .param("sourceId", "2") + .param("targetId", "1") + .param("amount", "1337")) + .andDo(print()) + .andExpect(status().isInternalServerError()); + } +} \ 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..ba74a98 --- /dev/null +++ b/src/test/java/com/devexperts/service/AccountServiceImplTest.java @@ -0,0 +1,107 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; + +@SpringBootTest +public class AccountServiceImplTest { + + private AccountServiceImpl accountService; + + + @BeforeEach + void initAccountServiceImpl() { + this.accountService = new AccountServiceImpl(); + } + + @Test + void transferInMultiThreadsThenTransferSuccessful() throws InterruptedException { + + final CountDownLatch latch = new CountDownLatch(1); + + accountService.createAccount(new Account(AccountKey.valueOf(1), "1", "2", (double) 0)); + accountService.createAccount(new Account(AccountKey.valueOf(2), "1", "2", (double) 1000000)); + + + final Account account1 = accountService.getAccount(1); + final Account account2 = accountService.getAccount(2); + + final Thread thread1 = new Thread(new Runnable() { + @Override + public void run() { + try { + latch.await(); + for (int i = 0; i < 100000; i++) { + accountService.transfer(account2, account1, (double) 10); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }); + + + final Thread thread2 = new Thread(new Runnable() { + @Override + public void run() { + try { + latch.await(); + for (int i = 0; i < 1000000; i++) { + accountService.transfer(account2, account1, 1); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }); + + final Thread thread3 = new Thread(new Runnable() { + @Override + public void run() { + try { + latch.await(); + Thread.sleep(1005); + for (int i = 0; i < 1000; i++) { + accountService.transfer(account1, account1, 1); + } + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }); + + + final Thread thread4 = new Thread(new Runnable() { + @Override + public void run() { + try { + latch.await(); + Thread.sleep(1005); + for (int i = 0; i < 1000; i++) accountService.transfer(account1, account2, 1); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + }); + + thread1.start(); + thread2.start(); + thread3.start(); + thread4.start(); + + latch.countDown(); + + thread1.join(); + thread2.join(); + thread3.join(); + thread4.join(); + + Assertions.assertEquals(1000000, account1.getBalance() + account2.getBalance()); + } +}