mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
Merge pull request #247 from hijak/fix/nanobanana-async
## Review ✅ **Aprovado** — implementação clara e bem estruturada. **O que a PR faz:** - Implementa o fluxo async correto da NanoBanana API: submit task → poll /record-info until successFlag=1 - Mantém backward compatibility para providers retornando payload síncrono - Infere aspectRatio e resolution do campo size (padrão OpenAI) - Permite tunagem via env vars (NANOBANANA_POLL_TIMEOUT_MS, NANOBANANA_POLL_INTERVAL_MS) - Adiciona 2 arquivos de teste com cobertura específica **Qualidade:** - Código limpo e bem documentado - Testes adicionados e passando localmente - Não quebra implementações existentes (sync path preservado) Obrigado pela contribuição @hijak! 🎉
This commit is contained in:
128
tests/unit/nanobanana-image-generation.test.mjs
Normal file
128
tests/unit/nanobanana-image-generation.test.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function inferAspectRatioFromSize(size) {
|
||||
if (typeof size !== "string") return null;
|
||||
const [wRaw, hRaw] = size.split("x");
|
||||
const width = Number(wRaw);
|
||||
const height = Number(hRaw);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
||||
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
|
||||
const div = gcd(Math.round(width), Math.round(height));
|
||||
return `${Math.round(width / div)}:${Math.round(height / div)}`;
|
||||
}
|
||||
|
||||
function inferResolutionFromSize(size) {
|
||||
if (typeof size !== "string") return null;
|
||||
const [wRaw, hRaw] = size.split("x");
|
||||
const width = Number(wRaw);
|
||||
const height = Number(hRaw);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
||||
const longestSide = Math.max(width, height);
|
||||
if (longestSide <= 1024) return "1K";
|
||||
if (longestSide <= 2048) return "2K";
|
||||
return "4K";
|
||||
}
|
||||
|
||||
test("nanobanana pro payload inference maps size to aspectRatio and resolution", () => {
|
||||
const aspectRatio = inferAspectRatioFromSize("1024x1280");
|
||||
const resolution = inferResolutionFromSize("1024x1280");
|
||||
|
||||
assert.equal(aspectRatio, "4:5");
|
||||
assert.equal(resolution, "2K");
|
||||
});
|
||||
|
||||
test("nanobanana async flow (submit->poll->url) normalizes to OpenAI-style url item", async () => {
|
||||
const calls = [];
|
||||
|
||||
const fetchMock = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), method: options.method || "GET", body: options.body });
|
||||
|
||||
if (String(url).includes("/generate")) {
|
||||
return new Response(JSON.stringify({ code: 200, msg: "success", data: { taskId: "t-1" } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (String(url).includes("/record-info")) {
|
||||
const polls = calls.filter((c) => c.url.includes("/record-info")).length;
|
||||
if (polls < 2) {
|
||||
return new Response(
|
||||
JSON.stringify({ code: 200, msg: "success", data: { successFlag: 0 } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: {
|
||||
successFlag: 1,
|
||||
response: { resultImageUrl: "https://cdn.example.com/final.jpg" },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const submit = await fetch("https://api.nanobananaapi.ai/api/v1/nanobanana/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer test" },
|
||||
body: JSON.stringify({ prompt: "space" }),
|
||||
});
|
||||
const submitData = await submit.json();
|
||||
const taskId = submitData.data.taskId;
|
||||
|
||||
let finalData;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const poll = await fetch(
|
||||
`https://api.nanobananaapi.ai/api/v1/nanobanana/record-info?taskId=${encodeURIComponent(taskId)}`
|
||||
);
|
||||
const pollData = await poll.json();
|
||||
if (pollData.data.successFlag === 1) {
|
||||
finalData = pollData.data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(finalData);
|
||||
assert.equal(finalData.response.resultImageUrl, "https://cdn.example.com/final.jpg");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("nanobanana b64 mode can convert result URL bytes to b64_json", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("https://cdn.example.com/final.jpg")) {
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch("https://cdn.example.com/final.jpg");
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const b64 = buf.toString("base64");
|
||||
assert.equal(b64, "iVBORw==");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
124
tests/unit/nanobanana-image-handler.test.mjs
Normal file
124
tests/unit/nanobanana-image-handler.test.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts";
|
||||
|
||||
test("handleImageGeneration(nanobanana): async submit+poll returns URL payload", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let pollCount = 0;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const u = String(url);
|
||||
|
||||
if (u.includes("/generate-pro")) {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.prompt, "galaxy test");
|
||||
assert.equal(body.aspectRatio, "4:5");
|
||||
assert.equal(body.resolution, "2K");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-1" } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (u.includes("/record-info")) {
|
||||
pollCount += 1;
|
||||
if (pollCount < 2) {
|
||||
return new Response(
|
||||
JSON.stringify({ code: 200, msg: "success", data: { successFlag: 0 } }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: {
|
||||
successFlag: 1,
|
||||
response: { resultImageUrl: "https://cdn.example.com/handler-result.jpg" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${u}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
body: {
|
||||
model: "nanobanana/nanobanana-pro",
|
||||
prompt: "galaxy test",
|
||||
size: "1024x1280",
|
||||
poll_interval_ms: 1,
|
||||
},
|
||||
credentials: { apiKey: "test-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.data.length, 1);
|
||||
assert.equal(result.data.data[0].url, "https://cdn.example.com/handler-result.jpg");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleImageGeneration(nanobanana): response_format=b64_json converts URL to b64", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
|
||||
if (u.includes("/generate")) {
|
||||
return new Response(
|
||||
JSON.stringify({ code: 200, msg: "success", data: { taskId: "task-handler-2" } }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (u.includes("/record-info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: {
|
||||
successFlag: 1,
|
||||
response: { resultImageUrl: "https://cdn.example.com/handler-result-2.jpg" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (u.includes("handler-result-2.jpg")) {
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { status: 200 });
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected URL: ${u}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
body: {
|
||||
model: "nanobanana/nanobanana-flash",
|
||||
prompt: "galaxy test",
|
||||
response_format: "b64_json",
|
||||
},
|
||||
credentials: { apiKey: "test-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.data.length, 1);
|
||||
assert.equal(result.data.data[0].b64_json, "iVBORw==");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user