|
9 | 9 |
|
10 | 10 | (globalThis as any).UrlFetchApp = { |
11 | 11 | fetch: jest.fn(), |
| 12 | + fetchAll: jest.fn(), |
12 | 13 | }; |
13 | 14 |
|
14 | 15 | (globalThis as any).PropertiesService = { |
|
19 | 20 |
|
20 | 21 | // ── Import after mocks ───────────────────────────────────────── |
21 | 22 |
|
22 | | -import { buildGeminiPayload, callGeminiAPI, invokeGemini } from "../src/server/api"; |
| 23 | +import { |
| 24 | + buildGeminiPayload, |
| 25 | + callGeminiAPI, |
| 26 | + callGeminiAPIBatch, |
| 27 | + invokeGemini, |
| 28 | +} from "../src/server/api"; |
23 | 29 | import { CONFIG } from "../src/server/config"; |
24 | 30 | import type { GeminiRequest } from "../src/server/types"; |
25 | 31 |
|
@@ -186,19 +192,12 @@ describe("buildGeminiPayload", () => { |
186 | 192 | expect((payload.generationConfig as any).temperature).toBe(0.5); |
187 | 193 | }); |
188 | 194 |
|
189 | | - it("applies CONFIG.MAX_OUTPUT_TOKENS as default maxOutputTokens when no generationConfig is provided", () => { |
| 195 | + it("does not set maxOutputTokens when no generationConfig is provided", () => { |
190 | 196 | const payload = buildGeminiPayload(baseReq); |
191 | | - expect((payload.generationConfig as any).maxOutputTokens).toBe(CONFIG.MAX_OUTPUT_TOKENS); |
| 197 | + expect((payload.generationConfig as any).maxOutputTokens).toBeUndefined(); |
192 | 198 | }); |
193 | 199 |
|
194 | | - it("applies CONFIG.MAX_OUTPUT_TOKENS as default when generationConfig omits maxOutputTokens", () => { |
195 | | - const req: GeminiRequest = { ...baseReq, generationConfig: { temperature: 0.7 } }; |
196 | | - const payload = buildGeminiPayload(req); |
197 | | - expect((payload.generationConfig as any).maxOutputTokens).toBe(CONFIG.MAX_OUTPUT_TOKENS); |
198 | | - expect((payload.generationConfig as any).temperature).toBe(0.7); |
199 | | - }); |
200 | | - |
201 | | - it("uses caller-supplied maxOutputTokens over CONFIG default", () => { |
| 200 | + it("passes through caller-supplied maxOutputTokens", () => { |
202 | 201 | const req: GeminiRequest = { ...baseReq, generationConfig: { maxOutputTokens: 512 } }; |
203 | 202 | const payload = buildGeminiPayload(req); |
204 | 203 | expect((payload.generationConfig as any).maxOutputTokens).toBe(512); |
@@ -362,3 +361,135 @@ describe("invokeGemini", () => { |
362 | 361 | expect(payload.contents[0].parts[1].inline_data.mime_type).toBe("application/pdf"); |
363 | 362 | }); |
364 | 363 | }); |
| 364 | + |
| 365 | +// ── callGeminiAPIBatch tests ─────────────────────────────────── |
| 366 | + |
| 367 | +function mockFetchAllResponses(bodies: unknown[]) { |
| 368 | + (UrlFetchApp.fetchAll as jest.Mock).mockReturnValue( |
| 369 | + bodies.map((body) => ({ getContentText: () => JSON.stringify(body) })), |
| 370 | + ); |
| 371 | +} |
| 372 | + |
| 373 | +describe("callGeminiAPIBatch", () => { |
| 374 | + beforeEach(() => jest.clearAllMocks()); |
| 375 | + |
| 376 | + it("returns one GeminiResponse per request", () => { |
| 377 | + mockFetchAllResponses([ |
| 378 | + { candidates: [{ content: { parts: [{ text: "Result A" }] } }] }, |
| 379 | + { candidates: [{ content: { parts: [{ text: "Result B" }] } }] }, |
| 380 | + ]); |
| 381 | + const reqs: GeminiRequest[] = [ |
| 382 | + { apiKey: "key", userParts: [{ text: "Q1" }] }, |
| 383 | + { apiKey: "key", userParts: [{ text: "Q2" }] }, |
| 384 | + ]; |
| 385 | + const results = callGeminiAPIBatch(reqs); |
| 386 | + expect(results).toHaveLength(2); |
| 387 | + expect(results[0].text).toBe("Result A"); |
| 388 | + expect(results[1].text).toBe("Result B"); |
| 389 | + }); |
| 390 | + |
| 391 | + it("returns empty array for empty input", () => { |
| 392 | + expect(callGeminiAPIBatch([])).toEqual([]); |
| 393 | + expect(UrlFetchApp.fetchAll as jest.Mock).not.toHaveBeenCalled(); |
| 394 | + }); |
| 395 | + |
| 396 | + it("maps a Gemini error response to an error text result (does not throw)", () => { |
| 397 | + mockFetchAllResponses([ |
| 398 | + { error: { message: "quota exceeded" } }, |
| 399 | + { candidates: [{ content: { parts: [{ text: "OK" }] } }] }, |
| 400 | + ]); |
| 401 | + const reqs: GeminiRequest[] = [ |
| 402 | + { apiKey: "key", userParts: [{ text: "Q1" }] }, |
| 403 | + { apiKey: "key", userParts: [{ text: "Q2" }] }, |
| 404 | + ]; |
| 405 | + const results = callGeminiAPIBatch(reqs); |
| 406 | + expect(results[0].text).toMatch(/Error:/); |
| 407 | + expect(results[1].text).toBe("OK"); |
| 408 | + }); |
| 409 | + |
| 410 | + it("maps a non-JSON response to an error text result without aborting the batch", () => { |
| 411 | + (UrlFetchApp.fetchAll as jest.Mock).mockReturnValue([ |
| 412 | + { |
| 413 | + getResponseCode: () => 503, |
| 414 | + getContentText: () => "<html>Service Unavailable</html>", |
| 415 | + }, |
| 416 | + { |
| 417 | + getContentText: () => |
| 418 | + JSON.stringify({ candidates: [{ content: { parts: [{ text: "OK" }] } }] }), |
| 419 | + }, |
| 420 | + ]); |
| 421 | + const reqs: GeminiRequest[] = [ |
| 422 | + { apiKey: "key", userParts: [{ text: "Q1" }] }, |
| 423 | + { apiKey: "key", userParts: [{ text: "Q2" }] }, |
| 424 | + ]; |
| 425 | + const results = callGeminiAPIBatch(reqs); |
| 426 | + expect(results[0].text).toMatch(/Error:.*503/); |
| 427 | + expect(results[1].text).toBe("OK"); |
| 428 | + }); |
| 429 | + |
| 430 | + it("includes file_data parts in the request payload", () => { |
| 431 | + mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]); |
| 432 | + const req: GeminiRequest = { |
| 433 | + apiKey: "key", |
| 434 | + userParts: [ |
| 435 | + { text: "Describe this file" }, |
| 436 | + { |
| 437 | + file_data: { |
| 438 | + file_uri: "https://generativelanguage.googleapis.com/v1beta/files/abc", |
| 439 | + mime_type: "application/pdf", |
| 440 | + }, |
| 441 | + }, |
| 442 | + ], |
| 443 | + }; |
| 444 | + callGeminiAPIBatch([req]); |
| 445 | + const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0]; |
| 446 | + const payload = JSON.parse(calls[0].payload); |
| 447 | + expect(payload.contents[0].parts[1].file_data).toEqual({ |
| 448 | + file_uri: "https://generativelanguage.googleapis.com/v1beta/files/abc", |
| 449 | + mime_type: "application/pdf", |
| 450 | + }); |
| 451 | + }); |
| 452 | + |
| 453 | + it("uses modelName from request when provided", () => { |
| 454 | + mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]); |
| 455 | + callGeminiAPIBatch([ |
| 456 | + { apiKey: "key", modelName: "gemini-1.5-pro", userParts: [{ text: "Q" }] }, |
| 457 | + ]); |
| 458 | + const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0]; |
| 459 | + expect(calls[0].url).toContain("gemini-1.5-pro"); |
| 460 | + }); |
| 461 | + |
| 462 | + it("falls back to CONFIG.MODEL_NAME when modelName is omitted", () => { |
| 463 | + mockFetchAllResponses([{ candidates: [{ content: { parts: [{ text: "ok" }] } }] }]); |
| 464 | + callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]); |
| 465 | + const calls = (UrlFetchApp.fetchAll as jest.Mock).mock.calls[0][0]; |
| 466 | + expect(calls[0].url).toContain(CONFIG.MODEL_NAME); |
| 467 | + }); |
| 468 | + |
| 469 | + it("returns 'No response.' when candidates are empty", () => { |
| 470 | + mockFetchAllResponses([{ candidates: [] }]); |
| 471 | + const results = callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]); |
| 472 | + expect(results[0].text).toBe("No response."); |
| 473 | + }); |
| 474 | + |
| 475 | + it("populates codePairs when executableCode and codeExecutionResult parts are present", () => { |
| 476 | + mockFetchAllResponses([ |
| 477 | + { |
| 478 | + candidates: [ |
| 479 | + { |
| 480 | + content: { |
| 481 | + parts: [ |
| 482 | + { executableCode: { language: "PYTHON", code: "print(42)" } }, |
| 483 | + { codeExecutionResult: { outcome: "OUTCOME_OK", output: "42\n" } }, |
| 484 | + ], |
| 485 | + }, |
| 486 | + }, |
| 487 | + ], |
| 488 | + }, |
| 489 | + ]); |
| 490 | + const results = callGeminiAPIBatch([{ apiKey: "key", userParts: [{ text: "Q" }] }]); |
| 491 | + expect(results[0].codePairs).toHaveLength(1); |
| 492 | + expect(results[0].codePairs![0].code.code).toBe("print(42)"); |
| 493 | + expect(results[0].codePairs![0].result.output).toBe("42\n"); |
| 494 | + }); |
| 495 | +}); |
0 commit comments