diff --git a/ordinis-app/pom.xml b/ordinis-app/pom.xml index 1382792..7f8f213 100644 --- a/ordinis-app/pom.xml +++ b/ordinis-app/pom.xml @@ -54,12 +54,48 @@ spring-cloud-starter-vault-config + + + cloud.nstart.terravault.ordinis + ordinis-migrations + test + + org.springframework.boot spring-boot-starter-test test + + org.springframework.kafka + spring-kafka-test + test + + + org.testcontainers + junit-jupiter + 1.21.3 + test + + + org.testcontainers + postgresql + 1.21.3 + test + + + org.testcontainers + kafka + 1.21.3 + test + + + org.liquibase + liquibase-core + test + @@ -68,6 +104,23 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + + ${env.DOCKER_HOST} + 1.45 + true + + + 1.45 + + + diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java new file mode 100644 index 0000000..8029f05 --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/E2ESupport.java @@ -0,0 +1,60 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Общий setup Testcontainers для e2e тестов: Postgres 18 + PostGIS-совместимый + * образ + Kafka 7.x. Контейнеры стартуют один раз (static), переиспользуются + * между тестовыми классами через {@link org.testcontainers.junit.jupiter.Container} + * shared mode. + * + *

Использовать через extends либо composition в @SpringBootTest классах. + */ +public final class E2ESupport { + + // Используем postgis image — у нас есть geometry колонки в dictionary_records. + // db-init.sql создаёт postgis + btree_gist (Liquibase 0001 проверяет их). + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>( + DockerImageName.parse("postgis/postgis:18-3.6") + .asCompatibleSubstituteFor("postgres")) + .withDatabaseName("ordinis_test") + .withUsername("ordinis_test") + .withPassword("test_password") + .withInitScript("db-init.sql") + .withReuse(true); + + // Confluent Kafka — confluentinc/cp-kafka работает out-of-the-box. + // Confluent 7.6.0 включает в Kafka 3.6 — kraft mode. + @SuppressWarnings("resource") + static final KafkaContainer KAFKA = new KafkaContainer( + DockerImageName.parse("confluentinc/cp-kafka:7.6.0")) + .withReuse(true); + + static { + POSTGRES.start(); + KAFKA.start(); + } + + private E2ESupport() {} + + /** + * Прокидывает DB + Kafka URLs в Spring Boot context. Вызывать из + * {@code @DynamicPropertySource}. + */ + public static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + + registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); + + // Liquibase использует тот же datasource — отдельные креды не нужны. + registry.add("spring.liquibase.url", POSTGRES::getJdbcUrl); + registry.add("spring.liquibase.user", POSTGRES::getUsername); + registry.add("spring.liquibase.password", POSTGRES::getPassword); + } +} diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/SmokeE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/SmokeE2ETest.java new file mode 100644 index 0000000..e70846b --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/SmokeE2ETest.java @@ -0,0 +1,129 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import cloud.nstart.terravault.ordinis.domain.audit.AuditLogRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Smoke e2e: Spring Boot context поднимается с реальным Postgres + Kafka + * (Testcontainers), создаём dictionary через POST /api/v1/dictionaries, + * добавляем record через POST .../records, проверяем что: + * - Запись персистнулась в Postgres (через repo) + * - audit_log получил CREATE-entry + * - GET /api/v1/dictionaries/{name}/records/{key} возвращает её обратно + * + *

Этап 1 покрытия. Bitemporal flows / idempotency / outbox→Kafka — Этап 2. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SmokeE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired DictionaryRecordRepository recordRepo; + @Autowired AuditLogRepository auditRepo; + + @Test + void contextLoadsAndCreatesRecord_endToEnd() throws Exception { + // Уникальный suffix чтобы при testcontainers reuse=true тест был идемпотентен. + String dictName = "smoke_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8); + long auditCountBefore = auditRepo.count(); + + // 1. Создать dictionary + JsonNode schema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code", "label"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,31}$", "x-unique": true }, + "label": { "type": "string", "minLength": 1 } + } + } + """); + + var createDictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "PUBLIC") + .put("displayName", "Smoke test dict") + .put("schemaVersion", "1.0.0") + .put("bundle", "test") + .set("schemaJson", schema); + + mvc.perform(post("/api/v1/dictionaries") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(createDictBody))) + .andExpect(status().is2xxSuccessful()); + + // 2. Создать record. CreateRecordRequest.businessKey @NotBlank — для + // x-id-source-схем фронт деривит и передаёт явно. Бэк потом проверит + // совпадение с data.code (см. resolveBusinessKey). + var createRecBody = om.createObjectNode(); + createRecBody.put("businessKey", "ALPHA"); + createRecBody.set("data", om.createObjectNode() + .put("code", "ALPHA") + .put("label", "Alpha record")); + + MvcResult recResult = mvc.perform(post("/api/v1/dictionaries/{name}/records", dictName) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(createRecBody))) + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$.businessKey").value("ALPHA")) + .andReturn(); + + JsonNode recResp = om.readTree(recResult.getResponse().getContentAsString()); + String recordId = recResp.get("id").asText(); + assertThat(recordId).isNotBlank(); + + // 3. Проверить что запись в репо + var allForKey = recordRepo.findAll().stream() + .filter(r -> "ALPHA".equals(r.getBusinessKey())) + .toList(); + assertThat(allForKey).hasSize(1); + assertThat(allForKey.get(0).getDataScope().name()).isEqualTo("PUBLIC"); + + // 4. GET через writer (read-api контроллер живёт в отдельном модуле, + // здесь admin-write путь) + mvc.perform(get("/api/v1/dictionaries/{name}/records/{key}", dictName, "ALPHA")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.businessKey").value("ALPHA")) + .andExpect(jsonPath("$.data.label").value("Alpha record")); + + // 5. audit_log пополнился (≥1 CREATE entry). + long auditCountAfter = auditRepo.count(); + assertThat(auditCountAfter).isGreaterThan(auditCountBefore); + var newEntries = auditRepo.findAll().stream() + .filter(a -> dictName.equals(a.getDictionaryName())) + .filter(a -> "CREATE".equals(a.getAction())) + .toList(); + assertThat(newEntries).isNotEmpty(); + assertThat(newEntries.get(0).getBusinessKey()).isEqualTo("ALPHA"); + } +} diff --git a/ordinis-app/src/test/resources/application-test.yml b/ordinis-app/src/test/resources/application-test.yml new file mode 100644 index 0000000..2729ca9 --- /dev/null +++ b/ordinis-app/src/test/resources/application-test.yml @@ -0,0 +1,58 @@ +# Профиль для @SpringBootTest e2e тестов. Тушит весь cloud-config / vault / +# OTel / outbox publishing — оставляем чистый Spring Boot + Postgres + Kafka +# Testcontainers. + +spring: + cloud: + config: + enabled: false + vault: + enabled: false + autoconfigure: + exclude: + - org.springframework.cloud.vault.config.VaultAutoConfiguration + - org.springframework.cloud.vault.config.VaultObservationAutoConfiguration + - org.springframework.cloud.vault.config.VaultReactiveAutoConfiguration + jpa: + hibernate: + ddl-auto: none + properties: + hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect + hibernate.jdbc.lob.non_contextual_creation: true + open-in-view: false + liquibase: + change-log: db/changelog/master.xml + enabled: true + kafka: + producer: + acks: all + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + +ordinis: + auth: + issuer-uri: "" + require-authentication: false + allow-query-scope: true + outbox: + enabled: ${ORDINIS_OUTBOX_ENABLED:false} + bundle: + cuod: + auto-import: ${ORDINIS_BUNDLE_AUTOIMPORT:false} + +otel: + sdk: + disabled: true + +management: + metrics: + export: + prometheus: + enabled: false + +logging: + level: + root: WARN + cloud.nstart.terravault.ordinis: INFO + liquibase: WARN + org.hibernate.SQL: WARN diff --git a/ordinis-app/src/test/resources/db-init.sql b/ordinis-app/src/test/resources/db-init.sql new file mode 100644 index 0000000..3b42d1f --- /dev/null +++ b/ordinis-app/src/test/resources/db-init.sql @@ -0,0 +1,6 @@ +-- Init SQL для testcontainer Postgres. Liquibase 0001-extensions.xml +-- ожидает postgis + btree_gist уже созданными (в прод-кластере CNPG initdb +-- их создаёт). В тестах создаём вручную. + +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS btree_gist; diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java index 63b1c75..8e2bff2 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/audit/AuditLog.java @@ -71,7 +71,7 @@ public class AuditLog { @Column(name = "request_id", length = 64) private String requestId; - @Column(name = "ip_address", columnDefinition = "inet") + @Column(name = "ip_address", length = 64) private String ipAddress; @Column(name = "user_agent", columnDefinition = "TEXT") diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0012-audit-log-ipaddress-varchar.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0012-audit-log-ipaddress-varchar.xml new file mode 100644 index 0000000..328a301 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0012-audit-log-ipaddress-varchar.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + ALTER TABLE audit_log + ALTER COLUMN ip_address TYPE VARCHAR(64) USING ip_address::TEXT; + + + + ALTER TABLE audit_log + ALTER COLUMN ip_address TYPE INET USING ip_address::INET; + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 6bc0982..95289a3 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -5,16 +5,22 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 6aa1686..254dc30 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,11 @@ ordinis-cuod-bundle ${project.version} + + cloud.nstart.terravault.ordinis + ordinis-migrations + ${project.version} +