From 1e5416486bd64d484a66102582914e45b4261be9 Mon Sep 17 00:00:00 2001 From: Alex Klimenko Date: Wed, 6 Aug 2025 18:14:26 +0200 Subject: [PATCH] test: Add comprehensive edge case and validation tests for core Spring AI builders Signed-off-by: Alex Klimenko --- .../ai/document/DocumentBuilderTests.java | 130 ++++++++++++++++++ .../prompt/PromptTemplateBuilderTests.java | 97 +++++++++++++ .../filter/FilterExpressionBuilderTests.java | 121 ++++++++++++++++ 3 files changed, 348 insertions(+) diff --git a/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentBuilderTests.java b/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentBuilderTests.java index cf8adaf5c27..484bbdb4026 100644 --- a/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentBuilderTests.java +++ b/spring-ai-commons/src/test/java/org/springframework/ai/document/DocumentBuilderTests.java @@ -160,4 +160,134 @@ void testBuildWithAllProperties() { assertThat(document.getMetadata()).isEqualTo(metadata); } + @Test + void testWithWhitespaceOnlyId() { + assertThatThrownBy(() -> this.builder.text("text").id(" ").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("id cannot be null or empty"); + } + + @Test + void testWithEmptyText() { + Document document = this.builder.text("").build(); + assertThat(document.getText()).isEqualTo(""); + } + + @Test + void testOverwritingText() { + Document document = this.builder.text("initial text").text("final text").build(); + assertThat(document.getText()).isEqualTo("final text"); + } + + @Test + void testMultipleMetadataKeyValueCalls() { + Document document = this.builder.text("text") + .metadata("key1", "value1") + .metadata("key2", "value2") + .metadata("key3", 123) + .build(); + + assertThat(document.getMetadata()).hasSize(3) + .containsEntry("key1", "value1") + .containsEntry("key2", "value2") + .containsEntry("key3", 123); + } + + @Test + void testMetadataMapOverridesKeyValue() { + Map metadata = new HashMap<>(); + metadata.put("newKey", "newValue"); + + Document document = this.builder.text("text").metadata("oldKey", "oldValue").metadata(metadata).build(); + + assertThat(document.getMetadata()).hasSize(1).containsEntry("newKey", "newValue").doesNotContainKey("oldKey"); + } + + @Test + void testKeyValueMetadataAfterMap() { + Map metadata = new HashMap<>(); + metadata.put("mapKey", "mapValue"); + + Document document = this.builder.text("text") + .metadata(metadata) + .metadata("additionalKey", "additionalValue") + .build(); + + assertThat(document.getMetadata()).hasSize(2) + .containsEntry("mapKey", "mapValue") + .containsEntry("additionalKey", "additionalValue"); + } + + @Test + void testWithEmptyMetadataMap() { + Map emptyMetadata = new HashMap<>(); + + Document document = this.builder.text("text").metadata(emptyMetadata).build(); + + assertThat(document.getMetadata()).isEmpty(); + } + + @Test + void testOverwritingMetadataWithSameKey() { + Document document = this.builder.text("text") + .metadata("key", "firstValue") + .metadata("key", "secondValue") + .build(); + + assertThat(document.getMetadata()).hasSize(1).containsEntry("key", "secondValue"); + } + + @Test + void testWithNullMedia() { + Document document = this.builder.text("text").media(null).build(); + assertThat(document.getMedia()).isNull(); + } + + @Test + void testIdOverridesIdGenerator() { + IdGenerator generator = contents -> "generated-id"; + + Document document = this.builder.text("text").idGenerator(generator).id("explicit-id").build(); + + assertThat(document.getId()).isEqualTo("explicit-id"); + } + + @Test + void testComplexMetadataTypes() { + Map nestedMap = new HashMap<>(); + nestedMap.put("nested", "value"); + + Document document = this.builder.text("text") + .metadata("string", "text") + .metadata("integer", 42) + .metadata("double", 3.14) + .metadata("boolean", true) + .metadata("map", nestedMap) + .build(); + + assertThat(document.getMetadata()).hasSize(5) + .containsEntry("string", "text") + .containsEntry("integer", 42) + .containsEntry("double", 3.14) + .containsEntry("boolean", true) + .containsEntry("map", nestedMap); + } + + @Test + void testBuilderReuse() { + // First document + Document doc1 = this.builder.text("first").id("id1").metadata("key", "value1").build(); + + // Reuse builder for second document + Document doc2 = this.builder.text("second").id("id2").metadata("key", "value2").build(); + + assertThat(doc1.getId()).isEqualTo("id1"); + assertThat(doc1.getText()).isEqualTo("first"); + assertThat(doc1.getMetadata()).containsEntry("key", "value1"); + + assertThat(doc2.getId()).isEqualTo("id2"); + assertThat(doc2.getText()).isEqualTo("second"); + assertThat(doc2.getMetadata()).containsEntry("key", "value2"); + } + } diff --git a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java index 249d980c615..6b23d2f8e73 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/chat/prompt/PromptTemplateBuilderTests.java @@ -96,4 +96,101 @@ void renderWithMissingVariableShouldThrow() { } } + @Test + void builderWithWhitespaceOnlyTemplateShouldThrow() { + assertThatThrownBy(() -> PromptTemplate.builder().template(" ")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("template cannot be null or empty"); + } + + @Test + void builderWithEmptyVariablesMapShouldWork() { + Map emptyVariables = new HashMap<>(); + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Status: active") + .variables(emptyVariables) + .build(); + + assertThat(promptTemplate.render()).isEqualTo("Status: active"); + } + + @Test + void builderNullVariableValueShouldWork() { + Map variables = new HashMap<>(); + variables.put("value", null); + + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Result: {value}") + .variables(variables) + .build(); + + // Should handle null values gracefully + String result = promptTemplate.render(); + assertThat(result).contains("Result:").contains(":"); + } + + @Test + void builderWithMultipleMissingVariablesShouldThrow() { + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Processing {item} with {type} at {level}") + .build(); + + try { + promptTemplate.render(); + Assertions.fail("Expected IllegalStateException was not thrown."); + } + catch (IllegalStateException e) { + assertThat(e.getMessage()).contains("Not all variables were replaced in the template"); + assertThat(e.getMessage()).contains("item", "type", "level"); + } + } + + @Test + void builderWithPartialVariablesShouldThrow() { + Map variables = new HashMap<>(); + variables.put("item", "data"); + // Missing 'type' variable + + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Processing {item} with {type}") + .variables(variables) + .build(); + + try { + promptTemplate.render(); + Assertions.fail("Expected IllegalStateException was not thrown."); + } + catch (IllegalStateException e) { + assertThat(e.getMessage()).contains("Missing variable names are: [type]"); + } + } + + @Test + void builderWithCompleteVariablesShouldRender() { + Map variables = new HashMap<>(); + variables.put("item", "data"); + variables.put("count", 42); + + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Processing {item} with count {count}") + .variables(variables) + .build(); + + String result = promptTemplate.render(); + assertThat(result).isEqualTo("Processing data with count 42"); + } + + @Test + void builderWithEmptyStringVariableShouldWork() { + Map variables = new HashMap<>(); + variables.put("name", ""); + + PromptTemplate promptTemplate = PromptTemplate.builder() + .template("Hello '{name}'!") + .variables(variables) + .build(); + + String result = promptTemplate.render(); + assertThat(result).isEqualTo("Hello ''!"); + } + } diff --git a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilderTests.java b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilderTests.java index 12084d00797..a68e8b89dd8 100644 --- a/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilderTests.java +++ b/spring-ai-vector-store/src/test/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilderTests.java @@ -28,8 +28,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.AND; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.EQ; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.GT; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.GTE; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.IN; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.LT; +import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.LTE; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NE; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NIN; import static org.springframework.ai.vectorstore.filter.Filter.ExpressionType.NOT; @@ -121,4 +124,122 @@ public void tesNot() { null)); } + @Test + public void testLessThanOperators() { + // value < 1 + var ltExp = this.b.lt("value", 1).build(); + assertThat(ltExp).isEqualTo(new Expression(LT, new Key("value"), new Value(1))); + + // value <= 1 + var lteExp = this.b.lte("value", 1).build(); + assertThat(lteExp).isEqualTo(new Expression(LTE, new Key("value"), new Value(1))); + } + + @Test + public void testGreaterThanOperators() { + // value > 1 + var gtExp = this.b.gt("value", 1).build(); + assertThat(gtExp).isEqualTo(new Expression(GT, new Key("value"), new Value(1))); + + // value >= 10 + var gteExp = this.b.gte("value", 10).build(); + assertThat(gteExp).isEqualTo(new Expression(GTE, new Key("value"), new Value(10))); + } + + @Test + public void testNullValues() { + // status == null + var exp = this.b.eq("status", null).build(); + assertThat(exp).isEqualTo(new Expression(EQ, new Key("status"), new Value(null))); + } + + @Test + public void testEmptyInClause() { + // category IN [] + var exp = this.b.in("category").build(); + assertThat(exp).isEqualTo(new Expression(IN, new Key("category"), new Value(List.of()))); + } + + @Test + public void testSingleValueInClause() { + // type IN ["basic"] + var exp = this.b.in("type", "basic").build(); + assertThat(exp).isEqualTo(new Expression(IN, new Key("type"), new Value(List.of("basic")))); + } + + @Test + public void testComplexNestedGroups() { + // ((level >= 1 AND level <= 5) OR status == "special") AND (region IN ["north", + // "south"] OR enabled == true) + var exp = this.b.and( + this.b.or(this.b.group(this.b.and(this.b.gte("level", 1), this.b.lte("level", 5))), + this.b.eq("status", "special")), + this.b.group(this.b.or(this.b.in("region", "north", "south"), this.b.eq("enabled", true)))) + .build(); + + Expression expected = new Expression(AND, + new Expression(OR, + new Group(new Expression(AND, new Expression(GTE, new Key("level"), new Value(1)), + new Expression(LTE, new Key("level"), new Value(5)))), + new Expression(EQ, new Key("status"), new Value("special"))), + new Group( + new Expression(OR, new Expression(IN, new Key("region"), new Value(List.of("north", "south"))), + new Expression(EQ, new Key("enabled"), new Value(true))))); + + assertThat(exp).isEqualTo(expected); + } + + @Test + public void testNotWithSimpleExpression() { + // NOT (active == true) + var exp = this.b.not(this.b.eq("active", true)).build(); + assertThat(exp).isEqualTo(new Expression(NOT, new Expression(EQ, new Key("active"), new Value(true)), null)); + } + + @Test + public void testNotWithGroup() { + // NOT (level >= 3 AND region == "east") + var exp = this.b.not(this.b.group(this.b.and(this.b.gte("level", 3), this.b.eq("region", "east")))).build(); + + Expression expected = new Expression(NOT, + new Group(new Expression(AND, new Expression(GTE, new Key("level"), new Value(3)), + new Expression(EQ, new Key("region"), new Value("east")))), + null); + + assertThat(exp).isEqualTo(expected); + } + + @Test + public void testMultipleNotOperators() { + // NOT (NOT (active == true)) + var exp = this.b.not(this.b.not(this.b.eq("active", true))).build(); + + Expression expected = new Expression(NOT, + new Expression(NOT, new Expression(EQ, new Key("active"), new Value(true)), null), null); + + assertThat(exp).isEqualTo(expected); + } + + @Test + public void testSpecialCharactersInKeys() { + // "item.name" == "test" AND "meta-data" != null + var exp = this.b.and(this.b.eq("item.name", "test"), this.b.ne("meta-data", null)).build(); + + Expression expected = new Expression(AND, new Expression(EQ, new Key("item.name"), new Value("test")), + new Expression(NE, new Key("meta-data"), new Value(null))); + + assertThat(exp).isEqualTo(expected); + } + + @Test + public void testEmptyStringValues() { + // description == "" OR label != "" + var exp = this.b.or(this.b.eq("description", ""), this.b.ne("label", "")).build(); + + Expression expected = new Expression(OR, new Expression(EQ, new Key("description"), new Value("")), + new Expression(NE, new Key("label"), new Value(""))); + + assertThat(exp).isEqualTo(expected); + } + }