Skip to content

Commit dffec8d

Browse files
alxkmsobychacko
authored andcommitted
test: Enhance Anthropic test coverage with comprehensive validation and edge cases
Comprehensive test coverage improvements for Anthropic tests test: Enhance Vertex AI Gemini test coverage for retry mechanisms and runtime hints Comprehensive test coverage improvements for Vertex AI Gemini module test: Add edge case coverage for OpenAI mutate functionality and usage handling Enhanced test coverage for OpenAI module edge cases and validation Co-authored-by: Oleksandr Klymenko <[email protected]> Signed-off-by: Oleksandr Klymenko <[email protected]>
1 parent 5afea94 commit dffec8d

File tree

6 files changed

+492
-0
lines changed

6 files changed

+492
-0
lines changed

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatOptionsTests.java

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,4 +318,157 @@ void testCopyPreservesAllFields() {
318318
assertThat(copied.getToolContext()).isNotSameAs(original.getToolContext());
319319
}
320320

321+
@Test
322+
void testBoundaryValues() {
323+
AnthropicChatOptions options = AnthropicChatOptions.builder()
324+
.maxTokens(Integer.MAX_VALUE)
325+
.temperature(1.0)
326+
.topP(1.0)
327+
.topK(Integer.MAX_VALUE)
328+
.build();
329+
330+
assertThat(options.getMaxTokens()).isEqualTo(Integer.MAX_VALUE);
331+
assertThat(options.getTemperature()).isEqualTo(1.0);
332+
assertThat(options.getTopP()).isEqualTo(1.0);
333+
assertThat(options.getTopK()).isEqualTo(Integer.MAX_VALUE);
334+
}
335+
336+
@Test
337+
void testToolContextWithVariousValueTypes() {
338+
Map<String, Object> mixedMap = Map.of("string", "value", "number", 42, "boolean", true, "null_value", "null",
339+
"nested_list", List.of("a", "b", "c"), "nested_map", Map.of("inner", "value"));
340+
341+
AnthropicChatOptions options = AnthropicChatOptions.builder().toolContext(mixedMap).build();
342+
343+
assertThat(options.getToolContext()).containsAllEntriesOf(mixedMap);
344+
assertThat(options.getToolContext().get("string")).isEqualTo("value");
345+
assertThat(options.getToolContext().get("number")).isEqualTo(42);
346+
assertThat(options.getToolContext().get("boolean")).isEqualTo(true);
347+
}
348+
349+
@Test
350+
void testCopyWithMutableCollections() {
351+
List<String> mutableStops = new java.util.ArrayList<>(List.of("stop1", "stop2"));
352+
Map<String, Object> mutableContext = new java.util.HashMap<>(Map.of("key", "value"));
353+
354+
AnthropicChatOptions original = AnthropicChatOptions.builder()
355+
.stopSequences(mutableStops)
356+
.toolContext(mutableContext)
357+
.build();
358+
359+
AnthropicChatOptions copied = original.copy();
360+
361+
// Modify original collections
362+
mutableStops.add("stop3");
363+
mutableContext.put("new_key", "new_value");
364+
365+
// Copied instance should not be affected
366+
assertThat(copied.getStopSequences()).hasSize(2);
367+
assertThat(copied.getToolContext()).hasSize(1);
368+
assertThat(copied.getStopSequences()).doesNotContain("stop3");
369+
assertThat(copied.getToolContext()).doesNotContainKey("new_key");
370+
}
371+
372+
@Test
373+
void testEqualsWithNullFields() {
374+
AnthropicChatOptions options1 = new AnthropicChatOptions();
375+
AnthropicChatOptions options2 = new AnthropicChatOptions();
376+
377+
assertThat(options1).isEqualTo(options2);
378+
assertThat(options1.hashCode()).isEqualTo(options2.hashCode());
379+
}
380+
381+
@Test
382+
void testEqualsWithMixedNullAndNonNullFields() {
383+
AnthropicChatOptions options1 = AnthropicChatOptions.builder()
384+
.model("test")
385+
.maxTokens(null)
386+
.temperature(0.5)
387+
.build();
388+
389+
AnthropicChatOptions options2 = AnthropicChatOptions.builder()
390+
.model("test")
391+
.maxTokens(null)
392+
.temperature(0.5)
393+
.build();
394+
395+
AnthropicChatOptions options3 = AnthropicChatOptions.builder()
396+
.model("test")
397+
.maxTokens(100)
398+
.temperature(0.5)
399+
.build();
400+
401+
assertThat(options1).isEqualTo(options2);
402+
assertThat(options1).isNotEqualTo(options3);
403+
}
404+
405+
@Test
406+
void testCopyDoesNotShareMetadataReference() {
407+
Metadata originalMetadata = new Metadata("user_123");
408+
AnthropicChatOptions original = AnthropicChatOptions.builder().metadata(originalMetadata).build();
409+
410+
AnthropicChatOptions copied = original.copy();
411+
412+
// Metadata should be the same value but potentially different reference
413+
assertThat(copied.getMetadata()).isEqualTo(original.getMetadata());
414+
415+
// Verify changing original doesn't affect copy
416+
original.setMetadata(new Metadata("different_user"));
417+
assertThat(copied.getMetadata()).isEqualTo(originalMetadata);
418+
}
419+
420+
@Test
421+
void testEqualsWithSelf() {
422+
AnthropicChatOptions options = AnthropicChatOptions.builder().model("test").build();
423+
424+
assertThat(options).isEqualTo(options);
425+
assertThat(options.hashCode()).isEqualTo(options.hashCode());
426+
}
427+
428+
@Test
429+
void testEqualsWithNull() {
430+
AnthropicChatOptions options = AnthropicChatOptions.builder().model("test").build();
431+
432+
assertThat(options).isNotEqualTo(null);
433+
}
434+
435+
@Test
436+
void testEqualsWithDifferentClass() {
437+
AnthropicChatOptions options = AnthropicChatOptions.builder().model("test").build();
438+
439+
assertThat(options).isNotEqualTo("not an AnthropicChatOptions");
440+
assertThat(options).isNotEqualTo(1);
441+
}
442+
443+
@Test
444+
void testBuilderPartialConfiguration() {
445+
// Test builder with only some fields set
446+
AnthropicChatOptions onlyModel = AnthropicChatOptions.builder().model("model-only").build();
447+
448+
AnthropicChatOptions onlyTokens = AnthropicChatOptions.builder().maxTokens(10).build();
449+
450+
AnthropicChatOptions onlyTemperature = AnthropicChatOptions.builder().temperature(0.8).build();
451+
452+
assertThat(onlyModel.getModel()).isEqualTo("model-only");
453+
assertThat(onlyModel.getMaxTokens()).isNull();
454+
455+
assertThat(onlyTokens.getModel()).isNull();
456+
assertThat(onlyTokens.getMaxTokens()).isEqualTo(10);
457+
458+
assertThat(onlyTemperature.getModel()).isNull();
459+
assertThat(onlyTemperature.getTemperature()).isEqualTo(0.8);
460+
}
461+
462+
@Test
463+
void testSetterOverwriteBehavior() {
464+
AnthropicChatOptions options = AnthropicChatOptions.builder().model("initial-model").maxTokens(100).build();
465+
466+
// Overwrite with setters
467+
options.setModel("updated-model");
468+
options.setMaxTokens(10);
469+
470+
assertThat(options.getModel()).isEqualTo("updated-model");
471+
assertThat(options.getMaxTokens()).isEqualTo(10);
472+
}
473+
321474
}

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/aot/AnthropicRuntimeHintsTests.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,86 @@ void verifyEnumTypesAreRegistered() {
145145
assertThat(registeredTypes.contains(TypeReference.of(AnthropicApi.EventType.class))).isTrue();
146146
}
147147

148+
@Test
149+
void verifyNestedClassesAreRegistered() {
150+
anthropicRuntimeHints.registerHints(runtimeHints, null);
151+
152+
Set<TypeReference> registeredTypes = new HashSet<>();
153+
runtimeHints.reflection().typeHints().forEach(typeHint -> registeredTypes.add(typeHint.getType()));
154+
155+
// Verify nested classes within AnthropicApi are registered
156+
assertThat(registeredTypes.contains(TypeReference.of(AnthropicApi.ChatCompletionRequest.class))).isTrue();
157+
assertThat(registeredTypes.contains(TypeReference.of(AnthropicApi.AnthropicMessage.class))).isTrue();
158+
assertThat(registeredTypes.contains(TypeReference.of(AnthropicApi.ContentBlock.class))).isTrue();
159+
}
160+
161+
@Test
162+
void verifyNoProxyHintsAreRegistered() {
163+
anthropicRuntimeHints.registerHints(runtimeHints, null);
164+
165+
// This implementation should only register reflection hints, not proxy hints
166+
long proxyHintCount = runtimeHints.proxies().jdkProxyHints().count();
167+
assertThat(proxyHintCount).isEqualTo(0);
168+
}
169+
170+
@Test
171+
void verifyNoSerializationHintsAreRegistered() {
172+
anthropicRuntimeHints.registerHints(runtimeHints, null);
173+
174+
// This implementation should only register reflection hints, not serialization
175+
// hints
176+
long serializationHintCount = runtimeHints.serialization().javaSerializationHints().count();
177+
assertThat(serializationHintCount).isEqualTo(0);
178+
}
179+
180+
@Test
181+
void verifyJsonAnnotatedClassesContainExpectedTypes() {
182+
Set<TypeReference> jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage("org.springframework.ai.anthropic");
183+
184+
// Verify that key API classes are found
185+
boolean containsApiClass = jsonAnnotatedClasses.stream()
186+
.anyMatch(typeRef -> typeRef.getName().contains("AnthropicApi")
187+
|| typeRef.getName().contains("ChatCompletion") || typeRef.getName().contains("AnthropicMessage"));
188+
189+
assertThat(containsApiClass).isTrue();
190+
}
191+
192+
@Test
193+
void verifyConsistencyAcrossInstances() {
194+
RuntimeHints hints1 = new RuntimeHints();
195+
RuntimeHints hints2 = new RuntimeHints();
196+
197+
AnthropicRuntimeHints anthropicHints1 = new AnthropicRuntimeHints();
198+
AnthropicRuntimeHints anthropicHints2 = new AnthropicRuntimeHints();
199+
200+
anthropicHints1.registerHints(hints1, null);
201+
anthropicHints2.registerHints(hints2, null);
202+
203+
// Different instances should register the same hints
204+
Set<TypeReference> types1 = new HashSet<>();
205+
Set<TypeReference> types2 = new HashSet<>();
206+
207+
hints1.reflection().typeHints().forEach(hint -> types1.add(hint.getType()));
208+
hints2.reflection().typeHints().forEach(hint -> types2.add(hint.getType()));
209+
210+
assertThat(types1).isEqualTo(types2);
211+
}
212+
213+
@Test
214+
void verifyPackageSpecificity() {
215+
Set<TypeReference> jsonAnnotatedClasses = findJsonAnnotatedClassesInPackage("org.springframework.ai.anthropic");
216+
217+
// All found classes should be from the anthropic package specifically
218+
for (TypeReference classRef : jsonAnnotatedClasses) {
219+
assertThat(classRef.getName()).startsWith("org.springframework.ai.anthropic");
220+
}
221+
222+
// Should not include classes from other AI packages
223+
for (TypeReference classRef : jsonAnnotatedClasses) {
224+
assertThat(classRef.getName()).doesNotContain("vertexai");
225+
assertThat(classRef.getName()).doesNotContain("openai");
226+
assertThat(classRef.getName()).doesNotContain("ollama");
227+
}
228+
}
229+
148230
}

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiChatModelMutateTests.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,63 @@ void mutateAndCloneAreEquivalent() {
131131
assertThat(mutated).isNotSameAs(cloned);
132132
}
133133

134+
@Test
135+
void testApiMutateWithComplexHeaders() {
136+
LinkedMultiValueMap<String, String> complexHeaders = new LinkedMultiValueMap<>();
137+
complexHeaders.add("Authorization", "Bearer custom-token");
138+
complexHeaders.add("X-Custom-Header", "value1");
139+
complexHeaders.add("X-Custom-Header", "value2");
140+
complexHeaders.add("User-Agent", "Custom-Client/1.0");
141+
142+
OpenAiApi mutatedApi = this.baseApi.mutate().headers(complexHeaders).build();
143+
144+
assertThat(mutatedApi.getHeaders()).containsKey("Authorization");
145+
assertThat(mutatedApi.getHeaders()).containsKey("X-Custom-Header");
146+
assertThat(mutatedApi.getHeaders()).containsKey("User-Agent");
147+
assertThat(mutatedApi.getHeaders().get("X-Custom-Header")).hasSize(2);
148+
}
149+
150+
@Test
151+
void testMutateWithEmptyOptions() {
152+
OpenAiChatOptions emptyOptions = OpenAiChatOptions.builder().build();
153+
154+
OpenAiChatModel mutated = this.baseModel.mutate().defaultOptions(emptyOptions).build();
155+
156+
assertThat(mutated.getDefaultOptions()).isNotNull();
157+
assertThat(mutated.getDefaultOptions()).isNotSameAs(this.baseModel.getDefaultOptions());
158+
}
159+
160+
@Test
161+
void testApiMutateWithEmptyHeaders() {
162+
LinkedMultiValueMap<String, String> emptyHeaders = new LinkedMultiValueMap<>();
163+
164+
OpenAiApi mutatedApi = this.baseApi.mutate().headers(emptyHeaders).build();
165+
166+
assertThat(mutatedApi.getHeaders()).isEmpty();
167+
}
168+
169+
@Test
170+
void testCloneAndMutateIndependence() {
171+
// Test that clone and mutate produce independent instances
172+
OpenAiChatModel cloned = this.baseModel.clone();
173+
OpenAiChatModel mutated = this.baseModel.mutate().build();
174+
175+
// Modify cloned instance (if options are mutable)
176+
// This test verifies that operations on one don't affect the other
177+
assertThat(cloned).isNotSameAs(mutated);
178+
assertThat(cloned).isNotSameAs(this.baseModel);
179+
assertThat(mutated).isNotSameAs(this.baseModel);
180+
}
181+
182+
@Test
183+
void testMutateBuilderValidation() {
184+
// Test that mutate builder validates inputs appropriately
185+
assertThat(this.baseModel.mutate()).isNotNull();
186+
187+
// Test building without any changes
188+
OpenAiChatModel unchanged = this.baseModel.mutate().build();
189+
assertThat(unchanged).isNotNull();
190+
assertThat(unchanged).isNotSameAs(this.baseModel);
191+
}
192+
134193
}

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/metadata/OpenAiUsageTests.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,22 @@ void whenPromptCacheMissTokensIsPresent() {
208208
assertThat(nativeUsage.promptTokensDetails().cachedTokens()).isEqualTo(15);
209209
}
210210

211+
@Test
212+
void whenAllTokenCountsAreZero() {
213+
OpenAiApi.Usage openAiUsage = new OpenAiApi.Usage(0, 0, 0);
214+
DefaultUsage usage = getDefaultUsage(openAiUsage);
215+
assertThat(usage.getPromptTokens()).isEqualTo(0);
216+
assertThat(usage.getCompletionTokens()).isEqualTo(0);
217+
assertThat(usage.getTotalTokens()).isEqualTo(0);
218+
}
219+
220+
@Test
221+
void whenAllTokenCountsAreNull() {
222+
OpenAiApi.Usage openAiUsage = new OpenAiApi.Usage(null, null, null);
223+
DefaultUsage usage = getDefaultUsage(openAiUsage);
224+
assertThat(usage.getPromptTokens()).isEqualTo(0);
225+
assertThat(usage.getCompletionTokens()).isEqualTo(0);
226+
assertThat(usage.getTotalTokens()).isEqualTo(0);
227+
}
228+
211229
}

models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiRetryTests.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,46 @@ public <T, E extends Throwable> void onError(RetryContext context, RetryCallback
134134

135135
}
136136

137+
@Test
138+
public void vertexAiGeminiChatSuccessOnFirstAttempt() throws Exception {
139+
// Create a mocked successful response
140+
GenerateContentResponse mockedResponse = GenerateContentResponse.newBuilder()
141+
.addCandidates(Candidate.newBuilder()
142+
.setContent(Content.newBuilder()
143+
.addParts(Part.newBuilder().setText("First Attempt Success").build())
144+
.build())
145+
.build())
146+
.build();
147+
148+
given(this.mockGenerativeModel.generateContent(any(List.class))).willReturn(mockedResponse);
149+
150+
// Call the chat model
151+
ChatResponse result = this.chatModel.call(new Prompt("test prompt"));
152+
153+
// Assertions
154+
assertThat(result).isNotNull();
155+
assertThat(result.getResult().getOutput().getText()).isEqualTo("First Attempt Success");
156+
assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(0); // No retries
157+
// needed
158+
assertThat(this.retryListener.onErrorRetryCount).isEqualTo(0);
159+
}
160+
161+
@Test
162+
public void vertexAiGeminiChatWithEmptyResponse() throws Exception {
163+
// Test handling of empty response after retries
164+
GenerateContentResponse emptyResponse = GenerateContentResponse.newBuilder().build();
165+
166+
given(this.mockGenerativeModel.generateContent(any(List.class)))
167+
.willThrow(new TransientAiException("Temporary issue"))
168+
.willReturn(emptyResponse);
169+
170+
// Call the chat model
171+
ChatResponse result = this.chatModel.call(new Prompt("test prompt"));
172+
173+
// Should handle empty response gracefully
174+
assertThat(result).isNotNull();
175+
assertThat(this.retryListener.onSuccessRetryCount).isEqualTo(1);
176+
assertThat(this.retryListener.onErrorRetryCount).isEqualTo(1);
177+
}
178+
137179
}

0 commit comments

Comments
 (0)