Skip to content

Commit b8a006b

Browse files
committed
feat(tests): add unit tests for Gemini API components
Signed-off-by: liuhy <liuhongyu@apache.org>
1 parent a2aef24 commit b8a006b

7 files changed

Lines changed: 783 additions & 0 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.model;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
import org.junit.jupiter.api.DisplayName;
22+
import org.junit.jupiter.api.Tag;
23+
import org.junit.jupiter.api.Test;
24+
25+
@Tag("unit")
26+
@DisplayName("GeminiApiException Unit Tests")
27+
class GeminiApiExceptionTest {
28+
29+
@Test
30+
@DisplayName("Should expose status code and response body")
31+
void testConstructorAndGetters() {
32+
GeminiApiException exception = new GeminiApiException(429, "{\"error\":\"rate_limited\"}");
33+
34+
assertEquals(429, exception.getStatusCode());
35+
assertEquals("{\"error\":\"rate_limited\"}", exception.getBody());
36+
assertTrue(exception.getMessage().contains("429"));
37+
assertTrue(exception.getMessage().contains("rate_limited"));
38+
}
39+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.model;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertFalse;
20+
import static org.junit.jupiter.api.Assertions.assertNotNull;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import io.agentscope.core.agent.StructuredOutputCapableAgent;
24+
import io.agentscope.core.formatter.gemini.GeminiChatFormatter;
25+
import io.agentscope.core.message.Msg;
26+
import io.agentscope.core.message.MsgRole;
27+
import io.agentscope.core.util.JsonCodec;
28+
import io.agentscope.core.util.JsonUtils;
29+
import java.io.IOException;
30+
import java.util.List;
31+
import java.util.Map;
32+
import okhttp3.Request;
33+
import okio.Buffer;
34+
import org.junit.jupiter.api.DisplayName;
35+
import org.junit.jupiter.api.Tag;
36+
import org.junit.jupiter.api.Test;
37+
import org.slf4j.Logger;
38+
import org.slf4j.LoggerFactory;
39+
40+
@Tag("unit")
41+
@DisplayName("GeminiRequestAssembler Unit Tests")
42+
class GeminiRequestAssemblerTest {
43+
44+
private static final Logger LOG = LoggerFactory.getLogger(GeminiRequestAssemblerTest.class);
45+
46+
@Test
47+
@DisplayName("Should assemble streaming request and append synthetic user when needed")
48+
void testAssembleStreamingRequestWithSyntheticUser() throws IOException {
49+
GeminiRequestAssembler assembler =
50+
newAssembler(
51+
"https://example.com/v1beta/models/",
52+
"gemini-2.5-flash",
53+
true,
54+
"mock_api_key",
55+
null);
56+
57+
List<Msg> messages =
58+
List.of(
59+
Msg.builder().role(MsgRole.USER).textContent("Q1").build(),
60+
Msg.builder().role(MsgRole.ASSISTANT).textContent("A1").build());
61+
62+
GeminiRequestAssembler.PreparedRequest prepared =
63+
assembler.assemble(messages, null, GenerateOptions.builder().build());
64+
65+
assertTrue(prepared.isStreamForRequest());
66+
67+
Request request = prepared.getHttpRequest();
68+
assertTrue(
69+
request.url().toString().contains(":streamGenerateContent?alt=sse"),
70+
"Expected streaming endpoint URL");
71+
assertEquals("mock_api_key", request.header("x-goog-api-key"));
72+
assertEquals(null, request.header("Authorization"));
73+
74+
Map<String, Object> requestBody = parseRequestBody(request);
75+
@SuppressWarnings("unchecked")
76+
List<Map<String, Object>> contents =
77+
(List<Map<String, Object>>) requestBody.get("contents");
78+
assertNotNull(contents);
79+
assertEquals(3, contents.size());
80+
81+
Map<String, Object> lastContent = contents.get(contents.size() - 1);
82+
assertEquals("user", lastContent.get("role"));
83+
84+
@SuppressWarnings("unchecked")
85+
List<Map<String, Object>> parts = (List<Map<String, Object>>) lastContent.get("parts");
86+
assertNotNull(parts);
87+
assertEquals("Please continue with your response.", parts.get(0).get("text"));
88+
}
89+
90+
@Test
91+
@DisplayName("Should force unary endpoint when structured output tool is present")
92+
void testForceUnaryForStructuredOutput() {
93+
GeminiRequestAssembler assembler =
94+
newAssembler(
95+
"https://example.com/v1beta/models/",
96+
"gemini-3-flash-preview",
97+
true,
98+
"mock_api_key",
99+
null);
100+
101+
List<Msg> messages = List.of(Msg.builder().role(MsgRole.USER).textContent("hello").build());
102+
List<ToolSchema> tools = List.of(structuredOutputTool());
103+
104+
GeminiRequestAssembler.PreparedRequest prepared =
105+
assembler.assemble(messages, tools, GenerateOptions.builder().build());
106+
107+
assertFalse(prepared.isStreamForRequest());
108+
assertTrue(
109+
prepared.getHttpRequest().url().toString().endsWith(":generateContent"),
110+
"Expected unary endpoint URL");
111+
assertFalse(prepared.getHttpRequest().url().toString().contains("alt=sse"));
112+
}
113+
114+
@Test
115+
@DisplayName("Should prefer Authorization header when access token is configured")
116+
void testAccessTokenHeader() {
117+
GeminiRequestAssembler assembler =
118+
newAssembler(
119+
"https://example.com/v1beta/models/",
120+
"gemini-2.5-flash",
121+
false,
122+
null,
123+
"access_token_123");
124+
125+
List<Msg> messages = List.of(Msg.builder().role(MsgRole.USER).textContent("hello").build());
126+
127+
GeminiRequestAssembler.PreparedRequest prepared =
128+
assembler.assemble(messages, null, GenerateOptions.builder().build());
129+
130+
assertFalse(prepared.isStreamForRequest());
131+
assertEquals("Bearer access_token_123", prepared.getHttpRequest().header("Authorization"));
132+
assertEquals(null, prepared.getHttpRequest().header("x-goog-api-key"));
133+
}
134+
135+
private static GeminiRequestAssembler newAssembler(
136+
String baseUrl, String modelName, boolean streamEnabled, String apiKey, String token) {
137+
JsonCodec codec = JsonUtils.getJsonCodec();
138+
return new GeminiRequestAssembler(
139+
baseUrl,
140+
modelName,
141+
streamEnabled,
142+
apiKey,
143+
token,
144+
GenerateOptions.builder().build(),
145+
new GeminiChatFormatter(),
146+
codec,
147+
new GeminiThinkingPolicy(LOG),
148+
LOG);
149+
}
150+
151+
private static ToolSchema structuredOutputTool() {
152+
return ToolSchema.builder()
153+
.name(StructuredOutputCapableAgent.STRUCTURED_OUTPUT_TOOL_NAME)
154+
.description("Structured output tool")
155+
.parameters(Map.of("type", "object", "properties", Map.of()))
156+
.build();
157+
}
158+
159+
@SuppressWarnings("unchecked")
160+
private static Map<String, Object> parseRequestBody(Request request) throws IOException {
161+
Buffer buffer = new Buffer();
162+
if (request.body() == null) {
163+
throw new IllegalStateException("Request body is null");
164+
}
165+
request.body().writeTo(buffer);
166+
String json = buffer.readUtf8();
167+
return JsonUtils.getJsonCodec().fromJson(json, Map.class);
168+
}
169+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.agentscope.core.model;
17+
18+
import static org.junit.jupiter.api.Assertions.assertEquals;
19+
import static org.junit.jupiter.api.Assertions.assertSame;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
import io.agentscope.core.message.TextBlock;
24+
import java.util.List;
25+
import org.junit.jupiter.api.DisplayName;
26+
import org.junit.jupiter.api.Tag;
27+
import org.junit.jupiter.api.Test;
28+
import org.slf4j.LoggerFactory;
29+
30+
@Tag("unit")
31+
@DisplayName("GeminiResponseGuard Unit Tests")
32+
class GeminiResponseGuardTest {
33+
34+
@Test
35+
@DisplayName("Should keep response unchanged when text content exists")
36+
void testKeepResponseWhenHasText() {
37+
GeminiResponseGuard guard =
38+
new GeminiResponseGuard("gemini-2.5-flash", LoggerFactory.getLogger(getClass()));
39+
ChatResponse response =
40+
ChatResponse.builder()
41+
.content(List.of(TextBlock.builder().text("hello").build()))
42+
.finishReason("STOP")
43+
.build();
44+
45+
ChatResponse result = guard.ensureMeaningfulContent(response);
46+
assertSame(response, result);
47+
}
48+
49+
@Test
50+
@DisplayName("Should keep response unchanged for expected finish reason")
51+
void testKeepResponseForExpectedFinishReason() {
52+
GeminiResponseGuard guard =
53+
new GeminiResponseGuard("gemini-2.5-flash", LoggerFactory.getLogger(getClass()));
54+
ChatResponse response =
55+
ChatResponse.builder()
56+
.content(List.of(TextBlock.builder().text("").build()))
57+
.finishReason("STOP")
58+
.build();
59+
60+
ChatResponse result = guard.ensureMeaningfulContent(response);
61+
assertSame(response, result);
62+
}
63+
64+
@Test
65+
@DisplayName("Should throw model exception on Gemini 3 malformed function call")
66+
void testThrowOnGemini3MalformedFunctionCall() {
67+
GeminiResponseGuard guard =
68+
new GeminiResponseGuard("gemini-3-pro", LoggerFactory.getLogger(getClass()));
69+
ChatResponse response =
70+
ChatResponse.builder()
71+
.content(List.of(TextBlock.builder().text("").build()))
72+
.finishReason("MALFORMED_FUNCTION_CALL")
73+
.build();
74+
75+
ModelException exception =
76+
assertThrows(ModelException.class, () -> guard.ensureMeaningfulContent(response));
77+
assertTrue(exception.getMessage().contains("MALFORMED_FUNCTION_CALL"));
78+
}
79+
80+
@Test
81+
@DisplayName("Should append fallback text on unexpected finish reason")
82+
void testAppendFallbackForUnexpectedFinishReason() {
83+
GeminiResponseGuard guard =
84+
new GeminiResponseGuard("gemini-2.0-flash", LoggerFactory.getLogger(getClass()));
85+
ChatResponse response =
86+
ChatResponse.builder()
87+
.content(List.of(TextBlock.builder().text("").build()))
88+
.finishReason("UNKNOWN_REASON")
89+
.build();
90+
91+
ChatResponse result = guard.ensureMeaningfulContent(response);
92+
93+
assertEquals(2, result.getContent().size());
94+
TextBlock fallback = (TextBlock) result.getContent().get(1);
95+
assertTrue(fallback.getText().contains("UNKNOWN_REASON"));
96+
}
97+
}

0 commit comments

Comments
 (0)