test(api): add route-level coverage for Adobe Firefly /v1/images/edits + fix typecheck/file-size drift

Covers the referenceBlobs upload path, the 4-reference cap error, and the
credentials/rate-limit branches added to the /v1/images/edits route for
adobe-firefly (#8510). Also fixes a Buffer/BodyInit typecheck mismatch in
uploadAdobeFireflyImage and corrects the adobeFireflyClient.ts file-size
baseline entry to match the gate's actual LOC count (it counts the trailing
newline, so the frozen value is 2317, not 2316), plus a testFrozen entry for
adobe-firefly.test.ts's own +159 line growth from this PR. Moves the
handleAdobeFireflyImageGeneration re-export out of the middle of the import
block in imageGeneration.ts for readability.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
artickc
2026-07-25 09:07:47 -03:00
committed by ikelvingo
parent f53ebe0619
commit 62785e972f
4 changed files with 238 additions and 5 deletions

View File

@@ -196,8 +196,8 @@
"_rebaseline_2026_07_23_8252_combo_400_advance": "#8252 (@RaviTharuma) own growth: accountFallback.ts 1932->1940 (+8) + combo.ts 3604->3630 (+26) — advance combo on model-scoped 400s wrapped as invalid/Bad-Request. Irreducible wiring at existing account-fallback + combo dispatch chokepoints. Covered by combo-model-scoped-400-advance.test.ts.",
"_rebaseline_2026_07_23_8247_8248_model_unhealthy": "#8247+#8248 own growth: accountFallback.ts 1940->1941 (+1, irreducible import statement only — the substantive #8248 DEGRADED-pattern classifier was extracted into open-sse/config/errorConfig.ts, which has ample headroom, instead of growing this frozen file; #8247's fix is a single existing-line condition change, net zero lines). Scoping the credits-exhausted 403/429 branch to isCompatibleProvider() (per-model-quota openai/anthropic-compatible-* nicknames) so it stays model-scoped instead of terminalling the whole connection, and classifying NVIDIA NIM 'Function ... DEGRADED' 400 bodies as model-access-denied instead of a raw passthrough 400. Covered by tests/unit/8247-accountfallback-model-unhealthy.test.ts and tests/unit/8248-accountfallback-nvidia-degraded.test.ts.",
"open-sse/services/accountFallback.ts": 1966,
"open-sse/services/adobeFireflyClient.ts": 2316,
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2316 (+upload helpers, extract sources, resolve blob ids).",
"open-sse/services/adobeFireflyClient.ts": 2317,
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 — check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.",
"open-sse/services/batchProcessor.ts": 915,
"open-sse/services/browserBackedChat.ts": 850,
"open-sse/services/claudeCodeCompatible.ts": 1202,
@@ -342,6 +342,8 @@
},
"testCap": 800,
"testFrozen": {
"_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff — new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.",
"tests/unit/adobe-firefly.test.ts": 871,
"_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).",
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",

View File

@@ -74,14 +74,15 @@ import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmin
import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts";
import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts";
import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts";
// Re-export so /v1/images/edits can dispatch Firefly reference-image edits.
export { handleAdobeFireflyImageGeneration };
import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts";
import {
applyPollinationsAnonymousFallback,
reportPollinationsAnonOutcome,
} from "./imageGeneration/pollinationsAnonAuth.ts";
// Re-export so /v1/images/edits can dispatch Firefly reference-image edits.
export { handleAdobeFireflyImageGeneration };
interface KieImageOptions {
model: string;
provider: string;

View File

@@ -1245,7 +1245,7 @@ export async function uploadAdobeFireflyImage(opts: {
cookie: cookieHeader || undefined,
prompt: opts.prompt || "upload",
}),
body: buffer,
body: buffer as unknown as BodyInit,
});
const text = await resp.text().catch(() => "");

View File

@@ -0,0 +1,230 @@
// #8510 (artickc, feat/adobe-firefly-reference-images): route-level coverage for the Adobe
// Firefly branch that /v1/images/edits gained in this PR. Exercises the actual
// POST(request) handler (not the inner handleAdobeFireflyImageGeneration helper directly,
// which tests/unit/adobe-firefly.test.ts already covers) so the credentials /
// rate-limit / 4-reference-cap branches added to route.ts itself are proven, not just the
// downstream service call.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-adobe-firefly-edits-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "adobe-firefly-edits-test-secret";
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
const {
ADOBE_FIREFLY_IMAGE_UPLOAD_URL,
ADOBE_FIREFLY_IMAGE_SUBMIT_URL,
} = await import("../../open-sse/services/adobeFireflyClient.ts");
interface ErrorResponseBody {
error: { message: string; code?: string };
}
interface ImageResponseBody {
data: Array<{ b64_json?: string; url?: string }>;
}
const originalFetch = globalThis.fetch;
async function resetStorage() {
globalThis.fetch = originalFetch;
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
}
async function seedAdobeFireflyConnection(
overrides: { apiKey?: string; rateLimitedUntil?: string | null } = {}
) {
return providersDb.createProviderConnection({
provider: "adobe-firefly",
authType: "apikey",
name: "adobe-firefly-test",
apiKey: overrides.apiKey ?? userImsJwt(),
isActive: true,
testStatus: "active",
rateLimitedUntil: overrides.rateLimitedUntil ?? null,
});
}
// Mirrors tests/unit/adobe-firefly.test.ts's userImsJwt() helper — a synthetic,
// non-guest IMS access token shape so resolveAdobeAccessToken() accepts it directly
// without needing a live cookie->token exchange call.
function userImsJwt(userId = "0EB@AdobeID"): string {
return (
`eyJhbGciOiJSUzI1NiJ9.` +
Buffer.from(
JSON.stringify({ user_id: userId, type: "access_token", client_id: "clio-playground-web" })
).toString("base64url") +
`.` +
"sig".padEnd(40, "x")
);
}
function dataUrlPng(bytes: number[]): string {
return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
}
const REF_A = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1]);
const REF_B = dataUrlPng([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 2]);
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
globalThis.fetch = originalFetch;
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#8510 v1 image edit POST uploads Adobe Firefly reference images and dispatches referenceBlobs", async () => {
await seedAdobeFireflyConnection();
const uploadedIds: string[] = [];
let submitBody: Record<string, unknown> | null = null;
globalThis.fetch = async (url, init: RequestInit = {}) => {
const stringUrl = String(url);
if (stringUrl === ADOBE_FIREFLY_IMAGE_UPLOAD_URL) {
const id = `blob-${uploadedIds.length + 1}`;
uploadedIds.push(id);
return new Response(JSON.stringify({ images: [{ id }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (stringUrl === ADOBE_FIREFLY_IMAGE_SUBMIT_URL) {
submitBody = JSON.parse(String(init.body || "{}"));
return new Response(JSON.stringify({ links: { result: "https://poll.example/job/img1" } }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (stringUrl === "https://poll.example/job/img1") {
return new Response(
JSON.stringify({
status: "COMPLETED",
outputs: [{ image: { presignedUrl: "https://cdn.example/edited-out.png" } }],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
throw new Error(`Unexpected URL: ${stringUrl}`);
};
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "adobe-firefly/nano-banana-pro",
prompt: "combine these two references",
images: [REF_A, REF_B],
}),
})
);
const body = (await response.json()) as ImageResponseBody;
assert.equal(response.status, 200);
assert.equal(body.data[0].url, "https://cdn.example/edited-out.png");
// referenceBlobs upload path: both distinct reference images must have been uploaded
// to Firefly storage and forwarded as referenceBlobs on the generate-async dispatch.
assert.equal(uploadedIds.length, 2, "both reference images must be uploaded individually");
assert.ok(submitBody, "generate-async must have been called");
const referenceBlobs = (submitBody as Record<string, unknown>).referenceBlobs as Array<{
id: string;
}>;
assert.ok(Array.isArray(referenceBlobs), "generate-async payload must carry referenceBlobs");
assert.deepEqual(
referenceBlobs.map((r) => r.id).sort(),
[...uploadedIds].sort()
);
});
test("#8510 v1 image edit POST rejects more than 4 Adobe Firefly reference images", async () => {
await seedAdobeFireflyConnection();
globalThis.fetch = async () => {
throw new Error("Over-cap Adobe Firefly reference sets must not reach upstream");
};
const images = Array.from({ length: 5 }, (_, i) => dataUrlPng([0x89, 0x50, 0x4e, 0x47, i + 1]));
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "adobe-firefly/nano-banana-pro",
prompt: "combine these references",
images,
}),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 400);
assert.match(body.error.message, /Adobe Firefly image edit supports at most 4 reference images/);
// Hard Rule #12 — every error response routes through buildErrorBody/sanitizeErrorMessage
// and must never leak a raw stack trace.
assert.ok(!body.error.message.includes("at /"));
});
test("#8510 v1 image edit POST surfaces missing Adobe Firefly credentials", async () => {
// No adobe-firefly connection seeded at all.
globalThis.fetch = async () => {
throw new Error("Missing-credentials path must not reach upstream");
};
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "adobe-firefly/nano-banana-pro",
prompt: "edit this",
images: [REF_A],
}),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 401);
assert.match(body.error.message, /No credentials for provider: adobe-firefly/);
assert.ok(!body.error.message.includes("at /"));
});
test("#8510 v1 image edit POST surfaces Adobe Firefly rate-limit sentinel", async () => {
await seedAdobeFireflyConnection({ rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() });
globalThis.fetch = async () => {
throw new Error("Rate-limited path must not reach upstream");
};
const response = await imageEditRoute.POST(
new Request("http://localhost/api/v1/images/edits", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "adobe-firefly/nano-banana-pro",
prompt: "edit this",
images: [REF_A],
}),
})
);
const body = (await response.json()) as ErrorResponseBody;
assert.equal(response.status, 429);
assert.match(body.error.message, /All accounts rate limited/);
assert.ok(!body.error.message.includes("at /"));
});