diff --git a/pom.xml b/pom.xml index a8ffa1a..090a46e 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,18 @@ spring-boot-starter-test test + + junit + junit + 4.13.1 + compile + + + org.junit.jupiter + junit-jupiter-api + 5.5.2 + compile + diff --git a/src/main/java/com/devexperts/AccountServiceTest.java b/src/main/java/com/devexperts/AccountServiceTest.java new file mode 100644 index 0000000..edd5ec5 --- /dev/null +++ b/src/main/java/com/devexperts/AccountServiceTest.java @@ -0,0 +1,59 @@ +package com.devexperts; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.AccountService; +import com.devexperts.service.AccountServiceImpl; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class AccountServiceTest { + private AccountService accountService; + private Account account; + + @Before + public void setUp() { + accountService = new AccountServiceImpl(); + account = new Account(AccountKey.valueOf(42), "FirstName", "LastName", 0.0); + } + + @Test + public void testSingleAccountCreation() { + accountService.createAccount(account); + Account actual = accountService.getAccount(account.getAccountKey().getAccountId()); + Assert.assertEquals(account, actual); + } + + @Test + public void testAccountsCreation() { + accountService.createAccount(account); + Account actual = accountService.getAccount(account.getAccountKey().getAccountId()); + Assert.assertEquals(account, actual); + + Account otherAccount = new Account(AccountKey.valueOf(account.getAccountKey().getAccountId() + 1), "FirstName", "LastName", 0.0); + accountService.createAccount(otherAccount); + actual = accountService.getAccount(otherAccount.getAccountKey().getAccountId()); + Assert.assertEquals(otherAccount, actual); + Assert.assertNotEquals(account, actual); + } + + @Test(expected = IllegalArgumentException.class) + public void testExistingAccountCreation() { + Account account = new Account(AccountKey.valueOf(1), "FirstName", "LastName", 0.0); + accountService.createAccount(account); + accountService.createAccount(account); + } + + @Test + public void testAccountsClearing() { + accountService.createAccount(account); + Account actual = accountService.getAccount(account.getAccountKey().getAccountId()); + Assert.assertNotNull(actual); + + accountService.clear(); + + actual = accountService.getAccount(account.getAccountKey().getAccountId()); + Assert.assertNull(actual); + } +} diff --git a/src/main/java/com/devexperts/MultithreadTransferTest.java b/src/main/java/com/devexperts/MultithreadTransferTest.java new file mode 100644 index 0000000..3badedc --- /dev/null +++ b/src/main/java/com/devexperts/MultithreadTransferTest.java @@ -0,0 +1,43 @@ +package com.devexperts; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.AccountServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MultithreadTransferTest { + AccountServiceImpl service; + Account source, target; + AccountKey skey, tkey; + + @BeforeEach + private void setUp() { + service = new AccountServiceImpl(); + skey = AccountKey.valueOf(1); + source = new Account(skey, "One", "First", 100.0); + + tkey = AccountKey.valueOf(2); + target = new Account(tkey, "Two", "Second", 200.0); + } + + @Test + void multithreadTransfer() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(10); + for (int i = 0; i < 10; i++) { + new Thread(()->{ + service.transfer(source, target, 10.0); + latch.countDown(); + }).start(); + } + latch.await(1, TimeUnit.SECONDS); + + assertEquals(0.0, source.getBalance()); + assertEquals(300.0, target.getBalance()); + } +} diff --git a/src/main/java/com/devexperts/TransferTest.java b/src/main/java/com/devexperts/TransferTest.java new file mode 100644 index 0000000..b6429e1 --- /dev/null +++ b/src/main/java/com/devexperts/TransferTest.java @@ -0,0 +1,33 @@ +package com.devexperts; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.AccountService; +import com.devexperts.service.AccountServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TransferTest { + private AccountService accountService; + private Account source; + private Account target; + + @BeforeEach + public void setUp() { + accountService = new AccountServiceImpl(); + + source = new Account(AccountKey.valueOf(1), "One", "First", 100.0); + target = new Account(AccountKey.valueOf(2), "Two", "Second", 150.0); + accountService.createAccount(source); + accountService.createAccount(target); + } + + @Test + public void transfer() { + accountService.transfer(source, target, 50.0); + assertEquals(source.getBalance(), 50.0); + assertEquals(target.getBalance(), 200.0); + } +} \ No newline at end of file diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java index 1b0a233..1aae4af 100644 --- a/src/main/java/com/devexperts/account/AccountKey.java +++ b/src/main/java/com/devexperts/account/AccountKey.java @@ -1,5 +1,7 @@ package com.devexperts.account; +import java.util.Objects; + /** * Unique Account identifier * @@ -17,4 +19,21 @@ private AccountKey(long accountId) { public static AccountKey valueOf(long accountId) { return new AccountKey(accountId); } + + public long getAccountId() { + return 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/rest/AbstractAccountController.java b/src/main/java/com/devexperts/rest/AbstractAccountController.java index dea5a3c..f230808 100644 --- a/src/main/java/com/devexperts/rest/AbstractAccountController.java +++ b/src/main/java/com/devexperts/rest/AbstractAccountController.java @@ -3,5 +3,5 @@ import org.springframework.http.ResponseEntity; public abstract class AbstractAccountController { - abstract ResponseEntity transfer(long sourceId, long targetId, double amount); + abstract ResponseEntity transfer(Long sourceId, Long targetId, Double amount); } diff --git a/src/main/java/com/devexperts/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java index b300282..e13ebe2 100644 --- a/src/main/java/com/devexperts/rest/AccountController.java +++ b/src/main/java/com/devexperts/rest/AccountController.java @@ -1,14 +1,44 @@ package com.devexperts.rest; +import com.devexperts.account.Account; +import com.devexperts.service.AccountService; +import com.devexperts.service.exceptions.AccountNotFoundException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; 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 { - public ResponseEntity transfer(long sourceId, long targetId, double amount) { - return null; + private final AccountService service; + + @Autowired + public AccountController (AccountService service) { + this.service = service; + } + + @GetMapping("operations/transfer") + public ResponseEntity transfer( + @RequestParam("sourceId") Long sourceId, + @RequestParam("targetId") Long targetId, + @RequestParam("amount") Double amount) { + if (sourceId == null || targetId == null || amount < 0) + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + + try { + Account source = service.getAccount(sourceId); + Account target = service.getAccount(targetId); + service.transfer(source, target, amount); + return new ResponseEntity<>(HttpStatus.OK); + } catch (AccountNotFoundException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (IllegalStateException e) { + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } } -} +} \ No newline at end of file diff --git a/src/main/java/com/devexperts/service/AccountServiceImpl.java b/src/main/java/com/devexperts/service/AccountServiceImpl.java index 91261ba..1ced5a7 100644 --- a/src/main/java/com/devexperts/service/AccountServiceImpl.java +++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java @@ -4,13 +4,15 @@ import com.devexperts.account.AccountKey; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; @Service public class AccountServiceImpl implements AccountService { - private final List accounts = new ArrayList<>(); + private final Map accounts = new ConcurrentHashMap<>(); + private final ReentrantLock lock = new ReentrantLock(); @Override public void clear() { @@ -19,19 +21,38 @@ public void clear() { @Override public void createAccount(Account account) { - accounts.add(account); + if (account.getAccountKey() == null) { + throw new NullPointerException(); + } + if (accounts.containsKey(account.getAccountKey())) { + throw new IllegalArgumentException("This key is already used."); + } + accounts.put(account.getAccountKey(), account); } @Override public Account getAccount(long id) { - return accounts.stream() - .filter(account -> account.getAccountKey() == AccountKey.valueOf(id)) - .findAny() - .orElse(null); + return accounts.getOrDefault(AccountKey.valueOf(id), null); } @Override public void transfer(Account source, Account target, double amount) { - //do nothing for now + if (amount < 0 || source == target) + throw new IllegalArgumentException("Amount can't be negative OR Source can't be same as target."); + + lock.lock(); + try { + double sourceBalance = source.getBalance(); + double targetBalance = target.getBalance(); + + if (sourceBalance < amount) + throw new IllegalStateException("Source balance is insufficient."); + + source.setBalance(sourceBalance - amount); + target.setBalance(targetBalance + amount); + + } finally { + lock.unlock(); + } } } diff --git a/src/main/java/com/devexperts/service/exceptions/AccountNotFoundException.java b/src/main/java/com/devexperts/service/exceptions/AccountNotFoundException.java new file mode 100644 index 0000000..e0a217b --- /dev/null +++ b/src/main/java/com/devexperts/service/exceptions/AccountNotFoundException.java @@ -0,0 +1,7 @@ +package com.devexperts.service.exceptions; + +public class AccountNotFoundException extends RuntimeException { + public AccountNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/resources/data/accounts.sql b/src/main/java/resources/data/accounts.sql new file mode 100644 index 0000000..29f4e9d --- /dev/null +++ b/src/main/java/resources/data/accounts.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS Accounts +( + ID BIGINT AUTO_INCREMENT PRIMARY, + 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/java/resources/data/select.sql b/src/main/java/resources/data/select.sql new file mode 100644 index 0000000..7c39e59 --- /dev/null +++ b/src/main/java/resources/data/select.sql @@ -0,0 +1,4 @@ +SELECT t.source_id FROM Transfers AS t +WHERE t.transfer_time >= TIMESTAMP '2019-01-01' AND source_id != target_id AND amount > 0 +GROUP BY source_id +HAVING SUM(amount) > 1000; \ No newline at end of file diff --git a/src/main/java/resources/transfers.sql b/src/main/java/resources/transfers.sql new file mode 100644 index 0000000..1d477a4 --- /dev/null +++ b/src/main/java/resources/transfers.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS Transfers +( + ID BIGINT AUTO_INCREMENT PRIMARY, + SOURCE_ID BIGINT NOT NULL, + TARGET_ID BIGINT NOT NULL, + AMOUNT BIGINT NOT NULL, + TRANSFER_TIME TIMESTAMP NOT NULL, + FOREIGN KEY (SOURCE_ID) REFERENCES accounts (ID), + FOREIGN KEY (TARGET_ID) REFERENCES accounts (ID) + ); \ No newline at end of file