From 56e96f6c150c87cefdc0634bbfd460b2936c266b Mon Sep 17 00:00:00 2001 From: Anastasiia Fomkina Date: Fri, 28 Nov 2025 19:41:20 -0300 Subject: [PATCH 1/4] Add getOwnerById contact test --- accounts-service/build.gradle.kts | 13 ++++++ .../contract/BaseAccountsContractTest.java | 44 +++++++++++++++++++ .../service/contract/JwtTestConfig.java | 35 +++++++++++++++ .../contracts/accounts/getOwnerById.groovy | 26 +++++++++++ 4 files changed, 118 insertions(+) create mode 100644 accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java create mode 100644 accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java create mode 100644 accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy diff --git a/accounts-service/build.gradle.kts b/accounts-service/build.gradle.kts index 0898bf8..d1defbd 100644 --- a/accounts-service/build.gradle.kts +++ b/accounts-service/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("org.springframework.cloud.contract") version "4.3.0" +} + dependencies { implementation(libs.spring.boot.starter.web) @@ -5,4 +9,13 @@ dependencies { implementation(libs.spring.boot.starter.oauth2.resource.server) implementation(libs.spring.boot.starter.actuator) + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier:4.3.0") + testImplementation ("org.springframework.security:spring-security-test") } + +contracts { + baseClassForTests.set( + "ru.practicum.accounts.service.contract.BaseAccountsContractTest" + ) +} + diff --git a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java new file mode 100644 index 0000000..96a57cf --- /dev/null +++ b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java @@ -0,0 +1,44 @@ +package ru.practicum.accounts.service.contract; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import ru.practicum.accounts.service.model.Account; +import ru.practicum.accounts.service.service.AccountService; + +import static org.mockito.Mockito.when; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("contract-test") +public abstract class BaseAccountsContractTest { + + @Autowired + protected MockMvc mockMvc; + + @MockBean + protected AccountService accountService; + + @BeforeEach + void setup() { + RestAssuredMockMvc.mockMvc(mockMvc); + + when(accountService.getAccount("ACC-001")) + .thenReturn(new Account("ACC-001", "test-user", null)); + } +} diff --git a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java new file mode 100644 index 0000000..6dd70db --- /dev/null +++ b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java @@ -0,0 +1,35 @@ +package ru.practicum.accounts.service.contract; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +@Configuration +@Profile("contract-test") +public class JwtTestConfig { + + @Bean + @Primary + public JwtDecoder jwtDecoder() { + return token -> { + Instant now = Instant.now(); + + return Jwt.withTokenValue(token) + .header("alg", "none") + .subject("contract-test") + .claim("realm_access", Map.of( + "roles", List.of("SERVICE") + )) + .issuedAt(now) + .expiresAt(now.plusSeconds(3600)) + .build(); + }; + } +} diff --git a/accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy b/accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy new file mode 100644 index 0000000..822b49a --- /dev/null +++ b/accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy @@ -0,0 +1,26 @@ +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + description 'Get owner of account ACC-001' + name 'get_owner_by_id' + + request { + method GET() + url '/accounts/ACC-001/owner' + headers { + contentType(applicationJson()) + header 'Authorization', 'Bearer test-token' + } + } + + response { + status OK() + headers { + contentType(applicationJson()) + } + body( + accountId: 'ACC-001', + ownerUsername: 'test-user' + ) + } +} From b28b512211d4468b28de0b95959705d2ffe09d9d Mon Sep 17 00:00:00 2001 From: Anastasiia Fomkina Date: Sat, 29 Nov 2025 18:12:28 -0300 Subject: [PATCH 2/4] Add producer contract test --- accounts-service/build.gradle.kts | 22 ++++++++++++-- .../contract/BaseAccountsContractTest.java | 16 ++-------- .../service/contract/JwtTestConfig.java | 8 ++--- ...wnerById.groovy => get_owner_by_id.groovy} | 8 +++-- bank-ui/build.gradle.kts | 2 ++ build.gradle.kts | 14 --------- gradle/libs.versions.toml | 27 ++++++++++------- transfer-service/build.gradle.kts | 3 ++ .../contract/AccountsClientContractTest.java | 30 +++++++++++++++++++ .../resources/application-contract-test.yml | 7 +++++ 10 files changed, 91 insertions(+), 46 deletions(-) rename accounts-service/src/contractTest/resources/contracts/accounts/{getOwnerById.groovy => get_owner_by_id.groovy} (57%) create mode 100644 transfer-service/src/test/java/ru/practicum/transfer/service/contract/AccountsClientContractTest.java create mode 100644 transfer-service/src/test/resources/application-contract-test.yml diff --git a/accounts-service/build.gradle.kts b/accounts-service/build.gradle.kts index d1defbd..9c8f0a5 100644 --- a/accounts-service/build.gradle.kts +++ b/accounts-service/build.gradle.kts @@ -1,5 +1,6 @@ plugins { - id("org.springframework.cloud.contract") version "4.3.0" + alias(libs.plugins.spring.cloud.contract) + `maven-publish` } dependencies { @@ -9,8 +10,10 @@ dependencies { implementation(libs.spring.boot.starter.oauth2.resource.server) implementation(libs.spring.boot.starter.actuator) - testImplementation("org.springframework.cloud:spring-cloud-starter-contract-verifier:4.3.0") - testImplementation ("org.springframework.security:spring-security-test") + + testImplementation(libs.spring.cloud.starter.contract.verifier) + testImplementation(libs.spring.security.test) + testImplementation(libs.spring.boot.starter.test) } contracts { @@ -19,3 +22,16 @@ contracts { ) } +publishing { + publications { + create("mavenJava") { + // ЯВНО фиксируем координаты + groupId = "ru.practicum" + artifactId = "accounts-service" + version = "0.0.1-SNAPSHOT" + + from(components["java"]) + artifact(tasks.named("verifierStubsJar")) + } + } +} \ No newline at end of file diff --git a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java index 96a57cf..5a66809 100644 --- a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java +++ b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java @@ -1,23 +1,13 @@ package ru.practicum.accounts.service.contract; -import java.time.Instant; -import java.util.List; -import java.util.Map; - +import io.restassured.module.mockmvc.RestAssuredMockMvc; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; - -import io.restassured.module.mockmvc.RestAssuredMockMvc; import ru.practicum.accounts.service.model.Account; import ru.practicum.accounts.service.service.AccountService; @@ -31,7 +21,7 @@ public abstract class BaseAccountsContractTest { @Autowired protected MockMvc mockMvc; - @MockBean + @MockitoBean protected AccountService accountService; @BeforeEach diff --git a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java index 6dd70db..2f783b2 100644 --- a/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java +++ b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/JwtTestConfig.java @@ -1,9 +1,5 @@ package ru.practicum.accounts.service.contract; -import java.time.Instant; -import java.util.List; -import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; @@ -11,6 +7,10 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; +import java.time.Instant; +import java.util.List; +import java.util.Map; + @Configuration @Profile("contract-test") public class JwtTestConfig { diff --git a/accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy similarity index 57% rename from accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy rename to accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy index 822b49a..01d64ed 100644 --- a/accounts-service/src/contractTest/resources/contracts/accounts/getOwnerById.groovy +++ b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy @@ -1,3 +1,5 @@ +package contracts.accounts + import org.springframework.cloud.contract.spec.Contract Contract.make { @@ -8,8 +10,10 @@ Contract.make { method GET() url '/accounts/ACC-001/owner' headers { - contentType(applicationJson()) - header 'Authorization', 'Bearer test-token' + header 'Authorization', value( + consumer(regex('Bearer\\s+.+')), // для консьюмера (WireMock): любой Bearer-токен + producer('Bearer test-token') // для провайдера (MockMvc-тест): ровно этот токен + ) } } diff --git a/bank-ui/build.gradle.kts b/bank-ui/build.gradle.kts index 91a7056..e82752e 100644 --- a/bank-ui/build.gradle.kts +++ b/bank-ui/build.gradle.kts @@ -6,4 +6,6 @@ dependencies { implementation(libs.spring.boot.starter.oauth2.client) implementation(libs.spring.boot.starter.webflux) + + testImplementation(libs.spring.boot.starter.test) } diff --git a/build.gradle.kts b/build.gradle.kts index 2e51080..e24a57f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,6 @@ -import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension - plugins { java alias(libs.plugins.spring.boot) apply false - alias(libs.plugins.spring.dependency.management) apply false } group = "ru.practicum" @@ -18,7 +15,6 @@ allprojects { subprojects { apply(plugin = "java") apply(plugin = "org.springframework.boot") - apply(plugin = "io.spring.dependency-management") java { toolchain { @@ -26,17 +22,7 @@ subprojects { } } - dependencies { - testImplementation("org.springframework.boot:spring-boot-starter-test") - } - tasks.test { useJUnitPlatform() } - - configure { - imports { - mavenBom("org.springframework.cloud:spring-cloud-dependencies:2025.0.0") - } - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac22635..d9bb580 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,25 @@ [versions] spring-boot = "3.5.8" -spring-dep-mgmt = "1.1.7" +contract-verifier = "4.3.0" +spring-security = "6.4.2" [libraries] -spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } -spring-boot-starter-thymeleaf = { module = "org.springframework.boot:spring-boot-starter-thymeleaf" } -spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } -spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } -spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" } -spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux" } -spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } +spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } +spring-boot-starter-thymeleaf = { module = "org.springframework.boot:spring-boot-starter-thymeleaf", version.ref = "spring-boot" } +spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "spring-boot" } +spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client", version.ref = "spring-boot" } +spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "spring-boot" } +spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot" } +spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot" } -spring-cloud-starter-gateway-server-webflux = { module = "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux" } +spring-cloud-starter-gateway-server-webflux = { module = "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux", version.ref = "contract-verifier" } + +spring-cloud-starter-contract-verifier = { module = "org.springframework.cloud:spring-cloud-starter-contract-verifier", version.ref = "contract-verifier" } +spring-cloud-starter-contract-stub-runner = { module = "org.springframework.cloud:spring-cloud-starter-contract-stub-runner", version.ref = "contract-verifier" } + +spring-security-test = { module = "org.springframework.security:spring-security-test", version.ref = "spring-security" } +spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } -spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dep-mgmt" } +spring-cloud-contract = {id = "org.springframework.cloud.contract", version.ref = "contract-verifier"} diff --git a/transfer-service/build.gradle.kts b/transfer-service/build.gradle.kts index 62fd316..d91ff57 100644 --- a/transfer-service/build.gradle.kts +++ b/transfer-service/build.gradle.kts @@ -7,4 +7,7 @@ dependencies { implementation(libs.spring.boot.starter.webflux) implementation(libs.spring.boot.starter.actuator) + + testImplementation(libs.spring.cloud.starter.contract.stub.runner) + testImplementation(libs.spring.boot.starter.test) } diff --git a/transfer-service/src/test/java/ru/practicum/transfer/service/contract/AccountsClientContractTest.java b/transfer-service/src/test/java/ru/practicum/transfer/service/contract/AccountsClientContractTest.java new file mode 100644 index 0000000..4cf7b83 --- /dev/null +++ b/transfer-service/src/test/java/ru/practicum/transfer/service/contract/AccountsClientContractTest.java @@ -0,0 +1,30 @@ +package ru.practicum.transfer.service.contract; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; +import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties; +import org.springframework.test.context.ActiveProfiles; +import ru.practicum.transfer.service.client.AccountsClient; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("contract-test") +@AutoConfigureStubRunner( + ids = "ru.practicum:accounts-service:+:stubs:8085", + stubsMode = StubRunnerProperties.StubsMode.LOCAL +) +class AccountsClientContractTest { + + @Autowired + private AccountsClient accountsClient; + + @Test + void shouldMatchContractWhenCheckingOwner() { + boolean result = accountsClient.isOwner("ACC-001", "test-user"); + + assertTrue(result); + } +} \ No newline at end of file diff --git a/transfer-service/src/test/resources/application-contract-test.yml b/transfer-service/src/test/resources/application-contract-test.yml new file mode 100644 index 0000000..692a03c --- /dev/null +++ b/transfer-service/src/test/resources/application-contract-test.yml @@ -0,0 +1,7 @@ +spring: + main: + allow-bean-definition-overriding: true + +bank: + accounts-service: + base-url: http://localhost:8085 \ No newline at end of file From 12d6b193fe31c16e8e10fec0aac1e8d74ea3b5d9 Mon Sep 17 00:00:00 2001 From: Anastasiia Fomkina Date: Sat, 29 Nov 2025 18:21:48 -0300 Subject: [PATCH 3/4] Clean up code --- .../resources/contracts/accounts/get_owner_by_id.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy index 01d64ed..27860ac 100644 --- a/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy +++ b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy @@ -11,8 +11,8 @@ Contract.make { url '/accounts/ACC-001/owner' headers { header 'Authorization', value( - consumer(regex('Bearer\\s+.+')), // для консьюмера (WireMock): любой Bearer-токен - producer('Bearer test-token') // для провайдера (MockMvc-тест): ровно этот токен + consumer(regex('Bearer\\s+.+')), // для консьюмера (WireMock): любой Bearer-токен + producer('Bearer test-token') // для провайдера (MockMvc-тест): ровно этот токен ) } } From 188e179a3f47e58ee2dd04ca61fa63ee7ca629c3 Mon Sep 17 00:00:00 2001 From: Anastasiia Fomkina Date: Sun, 30 Nov 2025 19:21:37 -0300 Subject: [PATCH 4/4] Update README.md --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/README.md b/README.md index b2a2258..e5d4215 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,86 @@ http://localhost:8084 - На UI отображается: **«Перевод выполнен...»** + +--- + +# Контрактные тесты между accounts-service и transfer-service + +В проекте настроены контрактные тесты Spring Cloud Contract для взаимодействия между сервисами **accounts-service** (провайдер API) и **transfer-service** (клиент этого API). + +## Продюсер: accounts-service + +Для сервиса `accounts-service` контракты описаны в `src/contractTest/resources/contracts/accounts`. + +Пример контракта для эндпоинта `GET /accounts/ACC-001/owner`: + +```groovy +Contract.make { + description 'Get owner of account ACC-001' + name 'get_owner_by_id' + + request { + method GET() + url '/accounts/ACC-001/owner' + headers { + header 'Authorization', value( + consumer(regex('Bearer\s+.+')), + producer('Bearer test-token') + ) + } + } + + response { + status OK() + headers { + contentType(applicationJson()) + } + body( + accountId: 'ACC-001', + ownerUsername: 'test-user' + ) + } +} +``` + +Базовый тест `BaseAccountsContractTest` поднимает Spring Boot‑контекст, настраивает `MockMvc` и мок `AccountService`, чтобы контрактные тесты можно было прогонять без подключения к реальной базе данных. +Через отдельную конфигурацию `JwtTestConfig` переопределяется `JwtDecoder`, чтобы сервис принимал тестовый JWT с ролью `SERVICE`. + +При сборке модуля `accounts-service`: + +```bash +./gradlew :accounts-service:clean :accounts-service:build +``` + +Spring Cloud Contract: + +- генерирует тесты по контрактам; +- выполняет их на стороне провайдера; +- собирает jar со стабами с classifier `stubs` (артефакт `ru.practicum:accounts-service:…:stubs`). + +Этот jar со стабами необходимо выложить в Maven‑репозиторий. + +## Консьюмер: transfer-service + +В модуле `transfer-service` для проверки клиента `AccountsClient` используется тест `AccountsClientContractTest`. + +В нем `StubRunner` поднимает локальный HTTP‑сервер на порту `8085` и отвечает по контрактам, загруженным из jar‑файла стабов `accounts-service`. +Тест вызывает реальный `AccountsClient` и проверяет, что он правильно формирует запрос к `accounts-service` и корректно обрабатывает ответ. + +Важно: чтобы этот тест прошёл, jar со стабами `accounts-service` должен быть доступен в локальном Maven‑репозитории, откуда его заберёт Stub Runner. + +## Как запустить контрактные тесты целиком + +1. Собрать и опубликовать стабы `accounts-service` в локальный Maven‑репозиторий: + + ```bash + ./gradlew :accounts-service:clean :accounts-service:build :accounts-service:publishToMavenLocal + ``` + + (команда `publishToMavenLocal` предполагает, что в проекте настроен `maven-publish` и публикация стабов в `mavenLocal()` включена в конфигурацию Spring Cloud Contract). + +2. Запустить контрактные тесты клиента в `transfer-service`: + + ```bash + ./gradlew :transfer-service:test --tests '*AccountsClientContractTest' + ``` \ No newline at end of file