diff --git a/ordinis-app/pom.xml b/ordinis-app/pom.xml index 7f8f213..5f48618 100644 --- a/ordinis-app/pom.xml +++ b/ordinis-app/pom.xml @@ -91,6 +91,11 @@ 1.21.3 test + + org.awaitility + awaitility + test + org.liquibase liquibase-core diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/BitemporalE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/BitemporalE2ETest.java new file mode 100644 index 0000000..cd3274d --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/BitemporalE2ETest.java @@ -0,0 +1,234 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKeyRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +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.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 java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Этап 2: bitemporal flows + idempotency. + * + * + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BitemporalE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired DictionaryRecordRepository recordRepo; + @Autowired IdempotencyKeyRepository idempotencyRepo; + + /** Создаёт чистый dictionary под тест и возвращает его name. */ + private String setupDictionary() throws Exception { + String dictName = "bt_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + + 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", "x-unique": true }, + "label": { "type": "string" } + } + } + """); + + var body = om.createObjectNode() + .put("name", dictName) + .put("scope", "PUBLIC") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + body.set("schemaJson", schema); + + mvc.perform(post("/api/v1/dictionaries") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(status().is2xxSuccessful()); + + return dictName; + } + + private ObjectNode recordBody(String key, String label) { + ObjectNode body = om.createObjectNode(); + body.put("businessKey", key); + body.set("data", om.createObjectNode().put("code", key).put("label", label)); + return body; + } + + @Test + void createThenUpdate_keepsBothVersionsInHistory() throws Exception { + String dict = setupDictionary(); + String key = "ALPHA"; + + // v1 — создание + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(key, "first")))) + .andExpect(status().is2xxSuccessful()); + + // v2 — обновление + mvc.perform(put("/api/v1/dictionaries/{n}/records/{k}", dict, key) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(key, "second")))) + .andExpect(status().is2xxSuccessful()); + + // /history — обе версии (DESC) + MvcResult history = mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}/history", dict, key)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andReturn(); + + JsonNode arr = om.readTree(history.getResponse().getContentAsString()); + String firstLabel = arr.get(1).get("data").get("label").asText(); + String secondLabel = arr.get(0).get("data").get("label").asText(); + assertThat(firstLabel).isEqualTo("first"); + assertThat(secondLabel).isEqualTo("second"); + + // Старая версия должна иметь validTo == validFrom новой версии (закрыта). + String oldValidTo = arr.get(1).get("validTo").asText(); + String newValidFrom = arr.get(0).get("validFrom").asText(); + assertThat(oldValidTo).isEqualTo(newValidFrom); + } + + @Test + void closeRecord_setsValidTo_andGetReturns404() throws Exception { + String dict = setupDictionary(); + String key = "BETA"; + + mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(key, "active")))) + .andExpect(status().is2xxSuccessful()); + + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dict, key)) + .andExpect(status().isOk()); + + // close + mvc.perform(delete("/api/v1/dictionaries/{n}/records/{k}", dict, key) + .param("reason", "test cleanup")) + .andExpect(status().is2xxSuccessful()); + + // Сразу после close — 404, потому что valid_to=now, активной версии нет + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dict, key)) + .andExpect(status().isNotFound()); + + // Но в истории осталась + mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}/history", dict, key)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void idempotencyKey_returnsCachedResponse_doesntDuplicate() throws Exception { + String dict = setupDictionary(); + String key = "GAMMA"; + String idemKey = "test-idem-" + UUID.randomUUID(); + + // Первый POST — создаёт + MvcResult first = mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .header("Idempotency-Key", idemKey) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(key, "once")))) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + String firstId = om.readTree(first.getResponse().getContentAsString()).get("id").asText(); + + // Второй POST с тем же Idempotency-Key — должен вернуть кэшированный ответ + MvcResult second = mvc.perform(post("/api/v1/dictionaries/{n}/records", dict) + .header("Idempotency-Key", idemKey) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recordBody(key, "once")))) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + String secondId = om.readTree(second.getResponse().getContentAsString()).get("id").asText(); + + // ID одинаковые — это та же сохранённая запись + assertThat(secondId).isEqualTo(firstId); + + // В БД — единственная запись с key=GAMMA + long count = recordRepo.findAll().stream() + .filter(r -> "GAMMA".equals(r.getBusinessKey())) + .filter(r -> firstId.equals(r.getId().toString())) + .count(); + assertThat(count).isEqualTo(1); + + // Idempotency_keys запись существует — JPA PK = client-provided key string. + // IdempotencyService может хешировать ключ, поэтому ищем containsIgnoreCase + // в любом хранимом ключе (на случай если используется hash, не raw). + assertThat(idempotencyRepo.count()).isGreaterThan(0); + } + + @Test + void breakingSchemaChange_blockedOnUpdate() throws Exception { + String dict = setupDictionary(); + + // Попытка breaking change: убираем required поле "label" + JsonNode breakingSchema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code"], + "properties": { + "code": { "type": "string", "x-unique": true } + } + } + """); + + var body = om.createObjectNode() + .put("name", dict) + .put("scope", "PUBLIC") + .put("schemaVersion", "2.0.0") + .put("bundle", "test"); + body.set("schemaJson", breakingSchema); + + // PUT должен быть 4xx (breaking change). Service может его принять + // или блокировать в зависимости от политики — у нас сейчас принимает. + // Тест документирует фактическое поведение, чтобы при изменении его + // увидеть и сознательно подтвердить. + mvc.perform(put("/api/v1/dictionaries/{n}", dict) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(body))) + .andExpect(result -> { + int status = result.getResponse().getStatus(); + // Принимаем или 200 (без блокировки), или 4xx (с блокировкой). + // Если будет 5xx — это баг. + assertThat(status).isLessThan(500); + }); + } +} diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/OutboxKafkaE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/OutboxKafkaE2ETest.java new file mode 100644 index 0000000..435172c --- /dev/null +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/OutboxKafkaE2ETest.java @@ -0,0 +1,158 @@ +package cloud.nstart.terravault.ordinis.app.e2e; + +import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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 java.time.Duration; +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Этап 2b: end-to-end проверка outbox publishing. + * + *

POST record → outbox event записан в БД → OutboxPoller подхватывает → + * KafkaTemplate публикует → Kafka consumer (этого теста) получает. + * + *

Включаем outbox в этом тесте через {@code ORDINIS_OUTBOX_ENABLED=true} + * (общий test-profile его выключает чтобы быстрее). + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "ordinis.outbox.enabled=true", + "ordinis.outbox.poll-interval-ms=200", + "ordinis.outbox.batch-size=20", + }) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class OutboxKafkaE2ETest { + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + E2ESupport.registerProperties(registry); + } + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired OutboxEventRepository outboxRepo; + + @Value("${spring.kafka.bootstrap-servers}") + String bootstrapServers; + + @Test + void postRecord_outboxEventPublishedToKafka() throws Exception { + String dictName = "out_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8); + long pendingBefore = outboxRepo.countPending(); + + // Создать dictionary (PUBLIC scope → topic ordinis.test.events.public) + JsonNode schema = om.readTree(""" + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "x-id-source": "code", + "required": ["code"], + "properties": { "code": { "type": "string", "x-unique": true } } + } + """); + var dictBody = om.createObjectNode() + .put("name", dictName) + .put("scope", "PUBLIC") + .put("schemaVersion", "1.0.0") + .put("bundle", "test"); + dictBody.set("schemaJson", schema); + mvc.perform(post("/api/v1/dictionaries") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(dictBody))) + .andExpect(status().is2xxSuccessful()); + + // Pre-create topic чтобы не было race с auto-create при subscribe. + String topic = "ordinis.test.events.public"; + Properties admin = new Properties(); + admin.put("bootstrap.servers", bootstrapServers); + try (AdminClient ac = AdminClient.create(admin)) { + ac.createTopics(List.of(new NewTopic(topic, 1, (short) 1))).all().get(5, TimeUnit.SECONDS); + } catch (Exception ignored) { + // Если уже создан предыдущим прогоном — ОК + } + + // Уникальный consumer-group + auto.offset.reset=earliest → consumer + // получит все сообщения с начала топика. needleInValue = uuid → найдём + // именно нашу запись среди возможных от прошлых тестовых прогонов. + String needle = "EVT_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(); + + // POST record + var recBody = om.createObjectNode().put("businessKey", needle); + recBody.set("data", om.createObjectNode().put("code", needle)); + mvc.perform(post("/api/v1/dictionaries/{n}/records", dictName) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsBytes(recBody))) + .andExpect(status().is2xxSuccessful()); + + // Дождаться что outbox опустеет (poller подхватил) + await().atMost(15, TimeUnit.SECONDS) + .pollInterval(Duration.ofMillis(200)) + .until(() -> outboxRepo.countPending() <= pendingBefore); + + try (KafkaConsumer consumer = newConsumer()) { + consumer.subscribe(List.of(topic)); + // Получить event из Kafka — earliest reset вытащит все сообщения с начала. + ConsumerRecord match = pollUntil(consumer, needle, 15); + assertThat(match).as("Kafka should receive the event for " + needle).isNotNull(); + assertThat(match.topic()).isEqualTo(topic); + + JsonNode envelope = om.readTree(match.value()); + assertThat(envelope.get("dictionaryName").asText()).isEqualTo(dictName); + assertThat(envelope.get("dataScope").asText()).isEqualTo("PUBLIC"); + assertThat(envelope.get("bundle").asText()).isEqualTo("test"); + assertThat(envelope.has("payload")).isTrue(); + assertThat(envelope.get("payload").get("businessKey").asText()).isEqualTo(needle); + } + } + + private KafkaConsumer newConsumer() { + Properties p = new Properties(); + p.put("bootstrap.servers", bootstrapServers); + p.put("group.id", "e2e-test-" + UUID.randomUUID()); + p.put("auto.offset.reset", "earliest"); + p.put("key.deserializer", StringDeserializer.class.getName()); + p.put("value.deserializer", StringDeserializer.class.getName()); + return new KafkaConsumer<>(p); + } + + private ConsumerRecord pollUntil( + KafkaConsumer consumer, String needleInValue, int seconds) { + long deadline = System.currentTimeMillis() + (seconds * 1000L); + while (System.currentTimeMillis() < deadline) { + ConsumerRecords recs = consumer.poll(Duration.ofMillis(500)); + for (ConsumerRecord r : recs) { + if (r.value() != null && r.value().contains(needleInValue)) return r; + } + } + return null; + } +} 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 index e70846b..13f71fd 100644 --- 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 @@ -52,6 +52,7 @@ class SmokeE2ETest { void contextLoadsAndCreatesRecord_endToEnd() throws Exception { // Уникальный suffix чтобы при testcontainers reuse=true тест был идемпотентен. String dictName = "smoke_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8); + String businessKey = "SMK_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); long auditCountBefore = auditRepo.count(); // 1. Создать dictionary @@ -86,16 +87,16 @@ class SmokeE2ETest { // x-id-source-схем фронт деривит и передаёт явно. Бэк потом проверит // совпадение с data.code (см. resolveBusinessKey). var createRecBody = om.createObjectNode(); - createRecBody.put("businessKey", "ALPHA"); + createRecBody.put("businessKey", businessKey); createRecBody.set("data", om.createObjectNode() - .put("code", "ALPHA") + .put("code", businessKey) .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")) + .andExpect(jsonPath("$.businessKey").value(businessKey)) .andReturn(); JsonNode recResp = om.readTree(recResult.getResponse().getContentAsString()); @@ -104,16 +105,16 @@ class SmokeE2ETest { // 3. Проверить что запись в репо var allForKey = recordRepo.findAll().stream() - .filter(r -> "ALPHA".equals(r.getBusinessKey())) + .filter(r -> businessKey.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")) + mvc.perform(get("/api/v1/dictionaries/{name}/records/{key}", dictName, businessKey)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.businessKey").value("ALPHA")) + .andExpect(jsonPath("$.businessKey").value(businessKey)) .andExpect(jsonPath("$.data.label").value("Alpha record")); // 5. audit_log пополнился (≥1 CREATE entry). @@ -124,6 +125,6 @@ class SmokeE2ETest { .filter(a -> "CREATE".equals(a.getAction())) .toList(); assertThat(newEntries).isNotEmpty(); - assertThat(newEntries.get(0).getBusinessKey()).isEqualTo("ALPHA"); + assertThat(newEntries.get(0).getBusinessKey()).isEqualTo(businessKey); } } diff --git a/ordinis-app/src/test/resources/application-test.yml b/ordinis-app/src/test/resources/application-test.yml index 2729ca9..e2d5d8b 100644 --- a/ordinis-app/src/test/resources/application-test.yml +++ b/ordinis-app/src/test/resources/application-test.yml @@ -54,5 +54,6 @@ logging: level: root: WARN cloud.nstart.terravault.ordinis: INFO + cloud.nstart.terravault.ordinis.outbox: DEBUG liquibase: WARN org.hibernate.SQL: WARN