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 diff --git a/accounts-service/build.gradle.kts b/accounts-service/build.gradle.kts index 2258664..c545859 100644 --- a/accounts-service/build.gradle.kts +++ b/accounts-service/build.gradle.kts @@ -1,3 +1,8 @@ +plugins { + alias(libs.plugins.spring.cloud.contract) + `maven-publish` +} + dependencies { implementation(libs.spring.boot.starter.web) @@ -6,5 +11,26 @@ dependencies { implementation(libs.spring.boot.starter.actuator) + testImplementation(libs.spring.security.test) + testImplementation(libs.spring.cloud.starter.contract.verifier) testImplementation(libs.spring.boot.starter.test) } + +contracts { + baseClassForTests.set( + "ru.practicum.accounts.service.contract.BaseAccountsContractTest" + ) +} + +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 new file mode 100644 index 0000000..5a66809 --- /dev/null +++ b/accounts-service/src/contractTest/java/ru/practicum/accounts/service/contract/BaseAccountsContractTest.java @@ -0,0 +1,34 @@ +package ru.practicum.accounts.service.contract; + +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.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +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; + + @MockitoBean + 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..2f783b2 --- /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 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; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@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/get_owner_by_id.groovy b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy new file mode 100644 index 0000000..27860ac --- /dev/null +++ b/accounts-service/src/contractTest/resources/contracts/accounts/get_owner_by_id.groovy @@ -0,0 +1,30 @@ +package contracts.accounts + +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 { + header 'Authorization', value( + consumer(regex('Bearer\\s+.+')), // для консьюмера (WireMock): любой Bearer-токен + producer('Bearer test-token') // для провайдера (MockMvc-тест): ровно этот токен + ) + } + } + + response { + status OK() + headers { + contentType(applicationJson()) + } + body( + accountId: 'ACC-001', + ownerUsername: 'test-user' + ) + } +} diff --git a/bank-ui/build.gradle.kts b/bank-ui/build.gradle.kts index d08bb80..e82752e 100644 --- a/bank-ui/build.gradle.kts +++ b/bank-ui/build.gradle.kts @@ -8,4 +8,4 @@ dependencies { implementation(libs.spring.boot.starter.webflux) testImplementation(libs.spring.boot.starter.test) -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa90b4a..d9bb580 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,9 @@ spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot- 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" } diff --git a/transfer-service/build.gradle.kts b/transfer-service/build.gradle.kts index 9613f8a..2ce399f 100644 --- a/transfer-service/build.gradle.kts +++ b/transfer-service/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { implementation(libs.spring.boot.starter.actuator) testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.spring.cloud.starter.contract.stub.runner) } 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