diff --git a/src/main/java/com/devexperts/account/Account.java b/src/main/java/com/devexperts/account/Account.java index fb2a3af..0649409 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,19 @@ 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); + } + + @Override + public int hashCode() { + return Objects.hash(accountKey, firstName, lastName); + } } diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java index 1b0a233..95213ea 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,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/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java index b300282..cf890e4 100644 --- a/src/main/java/com/devexperts/rest/AccountController.java +++ b/src/main/java/com/devexperts/rest/AccountController.java @@ -1,14 +1,77 @@ package com.devexperts.rest; +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.rest.data.ResponseMessage; +import com.devexperts.service.AccountService; +import com.devexperts.service.exceptions.NegativeBalanceException; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api") public class AccountController extends AbstractAccountController { - public ResponseEntity transfer(long sourceId, long targetId, double amount) { - return null; + private AccountService accountService; + + @Autowired + public void setAccountService( AccountService accountService ) { + this.accountService = accountService; + } + + //For test + @Deprecated + @GetMapping("test/init") + public ResponseEntity testInit() { + Account account1 = new Account( AccountKey.valueOf( 1 ), "Sergey", "Ivanov", 12d ); + Account account2 = new Account( AccountKey.valueOf( 2 ), "Vika", "Okulist", 125.0 ); + Account account3 = new Account( AccountKey.valueOf( 3 ), "Nikoly", "Frolov", 0.3d ); + accountService.createAccount( account1 ); + accountService.createAccount( account2 ); + accountService.createAccount( account3 ); + + return ResponseEntity.ok().build(); + } + + @PostMapping("operations/transfer") + public ResponseEntity transfer( + @RequestParam("source_id") long sourceId, + @RequestParam("target_id") long targetId, + @RequestParam("amount") double amount + ) { + Account source = accountService.getAccount( sourceId ); + Account target = accountService.getAccount( targetId ); + + if ( source == null || target == null ) { + throw new NullPointerException ("account is not found"); + } + + accountService.transfer( source, target, amount ); + return ResponseEntity.ok().build(); + } + + @ExceptionHandler({IllegalArgumentException.class}) + public ResponseEntity handleIllegalArgumentException( IllegalArgumentException ex ) { + return ResponseEntity.badRequest().body( + new ResponseMessage( ex.getMessage(), + 400, "BAD_REQUEST", "/operations/transfer") + ); + } + + @ExceptionHandler({NegativeBalanceException.class}) + public ResponseEntity handleNegativeBalanceException( NegativeBalanceException ex ) { + return ResponseEntity.status( 500 ).body( + new ResponseMessage( ex.getMessage(), + 500, "INTERNAL_SERVER_ERROR", "/operations/transfer") + ); + } + + @ExceptionHandler({NullPointerException.class}) + public ResponseEntity handleNullPointerException( NullPointerException ex ) { + return ResponseEntity.status( 404 ).body( + new ResponseMessage( ex.getMessage(), + 404, "NOT_FOUND", "/operations/transfer") + ); } } diff --git a/src/main/java/com/devexperts/rest/data/ResponseMessage.java b/src/main/java/com/devexperts/rest/data/ResponseMessage.java new file mode 100644 index 0000000..a186468 --- /dev/null +++ b/src/main/java/com/devexperts/rest/data/ResponseMessage.java @@ -0,0 +1,41 @@ +package com.devexperts.rest.data; + +import java.text.SimpleDateFormat; +import java.util.Date; + +public class ResponseMessage { + private final String message; + private final int status; + private final String error; + private final Date date; + private final String path; + + public ResponseMessage( String message, int status, String error, String path ) { + this.message = message; + this.status = status; + this.date = new Date(System.currentTimeMillis());; + this.error = error; + this.path = path; + } + + public String getMessage() { + return message; + } + + public int getStatus() { + return status; + } + + public String getError() { + return error; + } + + public String getDate() { + SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss z"); + return formatter.format( date ); + } + + public String getPath() { + return path; + } +} diff --git a/src/main/java/com/devexperts/service/AccountService.java b/src/main/java/com/devexperts/service/AccountService.java index f287597..310fc6a 100644 --- a/src/main/java/com/devexperts/service/AccountService.java +++ b/src/main/java/com/devexperts/service/AccountService.java @@ -12,6 +12,8 @@ public interface AccountService { /** * Creates a new account + * Todo update??? + * Todo if null??? * * @param account account entity to add or update * @throws IllegalArgumentException if account is already present diff --git a/src/main/java/com/devexperts/service/AccountServiceImpl.java b/src/main/java/com/devexperts/service/AccountServiceImpl.java index 91261ba..4bbc27d 100644 --- a/src/main/java/com/devexperts/service/AccountServiceImpl.java +++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java @@ -2,36 +2,77 @@ import com.devexperts.account.Account; import com.devexperts.account.AccountKey; +import com.devexperts.service.exceptions.NegativeBalanceException; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; +import java.util.HashMap; +import java.util.Map; @Service public class AccountServiceImpl implements AccountService { - private final List accounts = new ArrayList<>(); + //Changed the collection, because the speed of searching for an element in HashMap is constant. + private final Map accounts = new HashMap<>(); @Override public void clear() { accounts.clear(); } + /** + * @throws NullPointerException if account null + */ @Override - public void createAccount(Account account) { - accounts.add(account); + public void createAccount( Account account ) { + if ( account == null ) { + throw new NullPointerException( "account is null" ); + } + + if ( accounts.containsKey( account.getAccountKey() ) ) { + throw new IllegalArgumentException( "account is already present" ); + } + + accounts.put( account.getAccountKey(), account ); } @Override - public Account getAccount(long id) { - return accounts.stream() - .filter(account -> account.getAccountKey() == AccountKey.valueOf(id)) - .findAny() - .orElse(null); + public Account getAccount( long id ) { + return accounts.get( AccountKey.valueOf( id ) ); } @Override - public void transfer(Account source, Account target, double amount) { - //do nothing for now + public void transfer( Account source, Account target, double amount ) { + checkArgumentForTransfer( source, target, amount ); + transferAmount( source, target, amount ); + } + + protected void checkArgumentForTransfer( Account source, Account target, double amount ) { + if ( amount <= 0 ) { + throw new IllegalArgumentException( "amount is negative or zero" ); + } + + if ( source == null ) { + throw new IllegalArgumentException( "source is null" ); + } + + if ( target == null ) { + throw new IllegalArgumentException( "target is null" ); + } + + if ( source.getAccountKey().equals( target.getAccountKey() ) ) { + throw new IllegalArgumentException( "source and target is one account" ); + } + } + + private void transferAmount( Account source, Account target, double amount ) { + if ( source.getBalance() - amount < 0 ) { + throw new NegativeBalanceException( "source has insufficient funds on his account" ); + } + source.setBalance( source.getBalance() - amount ); + target.setBalance( target.getBalance() + amount ); + } + + protected Map getAccounts() { + return accounts; } } diff --git a/src/main/java/com/devexperts/service/AccountTransferParallelService.java b/src/main/java/com/devexperts/service/AccountTransferParallelService.java new file mode 100644 index 0000000..3c993af --- /dev/null +++ b/src/main/java/com/devexperts/service/AccountTransferParallelService.java @@ -0,0 +1,42 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.exceptions.NegativeBalanceException; + +public class AccountTransferParallelService extends AccountServiceImpl { + + private Account getAccount( AccountKey accountKey ) { + return getAccounts().get( accountKey ); + } + + @Override + public void transfer( Account source, Account target, double amount ) { + checkArgumentForTransfer( source, target, amount ); + + //get from the cache (to be sure) + Account sourceCash = getAccount( source.getAccountKey() ); + Account targetCash = getAccount( target.getAccountKey() ); + + if (sourceCash == null) { + throw new IllegalArgumentException( "source-account is not found" ); + } + + if (targetCash == null) { + throw new IllegalArgumentException( "target-account is not found" ); + } + + //used an optimistic approach to blocking resources + synchronized ( sourceCash ) { + if ( sourceCash.getBalance() - amount < 0 ) { + throw new NegativeBalanceException( "source has insufficient funds on his account" ); + } + + source.setBalance( source.getBalance() - amount ); + } + + synchronized ( targetCash ) { + target.setBalance( target.getBalance() + amount ); + } + } +} diff --git a/src/main/java/com/devexperts/service/exceptions/NegativeBalanceException.java b/src/main/java/com/devexperts/service/exceptions/NegativeBalanceException.java new file mode 100644 index 0000000..a12ac6c --- /dev/null +++ b/src/main/java/com/devexperts/service/exceptions/NegativeBalanceException.java @@ -0,0 +1,20 @@ +package com.devexperts.service.exceptions; + +public class NegativeBalanceException extends IllegalArgumentException { + + public NegativeBalanceException() { + super(); + } + + public NegativeBalanceException(String s) { + super(s); + } + + public NegativeBalanceException(String message, Throwable cause) { + super(message, cause); + } + + public NegativeBalanceException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql new file mode 100644 index 0000000..fcc204f --- /dev/null +++ b/src/main/resources/sql/data.sql @@ -0,0 +1,11 @@ +INSERT INTO ACCOUNTS (FIRST_NAME, LAST_NAME, BALANCE) +VALUES ('Сергей', 'Иванов', 170000.34), + ('Николай', 'Гумилёв', 20000), + ('Дарт', 'Вейдер', 7000000.07), + ('Сергей', 'Иванов', 4500000); + +INSERT INTO TRANSFERS (SOURCE_ID, TARGET_ID, AMOUNT, TRANSFER_TIME) +VALUES (1, 3, 999, '2019-02-23'), + (1, 4, 2000, '2020-07-04'), + (1, 2, 760, '208-01-08'), + (4, 3, 10000, '2020-07-25') \ No newline at end of file diff --git a/src/main/resources/sql/data/accounts.sql b/src/main/resources/sql/data/accounts.sql new file mode 100644 index 0000000..7459452 --- /dev/null +++ b/src/main/resources/sql/data/accounts.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS ACCOUNTS; + +CREATE TABLE ACCOUNTS +( + ID LONG NOT NULL AUTO_INCREMENT, + FIRST_NAME VARCHAR(100) NOT NULL, + LAST_NAME VARCHAR(100) NOT NULL, + BALANCE DOUBLE, + CONSTRAINT ACCOUNTS_PK + PRIMARY KEY (ID) +); + diff --git a/src/main/resources/sql/data/transfers.sql b/src/main/resources/sql/data/transfers.sql new file mode 100644 index 0000000..bf4eefc --- /dev/null +++ b/src/main/resources/sql/data/transfers.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS TRANSFERS; + +CREATE TABLE TRANSFERS +( + ID LONG NOT NULL AUTO_INCREMENT, + SOURCE_ID LONG NOT NULL, + TARGET_ID LONG NOT NULL, + AMOUNT DOUBLE NOT NULL, + TRANSFER_TIME TIMESTAMP NOT NULL, + CONSTRAINT TRANSFERS_PK + PRIMARY KEY (ID) +); \ No newline at end of file diff --git a/src/main/resources/sql/select.sql b/src/main/resources/sql/select.sql new file mode 100644 index 0000000..8fc5245 --- /dev/null +++ b/src/main/resources/sql/select.sql @@ -0,0 +1,10 @@ +SELECT SOURCE_ID +FROM TRANSFERS t + JOIN ACCOUNTS a_source + ON t.SOURCE_ID = a_source.ID + JOIN ACCOUNTS a_target + ON t.TARGET_ID = a_target.ID +WHERE (a_source.FIRST_NAME <> a_target.FIRST_NAME OR a_source.LAST_NAME <> a_target.LAST_NAME) + AND TRANSFER_TIME >= '2019-01-01' +GROUP BY SOURCE_ID +HAVING SUM(AMOUNT) > 1000 \ No newline at end of file diff --git a/src/test/java/com/devexperts/service/AccountServiceTaskOneTest.java b/src/test/java/com/devexperts/service/AccountServiceTaskOneTest.java new file mode 100644 index 0000000..142626e --- /dev/null +++ b/src/test/java/com/devexperts/service/AccountServiceTaskOneTest.java @@ -0,0 +1,57 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Tests for checking the first task") +class AccountServiceTaskOneTest { + private static Account account; + private static long key = 1123456; + private static AccountService accountService = new AccountServiceImpl(); + + static { + account = new Account( + AccountKey.valueOf( key ), "Sergey", "Ivanov", 100d + ); + } + + @Test + @DisplayName("Check 'createAccount', normal work") + void testCreateAccount() { + accountService.clear(); + assertDoesNotThrow( () -> accountService.createAccount( account ) ); + } + + @Test + @DisplayName("Check 'createAccount', that an 'NullPointerException' was thrown") + void testCreateAccountThrowNullPointerException() { + assertThrows( NullPointerException.class, () -> accountService.createAccount( null ) ); + } + + @Test + @DisplayName("Check 'createAccount', that an 'IllegalArgumentException' was thrown") + void testCreateAccountThrowIllegalArgumentException() { + accountService.clear(); + accountService.createAccount( account ); + assertThrows( IllegalArgumentException.class, () -> accountService.createAccount( account ) ); + } + + @Test + @DisplayName("Check 'getAccount', normal work") + void testGetAccount() { + accountService.clear(); + accountService.createAccount( account ); + assertEquals( account, accountService.getAccount( key ) ); + } + + @Test + @DisplayName("Check 'getAccount', account is not found") + void testGetAccountIsNotFound() { + accountService.clear(); + assertNull( accountService.getAccount( key ) ); + } +} \ No newline at end of file diff --git a/src/test/java/com/devexperts/service/AccountServiceTaskTwoTest.java b/src/test/java/com/devexperts/service/AccountServiceTaskTwoTest.java new file mode 100644 index 0000000..7bba9c1 --- /dev/null +++ b/src/test/java/com/devexperts/service/AccountServiceTaskTwoTest.java @@ -0,0 +1,93 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.service.exceptions.NegativeBalanceException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Tests for checking the second task") +class AccountServiceTaskTwoTest { + private static Account source; + private static Account target; + private static AccountService accountService = new AccountServiceImpl(); + + static { + source = new Account( + AccountKey.valueOf( 1123456 ), "Sergey", "Ivanov", 100d + ); + + target = new Account( + AccountKey.valueOf( 12332343 ), "Vika", "Okulist", 0d + ); + } + + @Test + @DisplayName("Check 'transfer', normal work") + void testTransfer() { + double amount = 99.12d; + double balanceTarget = target.getBalance(); + double balanceSource = source.getBalance(); + + accountService.transfer( source, target, amount ); + + assertEquals( target.getBalance(), balanceTarget + amount ); + assertEquals( source.getBalance(), balanceSource - amount ); + } + + @Test + @DisplayName("Check 'transfer', that an 'NegativeBalanceException' was thrown") + void testTransferThrowNegativeBalanceException() { + double amount = 100.12d; + assertThrows( NegativeBalanceException.class, () -> accountService.transfer( source, target, amount ) ); + } + + @Test + @DisplayName( + "Check 'transfer', that an 'IllegalArgumentException' was thrown, because 'source' or 'target' is null" + ) + void testTransferSourceIsNull() { + double amount = 100.12d; + Throwable thrown = assertThrows( IllegalArgumentException.class, () -> + accountService.transfer( null, target, amount ) + ); + + assertEquals( thrown.getMessage(), "source or target is null" ); + } + + @Test + @DisplayName( + "Check 'transfer', that an 'IllegalArgumentException' was thrown, " + + "because 'source' and 'target' is one account" + ) + void testTransferSourceAndTargetIsOneAccount() { + double amount = 82.12d; + Throwable thrown = assertThrows( IllegalArgumentException.class, () -> + accountService.transfer( source, source, amount ) + ); + + assertEquals( thrown.getMessage(), "source and target is one account" ); + } + + @Test + @DisplayName( + "Check 'transfer', that an 'IllegalArgumentException' was thrown, " + + "because 'source' and 'target' is one account" + ) + void testTransferAmountIsNegativeOrZero() { + double amountIsZero = 0.0d; + double amountIsNegative = -0.1d; + Throwable thrownIsZero = assertThrows( IllegalArgumentException.class, () -> + accountService.transfer( source, target, amountIsZero ) + ); + assertEquals( thrownIsZero.getMessage(), "amount is negative or zero" ); + + Throwable thrownIsNegative = assertThrows( IllegalArgumentException.class, () -> + accountService.transfer( source, target, amountIsNegative ) + ); + assertEquals( thrownIsNegative.getMessage(), "amount is negative or zero" ); + } +} \ No newline at end of file