/
niki02
/
Practice_SBER
Обзор
Документация
Войти
/
niki02
/
Practice_SBER
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/test/java/com/example/creditcalculator/LoanIntegrationTest.java
295 строк
14 KB
nikgilae
final commit
23 июн 2026, 12:08
23 июн 2026, 12:08
9a5fbe1
Код
Авторство
О чём код?
package com.example.creditcalculator; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.http.*; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.math.BigDecimal; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.*; /** * Integration tests against a real PostgreSQL container (Testcontainers). * * Covers: * - Full happy-path lifecycle (SPEC.md §12) * - Overdue accrual and priority repayment (SPEC.md §6.2-6.3) * - All edge cases from SPEC.md §11 * - GlobalExceptionHandler responses (4xx mapping) */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers class LoanIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16"); @Autowired TestRestTemplate rest; // ========================================================================= // Scenario 1 — happy path // 100 000 ₽, 12%, 12 мес., start 2026-01-01 // // Covers edge cases from §11: // #3 досрочное погашение на дату плового платежа → days=0 → interimInterest=0 // #4 полное погашение через EARLY_REPAYMENT → status CLOSED // ========================================================================= @Test void happyPath_threePayments_fullEarlyRepayment_close() { String id = createLoan(100_000, 12, 12, "2026-01-01"); // Schedule: first planned entry AP = 8884.88 List<Map<String, Object>> entries = scheduleEntries(id, "2026-01-01"); assertThat(bd(entries.get(0).get("paymentAmount"))).isEqualByComparingTo("8884.88"); // Three monthly payments // Month 1: interest=1000.00, principal=7884.88 → remaining=92115.12 // Month 2: interest=921.15, principal=7963.73 → remaining=84151.39 // Month 3: interest=841.51, principal=8043.37 → remaining=76108.02 payOk(id, "8884.88", "2026-02-01"); payOk(id, "8884.88", "2026-03-01"); payOk(id, "8884.88", "2026-04-01"); // Account summary after 3 payments Map<String, Object> sum = accountSummary(id); assertThat(bd(sum.get("remainingPrincipal"))).isEqualByComparingTo("76108.02"); assertThat(bd(sum.get("overdueInterest"))).isEqualByComparingTo("0"); assertThat(bd(sum.get("overduePrincipal"))).isEqualByComparingTo("0"); assertThat(bd(sum.get("totalOverdue"))).isEqualByComparingTo("0"); assertThat(sum.get("nextPaymentDate")).isEqualTo("2026-05-01"); // Full early repayment on the last plan date (2026-04-01): // lastPaymentDate = startDate.plusMonths(3) = 2026-04-01 // days = 0 → interimInterest = 0 → principalReduction = 76108.02 // → fullRepayment = true → status = CLOSED ResponseEntity<Map> er = earlyRepayment(id, "76108.02", "REDUCE_PAYMENT", "2026-04-01"); assertThat(er.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(er.getBody().get("status")).isEqualTo("CLOSED"); // Loan entity reflects CLOSED assertThat(getLoanStatus(id)).isEqualTo("CLOSED"); // Any further operation on CLOSED loan → 409 ResponseEntity<Map> blocked = payRaw(id, "1000", "2026-05-01"); assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); } // ========================================================================= // Scenario 2 — overdue + priority repayment + recovery // // Covers §11 edge case #5: // платёж при наличии одновременно ПРОСРОЧ_ПРОЦ и ПРОСРОЧ_КРЕД // ========================================================================= @Test void overdue_priorityRepayment_statusRecoveryToActive() { String id = createLoan(100_000, 12, 12, "2026-01-01"); // Record overdue at month 1 // overdueInterest = 100 000 × 0.01 = 1 000.00 // AP = 8 884.88 // overduePrincipal = 8 884.88 − 1 000.00 = 7 884.88 ResponseEntity<Map> ovResp = postEmpty("/loans/" + id + "/overdue?overdueDate=2026-02-01"); assertThat(ovResp.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(ovResp.getBody().get("status")).isEqualTo("OVERDUE"); // Account summary shows overdue split correctly Map<String, Object> sumOv = accountSummary(id); assertThat(bd(sumOv.get("overdueInterest"))).isEqualByComparingTo("1000.00"); assertThat(bd(sumOv.get("overduePrincipal"))).isEqualByComparingTo("7884.88"); assertThat(bd(sumOv.get("totalOverdue"))).isEqualByComparingTo("8884.88"); // Payment covering all priorities (amounts computed exactly): // Priority 1: ПРОСРОЧ_ПРОЦ = 1 000.00 // Priority 2: ПРОСРОЧ_КРЕД = 7 884.88 → remaining = 100 000 − 7 884.88 = 92 115.12 // Priority 3: currentInterest = 92 115.12 × 0.01 = 921.15 // currentPrincipal = 8 884.88 − 921.15 = 7 963.73 // Total = 1 000 + 7 884.88 + 921.15 + 7 963.73 = 17 769.76 ResponseEntity<Map> payResp = payRaw(id, "17769.76", "2026-03-01"); assertThat(payResp.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(payResp.getBody().get("status")).isEqualTo("ACTIVE"); // All overdue cleared; remainingPrincipal correct // state.remaining after PAYMENT: // LoanAccountant subtracts principalPaid(7963.73) + overduePrincipalPaid(7884.88) // = 100 000 − 7 963.73 − 7 884.88 = 84 151.39 Map<String, Object> sumAfter = accountSummary(id); assertThat(bd(sumAfter.get("remainingPrincipal"))).isEqualByComparingTo("84151.39"); assertThat(bd(sumAfter.get("overdueInterest"))).isEqualByComparingTo("0"); assertThat(bd(sumAfter.get("overduePrincipal"))).isEqualByComparingTo("0"); assertThat(bd(sumAfter.get("totalOverdue"))).isEqualByComparingTo("0"); } // ========================================================================= // Edge case §11 #1 — кредит на 1 месяц // ========================================================================= @Test void edgeCase_oneMonthloan_singlePaymentCloses() { // 10 000 ₽, 12%, 1 month // Last (only) payment = remaining(10 000) + interest(100) = 10 100 String id = createLoan(10_000, 12, 1, "2026-01-01"); List<Map<String, Object>> entries = scheduleEntries(id, "2026-01-01"); assertThat(entries).hasSize(1); assertThat(bd(entries.get(0).get("paymentAmount"))).isEqualByComparingTo("10100.00"); ResponseEntity<Map> resp = payRaw(id, "10100", "2026-02-01"); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(resp.getBody().get("status")).isEqualTo("CLOSED"); } // ========================================================================= // Edge case §11 #2 — ставка 0% // ========================================================================= @Test void edgeCase_zeroRate_equalPaymentsZeroInterest() { // 12 000 ₽, 0%, 12 мес. → AP = 1 000, interest = 0 every month String id = createLoan(12_000, 0, 12, "2026-01-01"); List<Map<String, Object>> entries = scheduleEntries(id, "2026-01-01"); assertThat(bd(entries.get(0).get("paymentAmount"))).isEqualByComparingTo("1000.00"); assertThat(bd(entries.get(0).get("interestPaid"))).isEqualByComparingTo("0"); } // ========================================================================= // Edge case §11 #6 — сумма досрочного погашения < interim_interest → 400 // ========================================================================= @Test void edgeCase_earlyRepayment_amountBelowInterimInterest_returns400() { // 100 000 ₽, 12%, 12 мес. // Repayment on 2026-01-16 (15 days after start): // interim_interest = 100 000 × 0.01 × (15/30) = 500.00 // sending 1.00 < 500.00 → InvalidPaymentException → 400 String id = createLoan(100_000, 12, 12, "2026-01-01"); ResponseEntity<Map> resp = earlyRepayment(id, "1.00", "REDUCE_TERM", "2026-01-16"); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } // ========================================================================= // Edge case §11 #7 — последний платёж при REDUCE_TERM досрочном погашении // Verified via the preview endpoint which uses buildSchedule's last-payment logic. // Amount chosen so newPrincipal = 5 000 → newTerm = 1 // Last payment = 5 000 + 5 000×0.01 = 5 050 (remainder formula, not AP formula) // ========================================================================= @Test void edgeCase_reduceTerm_previewLastPaymentIsRemainingPlusInterest() { String id = createLoan(100_000, 12, 12, "2026-01-01"); payOk(id, "8884.88", "2026-02-01"); payOk(id, "8884.88", "2026-03-01"); payOk(id, "8884.88", "2026-04-01"); // remaining = 76 108.02, days = 0, AP = 8 884.88 // amount 71 108.02 → newPrincipal = 5 000.00 → newTerm = 1 Map<?, ?> preview = rest.getForObject( "/loans/" + id + "/early-repayment/preview" + "?date=2026-04-01&repaymentType=REDUCE_TERM&amount=71108.02", Map.class); assertThat(preview.get("newTermMonths")).isEqualTo(1); @SuppressWarnings("unchecked") List<Map<String, Object>> sched = (List<Map<String, Object>>) preview.get("newSchedule"); assertThat(sched).hasSize(1); // Last payment = remaining(5 000) + interest(50) = 5 050 assertThat(bd(sched.get(0).get("paymentAmount"))).isEqualByComparingTo("5050.00"); assertThat(bd(sched.get(0).get("remainingPrincipal"))).isEqualByComparingTo("0"); } // ========================================================================= // Error handling // ========================================================================= @Test void errorHandling_unknownLoan_returns404() { ResponseEntity<Map> resp = rest.getForEntity( "/loans/00000000-0000-0000-0000-000000000000", Map.class); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } @Test void errorHandling_invalidRequest_returns400WithFieldDetails() { // Negative loanAmount fails @Positive validation ResponseEntity<Map> resp = postJson("/loans", "{\"loanAmount\":-100,\"annualRate\":12,\"termMonths\":12,\"startDate\":\"2026-01-01\"}"); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); // GlobalExceptionHandler attaches field-level message String detail = (String) resp.getBody().get("detail"); assertThat(detail).contains("loanAmount"); } // ========================================================================= // Helpers // ========================================================================= private String createLoan(int amount, int rate, int term, String startDate) { String json = String.format( "{\"loanAmount\":%d,\"annualRate\":%d,\"termMonths\":%d,\"startDate\":\"%s\"}", amount, rate, term, startDate); ResponseEntity<Map> resp = postJson("/loans", json); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); return (String) resp.getBody().get("id"); } @SuppressWarnings("unchecked") private List<Map<String, Object>> scheduleEntries(String id, String asOf) { Map<?, ?> body = rest.getForObject("/loans/" + id + "/schedule?asOf=" + asOf, Map.class); return (List<Map<String, Object>>) body.get("entries"); } @SuppressWarnings("unchecked") private Map<String, Object> accountSummary(String id) { return (Map<String, Object>) rest.getForObject("/loans/" + id + "/account-summary", Map.class); } private String getLoanStatus(String id) { Map<?, ?> body = rest.getForObject("/loans/" + id, Map.class); return (String) body.get("status"); } private void payOk(String id, String amount, String date) { ResponseEntity<Map> resp = payRaw(id, amount, date); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); } private ResponseEntity<Map> payRaw(String id, String amount, String date) { return postJson("/loans/" + id + "/payments", String.format("{\"amount\":%s,\"date\":\"%s\"}", amount, date)); } private ResponseEntity<Map> earlyRepayment(String id, String amount, String type, String date) { return postJson("/loans/" + id + "/early-repayment", String.format("{\"amount\":%s,\"repaymentType\":\"%s\",\"date\":\"%s\"}", amount, type, date)); } @SuppressWarnings("unchecked") private ResponseEntity<Map> postJson(String url, String body) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); } @SuppressWarnings("unchecked") private ResponseEntity<Map> postEmpty(String url) { return rest.exchange(url, HttpMethod.POST, HttpEntity.EMPTY, Map.class); } private BigDecimal bd(Object val) { return new BigDecimal(val.toString()); } }