diff --git a/pom.xml b/pom.xml index a8ffa1a..2b2c675 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,17 @@ spring-boot-starter-test test + + org.springframework + spring-tx + + + + org.mockito + mockito-junit-jupiter + + test + diff --git a/src/main/java/com/devexperts/account/AccountKey.java b/src/main/java/com/devexperts/account/AccountKey.java index 1b0a233..e9d788c 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,18 @@ private AccountKey(long accountId) { public static AccountKey valueOf(long accountId) { return new AccountKey(accountId); } + + // переопределил equals и hashcode + @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/exceptions/AccountNotFoundException.java b/src/main/java/com/devexperts/exceptions/AccountNotFoundException.java new file mode 100644 index 0000000..61b8b60 --- /dev/null +++ b/src/main/java/com/devexperts/exceptions/AccountNotFoundException.java @@ -0,0 +1,11 @@ +package com.devexperts.exceptions; + +public class AccountNotFoundException extends Exception { + public AccountNotFoundException(String message, Throwable cause) { + super(message, cause); + } + + public AccountNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devexperts/exceptions/AmountIsInvalidException.java b/src/main/java/com/devexperts/exceptions/AmountIsInvalidException.java new file mode 100644 index 0000000..5816133 --- /dev/null +++ b/src/main/java/com/devexperts/exceptions/AmountIsInvalidException.java @@ -0,0 +1,11 @@ +package com.devexperts.exceptions; + +public class AmountIsInvalidException extends Exception { + public AmountIsInvalidException(String message, Throwable cause) { + super(message, cause); + } + + public AmountIsInvalidException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devexperts/exceptions/InsufficientAccountBalanceException.java b/src/main/java/com/devexperts/exceptions/InsufficientAccountBalanceException.java new file mode 100644 index 0000000..85f61b8 --- /dev/null +++ b/src/main/java/com/devexperts/exceptions/InsufficientAccountBalanceException.java @@ -0,0 +1,11 @@ +package com.devexperts.exceptions; + +public class InsufficientAccountBalanceException extends Exception { + public InsufficientAccountBalanceException(String message, Throwable cause) { + super(message, cause); + } + + public InsufficientAccountBalanceException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devexperts/rest/AccountController.java b/src/main/java/com/devexperts/rest/AccountController.java index b300282..02158e9 100644 --- a/src/main/java/com/devexperts/rest/AccountController.java +++ b/src/main/java/com/devexperts/rest/AccountController.java @@ -1,14 +1,37 @@ package com.devexperts.rest; +import com.devexperts.account.Account; +import com.devexperts.exceptions.AccountNotFoundException; +import com.devexperts.exceptions.AmountIsInvalidException; +import com.devexperts.exceptions.InsufficientAccountBalanceException; +import com.devexperts.service.AccountService; +import org.springframework.beans.factory.annotation.Autowired; 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 { - public ResponseEntity transfer(long sourceId, long targetId, double amount) { - return null; + @Autowired + private AccountService accountService; + + @PostMapping("operations/transfer") + public ResponseEntity transfer(@RequestParam long sourceId, + @RequestParam long targetId, + @RequestParam double amount) { + try { + accountService.transferWithChecks(sourceId, targetId, amount); + } catch (AmountIsInvalidException e) { + return ResponseEntity.badRequest().build(); + } catch (AccountNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (InsufficientAccountBalanceException e) { + return ResponseEntity.status(500).build(); + } + return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/devexperts/service/AccountService.java b/src/main/java/com/devexperts/service/AccountService.java index f287597..0856cd9 100644 --- a/src/main/java/com/devexperts/service/AccountService.java +++ b/src/main/java/com/devexperts/service/AccountService.java @@ -1,6 +1,9 @@ package com.devexperts.service; import com.devexperts.account.Account; +import com.devexperts.exceptions.AccountNotFoundException; +import com.devexperts.exceptions.AmountIsInvalidException; +import com.devexperts.exceptions.InsufficientAccountBalanceException; public interface AccountService { @@ -33,5 +36,8 @@ public interface AccountService { * @param target account to transfer money to * @param amount dollar amount to transfer * */ - void transfer(Account source, Account target, double amount); + //void transfer(Account source, Account target, double amount); + + void transferWithChecks(long sourceId, long targetId, double amount) + throws AccountNotFoundException, InsufficientAccountBalanceException, AmountIsInvalidException; } diff --git a/src/main/java/com/devexperts/service/AccountServiceImpl.java b/src/main/java/com/devexperts/service/AccountServiceImpl.java index 91261ba..a8b119c 100644 --- a/src/main/java/com/devexperts/service/AccountServiceImpl.java +++ b/src/main/java/com/devexperts/service/AccountServiceImpl.java @@ -2,15 +2,21 @@ import com.devexperts.account.Account; import com.devexperts.account.AccountKey; +import com.devexperts.exceptions.AccountNotFoundException; +import com.devexperts.exceptions.AmountIsInvalidException; +import com.devexperts.exceptions.InsufficientAccountBalanceException; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RequestParam; -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<>(); + private final Map accounts = new HashMap<>(); @Override public void clear() { @@ -19,19 +25,61 @@ public void clear() { @Override public void createAccount(Account account) { - accounts.add(account); + if (!accounts.containsKey(account.getAccountKey())) { + accounts.put(account.getAccountKey(), account); + } } @Override public Account getAccount(long id) { - return accounts.stream() - .filter(account -> account.getAccountKey() == AccountKey.valueOf(id)) - .findAny() - .orElse(null); + synchronized (this) { + return accounts.get(AccountKey.valueOf(id)); + } + } + + private void transfer(Account source, Account target, double amount) { + source.setBalance(source.getBalance() - amount); + target.setBalance(target.getBalance() + amount); } @Override - public void transfer(Account source, Account target, double amount) { - //do nothing for now + @Transactional + public void transferWithChecks(long sourceId, long targetId, double amount) + throws AccountNotFoundException, InsufficientAccountBalanceException, + AmountIsInvalidException { + + if (amount <= 0) { + throw new AmountIsInvalidException("Amount is invalid"); + } + + Account sourceAccount = getAccount(sourceId); + Account targetAccount = getAccount(targetId); + + if (sourceAccount == null) { + throw new AccountNotFoundException("Source account is not found."); + } + + if (targetAccount == null) { + throw new AccountNotFoundException("Target account is not found."); + } + if (sourceId < targetId) { + synchronized (sourceAccount) { + synchronized (targetAccount) { + if (sourceAccount.getBalance() < amount) { + throw new InsufficientAccountBalanceException("Insufficient account balance"); + } + transfer(sourceAccount, targetAccount, amount); + } + } + } else { + synchronized (targetAccount) { + synchronized (sourceAccount) { + if (sourceAccount.getBalance() < amount) { + throw new InsufficientAccountBalanceException("Insufficient account balance"); + } + transfer(sourceAccount, targetAccount, amount); + } + } + } } } diff --git a/src/main/resources/sql.data/accounts.sql b/src/main/resources/sql.data/accounts.sql new file mode 100644 index 0000000..0d2ca5e --- /dev/null +++ b/src/main/resources/sql.data/accounts.sql @@ -0,0 +1,7 @@ +create table accounts ( + id int8 not null, + first_name varchar(250) not null, + last_name varchar(250), + balance money, + constraint accounts_pkey primary key (id) +); diff --git a/src/main/resources/sql.data/select.sql b/src/main/resources/sql.data/select.sql new file mode 100644 index 0000000..eef64a8 --- /dev/null +++ b/src/main/resources/sql.data/select.sql @@ -0,0 +1,5 @@ +select a.id, a.first_name, sum(t.amount) total +from accounts a join transfers t on a.id = t.source_id +where t.transfer_time >= '2019-01-01' +group by a.id +having sum(t.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..b02f6f0 --- /dev/null +++ b/src/main/resources/sql.data/transfers.sql @@ -0,0 +1,8 @@ +create table transfers ( + id int8 not null, + source_id int8 not null, + target_id int8 not null, + amount money not null, + transfer_time timestamp not null, + constraint transfers_pkey primary key (id) +); 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..ed3542c --- /dev/null +++ b/src/test/java/com/devexperts/rest/AccountControllerTest.java @@ -0,0 +1,126 @@ +package com.devexperts.rest; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.exceptions.AccountNotFoundException; +import com.devexperts.exceptions.AmountIsInvalidException; +import com.devexperts.exceptions.InsufficientAccountBalanceException; +import com.devexperts.service.AccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class AccountControllerTest { + + private MockMvc mvc; + + @InjectMocks + private AccountController accountController; + + @Mock + private AccountService accountService; + + @BeforeEach + public void setup() { + mvc = MockMvcBuilders.standaloneSetup(accountController) + .build(); + } + + /** + * successful transfer + * */ + @Test + void transfer_0K() throws Exception { + MockHttpServletResponse response = mvc.perform( + post("/api/operations/transfer") + .param("sourceId", String.valueOf(1l)) + .param("targetId", String.valueOf(2l)) + .param("amount", String.valueOf(20.1))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + } + + /** + * account is not found + * */ + @Test + void transfer_NOT_FOUND() throws Exception { + doThrow(AccountNotFoundException.class).when(accountService) + .transferWithChecks(1L, 2L, 20.1); + MockHttpServletResponse response = mvc.perform( + post("/api/operations/transfer") + .param("sourceId", String.valueOf(1l)) + .param("targetId", String.valueOf(2l)) + .param("amount", String.valueOf(20.1))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + } + + /** + * amount is invalid + * */ + @Test + void transfer_BAD_REQUEST() throws Exception { + doThrow(AmountIsInvalidException.class).when(accountService) + .transferWithChecks(1L, 2L, -1.0); + MockHttpServletResponse response = mvc.perform( + post("/api/operations/transfer") + .param("sourceId", String.valueOf(1l)) + .param("targetId", String.valueOf(2l)) + .param("amount", String.valueOf(-1.0))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + /** + * one of the parameters in not present + * */ + @Test + void transfer_BAD_REQUEST2() throws Exception { + MockHttpServletResponse response = mvc.perform( + post("/api/operations/transfer") + .param("sourceId", String.valueOf(1l)) + .param("amount", String.valueOf(1.0))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + /** + * insufficient account balance + * */ + @Test + void transfer_INTERNAL_SERVER_ERROR() throws Exception { + doThrow(InsufficientAccountBalanceException.class).when(accountService) + .transferWithChecks(1L, 2L, 1000.0); + MockHttpServletResponse response = mvc.perform( + post("/api/operations/transfer") + .param("sourceId", String.valueOf(1l)) + .param("targetId", String.valueOf(2l)) + .param("amount", String.valueOf(1000.0))) + .andReturn().getResponse(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value()); + } +} \ 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..b81c251 --- /dev/null +++ b/src/test/java/com/devexperts/service/AccountServiceImplTest.java @@ -0,0 +1,63 @@ +package com.devexperts.service; + +import com.devexperts.account.Account; +import com.devexperts.account.AccountKey; +import com.devexperts.exceptions.AccountNotFoundException; +import com.devexperts.exceptions.AmountIsInvalidException; +import com.devexperts.exceptions.InsufficientAccountBalanceException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.doReturn; + +@SpringBootTest +class AccountServiceImplTest { + @Autowired + AccountService accountService; + + @Test + void transfer_OK() throws AccountNotFoundException, AmountIsInvalidException, InsufficientAccountBalanceException { + accountService.createAccount(new Account(AccountKey.valueOf(1L), + "Bill", "Gates", 222.00)); + accountService.createAccount(new Account(AccountKey.valueOf(2L), + "Steve", "Jobs", 133.00)); + accountService.transferWithChecks(1L, 2L, 10); + assertEquals(222.00 - 10, accountService.getAccount(1L).getBalance()); + assertEquals(133.00 + 10, accountService.getAccount(2L).getBalance()); + accountService.clear(); + } + + + @Test + void transfer_AmountIsInvalid() { + accountService.createAccount(new Account(AccountKey.valueOf(1L), + "Bill", "Gates", 222.00)); + accountService.createAccount(new Account(AccountKey.valueOf(2L), + "Steve", "Jobs", 133.00)); + assertThrows(AmountIsInvalidException.class, () -> + accountService.transferWithChecks(1L, 2L, 0) + ); + accountService.clear(); + } + + @Test + void transfer_AccountIsNotFound() { + assertThrows(AccountNotFoundException.class, () -> + accountService.transferWithChecks(33L, 22L, 10) + ); + } + + @Test + void transfer_InsufficientAccountBalance() { + accountService.createAccount(new Account(AccountKey.valueOf(1L), + "Bill", "Gates", 222.00)); + accountService.createAccount(new Account(AccountKey.valueOf(2L), + "Steve", "Jobs", 133.00)); + assertThrows(InsufficientAccountBalanceException.class, () -> + accountService.transferWithChecks(1L, 2L, 1000) + ); + accountService.clear(); + } +}