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());
+ }
+}