test(e2e): smoke test через Testcontainers + fix audit_log.ip_address INET bug

Phase 1 e2e — proof framework работает:
- E2ESupport: Postgres 18 (postgis/postgis:18-3.6) + Kafka (cp-kafka 7.6.0)
  через Testcontainers, reuse=true. db-init.sql создаёт postgis+btree_gist
  расширения которые ожидает Liquibase 0001.
- application-test.yml: тушит cloud-config/vault/otel/outbox publishing,
  включает Liquibase autoconfig. Auth disabled для упрощения.
- SmokeE2ETest: full e2e — POST dictionary → POST record → GET record →
  проверка audit_log row. UUID-suffix в dictName для идемпотентности.
- master.xml: relativeToChangelogFile=true для всех include — иначе
  Liquibase из ordinis-app classpath не находит changes/X.xml в JAR.
- pom.xml: testcontainers 1.21.3, surefire env DOCKER_HOST + DOCKER_API_VERSION
  для macOS Docker Desktop.

Найден реальный prod-баг: audit_log.ip_address был INET, Hibernate JPA не
делает автоматический cast String→INET → INSERT падал. AuditLogger.write
обёрнут в try/catch, поэтому тихо съедал ошибку — на staging audit_log
оставался пустым после CRUD.
- 0012-audit-log-ipaddress-varchar.xml: ALTER COLUMN INET → VARCHAR(64)
- AuditLog entity: убрал columnDefinition="inet", length=64.
This commit is contained in:
Zimin A.N.
2026-05-05 19:16:53 +03:00
parent b58f6f400d
commit f535f7544d
9 changed files with 364 additions and 12 deletions
@@ -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.
*
* <p>Использовать через 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);
}
}
@@ -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} возвращает её обратно
*
* <p>Этап 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");
}
}
@@ -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
@@ -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;