feat: 支持对话与设置页单图编辑
This commit is contained in:
1 parent
7e2ec28b80
commit
99e087112b
8 files changed
+361
-30
No files matched your search
+128
-1
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { validateBaseUrl, validateGeneration, listModels, generateImages } from '../lib/engine.mjs';
|
||||
import { validateBaseUrl, validateGeneration, listModels, generateImages, validateEditImage, editImages } from '../lib/engine.mjs';
|
||||
|
||||
const apiKey = 'secret-do-not-echo';
|
||||
const input = { model: 'explicit-third-party-model', prompt: 'a blue bird' };
|
||||
@@ -31,6 +31,70 @@ async function requestJson(req) {
|
||||
return JSON.parse(Buffer.concat(chunks).toString());
|
||||
}
|
||||
|
||||
test('edit image validation enforces bytes, signatures, upload limit and owned copies', () => {
|
||||
const padded = Buffer.concat([Buffer.from('prefix'), png, Buffer.from('suffix')]);
|
||||
const view = new Uint8Array(padded.buffer, padded.byteOffset + 6, png.length);
|
||||
const validated = validateEditImage(view);
|
||||
assert.equal(validated.mediaType, 'image/png');
|
||||
assert.ok(Buffer.isBuffer(validated.data));
|
||||
assert.deepEqual(validated.data, png);
|
||||
view.fill(0);
|
||||
assert.deepEqual(validated.data, png);
|
||||
for (const data of [undefined, null, 'image.png', [], new ArrayBuffer(8), new Uint16Array(8), Buffer.alloc(0), Buffer.from('GIF89a'), Buffer.from('<svg/>')]) {
|
||||
assert.throws(() => validateEditImage(data), /PNG, JPEG, or WebP/);
|
||||
}
|
||||
const boundary = Buffer.alloc(20 * 1024 * 1024 + 1);
|
||||
png.copy(boundary);
|
||||
assert.throws(() => validateEditImage(boundary), /20 MiB/);
|
||||
assert.equal(validateEditImage(boundary.subarray(0, -1)).data.length, 20 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('edits upload real multipart with singular image, correct filename/type and validated fields', async t => {
|
||||
const fixtures = [
|
||||
[png, 'image/png', 'png'],
|
||||
[Buffer.from([255, 216, 255, 224, 0, 0]), 'image/jpeg', 'jpg'],
|
||||
[Buffer.from('RIFF\x04\0\0\0WEBP', 'latin1'), 'image/webp', 'webp'],
|
||||
];
|
||||
let index = 0;
|
||||
const specified = { ...input, prompt: '把背景换成海边', n: 2, size: 'auto', quality: 'high', background: 'opaque', output_format: 'webp' };
|
||||
const base = await server(t, async (req, res) => {
|
||||
const [bytes, type, extension] = fixtures[index++];
|
||||
assert.equal(req.method, 'POST');
|
||||
assert.equal(req.url, '/custom/v1/images/edits');
|
||||
assert.equal(req.headers.authorization, `Bearer ${apiKey}`);
|
||||
assert.match(req.headers['content-type'], /^multipart\/form-data; boundary=/);
|
||||
const request = new Request('http://localhost', { method: 'POST', headers: req.headers, body: req, duplex: 'half' });
|
||||
const form = await request.formData();
|
||||
const expected = index === 1 ? specified : { ...input, n: 1 };
|
||||
assert.deepEqual([...form.keys()].sort(), [...Object.keys(expected), 'image'].sort());
|
||||
for (const [key, value] of Object.entries(expected)) assert.equal(form.get(key), String(value));
|
||||
assert.equal(form.getAll('image').length, 1);
|
||||
const file = form.get('image');
|
||||
assert.equal(file.name, `image.${extension}`);
|
||||
assert.equal(file.type, type);
|
||||
assert.deepEqual(Buffer.from(await file.arrayBuffer()), bytes);
|
||||
json(res, { data: [{ b64_json: png.toString('base64'), revised_prompt: 'edited bird' }] });
|
||||
});
|
||||
for (const [i, [image]] of fixtures.entries()) {
|
||||
const options = i === 0 ? specified : input;
|
||||
const result = await editImages({ baseUrl: `${base}/custom/v1/`, apiKey, input: { ...options, ignored: true }, image });
|
||||
assert.deepEqual(result, { model: options.model, prompt: options.prompt, images: [{ data: png, mediaType: 'image/png', revisedPrompt: 'edited bird' }] });
|
||||
}
|
||||
assert.equal(index, 3);
|
||||
});
|
||||
|
||||
test('edits reject invalid images and options before any API request', async t => {
|
||||
let count = 0;
|
||||
const baseUrl = await server(t, (req, res) => { count++; json(res, { data: [] }); });
|
||||
for (const image of [Buffer.alloc(0), Buffer.from('GIF89a'), Buffer.alloc(20 * 1024 * 1024 + 1)]) {
|
||||
await assert.rejects(editImages({ baseUrl, apiKey, input, image }));
|
||||
}
|
||||
for (const change of [{ model: '' }, { prompt: '' }, { n: 5 }, { quality: 'invalid' }]) {
|
||||
await assert.rejects(editImages({ baseUrl, apiKey, input: { ...input, ...change }, image: png }));
|
||||
}
|
||||
assert.equal(count, 0);
|
||||
});
|
||||
|
||||
test('base URL normalization preserves API prefix and rejects unsafe parts', () => {
|
||||
assert.equal(validateBaseUrl(' HTTPS://EXAMPLE.COM/v1/// '), 'https://example.com/v1');
|
||||
for (const value of ['', null, '/v1', 'ftp://example.com', 'https://a:b@example.com', 'https://example.com?x=1', 'https://example.com#x', 'https://example.com?', 'https://example.com#']) {
|
||||
@@ -207,6 +271,69 @@ test('180 second timeout aborts a stalled request', async t => {
|
||||
assert.equal(configuredTimeout, 180_000);
|
||||
});
|
||||
|
||||
test('edit response downloads omit auth and retain output validation and bounds', async t => {
|
||||
const imageBase = await server(t, (req, res) => {
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
res.end(png);
|
||||
});
|
||||
let payload = { data: [{ url: `${imageBase}/image` }] };
|
||||
let oversized = false;
|
||||
const baseUrl = await server(t, (req, res) => {
|
||||
if (oversized) { res.writeHead(200, { 'Content-Length': 64 * 1024 * 1024 + 1 }); res.flushHeaders(); }
|
||||
else json(res, payload);
|
||||
});
|
||||
const edit = () => editImages({ baseUrl, apiKey, input, image: png });
|
||||
assert.deepEqual((await edit()).images, [{ data: png, mediaType: 'image/png' }]);
|
||||
payload = { data: [{ b64_json: '!!!!' }] };
|
||||
await assert.rejects(edit(), /invalid base64/);
|
||||
payload = { data: [] };
|
||||
await assert.rejects(edit(), /malformed image response/);
|
||||
oversized = true;
|
||||
await assert.rejects(edit(), /64 MB response limit/);
|
||||
});
|
||||
|
||||
test('edit API failure is sanitized and never retried', async t => {
|
||||
let count = 0;
|
||||
const baseUrl = await server(t, (req, res) => { count++; res.writeHead(503); res.end(`provider-private ${apiKey}`); });
|
||||
await assert.rejects(editImages({ baseUrl, apiKey, input, image: png }), error => {
|
||||
assert.equal(error.message, 'API request failed with HTTP 503.');
|
||||
assert.equal(error.cause, undefined);
|
||||
return true;
|
||||
});
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
test('edits cancel pending API requests, downloads and pre-aborted requests', async t => {
|
||||
let count = 0;
|
||||
let controller = new AbortController();
|
||||
let download = false;
|
||||
const imageBase = await server(t, (req, res) => {
|
||||
assert.equal(req.headers.authorization, undefined);
|
||||
res.write(png.subarray(0, 4));
|
||||
controller.abort(new Error(apiKey));
|
||||
});
|
||||
const baseUrl = await server(t, (req, res) => {
|
||||
count++;
|
||||
if (download) json(res, { data: [{ url: `${imageBase}/slow` }] });
|
||||
else controller.abort(new Error(apiKey));
|
||||
});
|
||||
const edit = signal => editImages({ baseUrl, apiKey, input, image: png, signal });
|
||||
await assert.rejects(edit(AbortSignal.abort(apiKey)), /^Error: Request aborted\.$/);
|
||||
assert.equal(count, 0);
|
||||
await assert.rejects(edit(controller.signal), /^Error: Request aborted\.$/);
|
||||
controller = new AbortController();
|
||||
download = true;
|
||||
await assert.rejects(edit(controller.signal), /^Error: Request aborted\.$/);
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
test('edits share the 180 second operation timeout', async t => {
|
||||
const baseUrl = await server(t, () => {});
|
||||
const original = globalThis.setTimeout;
|
||||
t.mock.method(globalThis, 'setTimeout', (callback, milliseconds, ...args) => original(callback, milliseconds === 180_000 ? 20 : milliseconds, ...args));
|
||||
await assert.rejects(editImages({ baseUrl, apiKey, input, image: png }), /timed out after 180 seconds/);
|
||||
});
|
||||
|
||||
test('pre-aborted signal does not send an API request', async t => {
|
||||
let count = 0;
|
||||
const baseUrl = await server(t, (req, res) => { count++; json(res, { data: [] }); });
|
||||
|
||||
Reference in new issue
Block a user