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:
@@ -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;
|
||||
Reference in New Issue
Block a user