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
+53
View File
@@ -54,12 +54,48 @@
<artifactId>spring-cloud-starter-vault-config</artifactId> <artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency> </dependency>
<!-- Migrations module — нужен на classpath для миграций при тесте,
в проде Liquibase запускается отдельным Job в k8s. -->
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-migrations</artifactId>
<scope>test</scope>
</dependency>
<!-- Test --> <!-- Test -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.21.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<version>1.21.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -68,6 +104,23 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Testcontainers требует DOCKER_HOST. На macOS Docker Desktop
socket в ~/.docker/run, не /var/run. ENV не наследуется в
forked surefire процесс по умолчанию — пробрасываем явно. -->
<environmentVariables>
<DOCKER_HOST>${env.DOCKER_HOST}</DOCKER_HOST>
<DOCKER_API_VERSION>1.45</DOCKER_API_VERSION>
<TESTCONTAINERS_RYUK_DISABLED>true</TESTCONTAINERS_RYUK_DISABLED>
</environmentVariables>
<systemPropertyVariables>
<api.version>1.45</api.version>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>
@@ -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;
@@ -71,7 +71,7 @@ public class AuditLog {
@Column(name = "request_id", length = 64) @Column(name = "request_id", length = 64)
private String requestId; private String requestId;
@Column(name = "ip_address", columnDefinition = "inet") @Column(name = "ip_address", length = 64)
private String ipAddress; private String ipAddress;
@Column(name = "user_agent", columnDefinition = "TEXT") @Column(name = "user_agent", columnDefinition = "TEXT")
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
Hibernate JPA не делает автоматический cast String→INET при INSERT.
Audit_log.ip_address был INET (см. 0004) — все INSERT'ы падают с
"column is of type inet but expression is of type character varying".
AuditLogger обёрнут в try/catch, поэтому не валит сервис, но audit
тихо не пишется — обнаружено e2e smoke тестом.
INET semantics (range queries, validation) для нас не критичны.
Меняем на VARCHAR(64) — IPv6 максимум 45 символов, запас на всякий.
-->
<changeSet id="0012-1-audit-log-ipaddress-varchar" author="ordinis">
<preConditions onFail="MARK_RAN">
<columnExists tableName="audit_log" columnName="ip_address"/>
</preConditions>
<sql>
ALTER TABLE audit_log
ALTER COLUMN ip_address TYPE VARCHAR(64) USING ip_address::TEXT;
</sql>
<rollback>
<sql>
ALTER TABLE audit_log
ALTER COLUMN ip_address TYPE INET USING ip_address::INET;
</sql>
</rollback>
</changeSet>
</databaseChangeLog>
@@ -5,16 +5,22 @@
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<include file="changes/0001-extensions.xml"/> <!--
<include file="changes/0002-dictionary-definitions.xml"/> relativeToChangelogFile=true чтобы пути ресолвились относительно master.xml,
<include file="changes/0003-dictionary-records.xml"/> а не от Liquibase searchPath. Иначе при подключении из ordinis-app
<include file="changes/0004-audit-log.xml"/> (тесты) Liquibase не находит changes/ внутри JAR.
<include file="changes/0005-outbox-events.xml"/> -->
<include file="changes/0006-idempotency-keys.xml"/> <include file="changes/0001-extensions.xml" relativeToChangelogFile="true"/>
<include file="changes/0007-add-version-columns.xml"/> <include file="changes/0002-dictionary-definitions.xml" relativeToChangelogFile="true"/>
<include file="changes/0008-fix-scope-check-uppercase.xml"/> <include file="changes/0003-dictionary-records.xml" relativeToChangelogFile="true"/>
<include file="changes/0009-fix-records-scope-check.xml"/> <include file="changes/0004-audit-log.xml" relativeToChangelogFile="true"/>
<include file="changes/0010-envers-tables.xml"/> <include file="changes/0005-outbox-events.xml" relativeToChangelogFile="true"/>
<include file="changes/0011-outbox-dlq.xml"/> <include file="changes/0006-idempotency-keys.xml" relativeToChangelogFile="true"/>
<include file="changes/0007-add-version-columns.xml" relativeToChangelogFile="true"/>
<include file="changes/0008-fix-scope-check-uppercase.xml" relativeToChangelogFile="true"/>
<include file="changes/0009-fix-records-scope-check.xml" relativeToChangelogFile="true"/>
<include file="changes/0010-envers-tables.xml" relativeToChangelogFile="true"/>
<include file="changes/0011-outbox-dlq.xml" relativeToChangelogFile="true"/>
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
+5
View File
@@ -94,6 +94,11 @@
<artifactId>ordinis-cuod-bundle</artifactId> <artifactId>ordinis-cuod-bundle</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-migrations</artifactId>
<version>${project.version}</version>
</dependency>
<!-- External --> <!-- External -->
<dependency> <dependency>