|
| 1 | +/** |
| 2 | + * OpenAI-Compatible Routes Tests (/v1) |
| 3 | + * Tests with mocked fetch - no real API calls |
| 4 | + */ |
| 5 | + |
| 6 | +import { ApiErrorCode } from '@z-image/shared' |
| 7 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 8 | +import { createApp } from '../../app' |
| 9 | + |
| 10 | +describe('OpenAI-compatible routes', () => { |
| 11 | + const app = createApp() |
| 12 | + |
| 13 | + beforeEach(() => { |
| 14 | + vi.stubGlobal('fetch', vi.fn()) |
| 15 | + }) |
| 16 | + |
| 17 | + afterEach(() => { |
| 18 | + vi.unstubAllGlobals() |
| 19 | + }) |
| 20 | + |
| 21 | + it('POST /v1/images/generations defaults to HuggingFace z-image-turbo', async () => { |
| 22 | + const mockFetch = vi.mocked(fetch) |
| 23 | + |
| 24 | + // Gradio queue |
| 25 | + mockFetch.mockResolvedValueOnce({ |
| 26 | + ok: true, |
| 27 | + json: async () => ({ event_id: 'evt-1' }), |
| 28 | + } as Response) |
| 29 | + |
| 30 | + // Gradio result (SSE) |
| 31 | + mockFetch.mockResolvedValueOnce({ |
| 32 | + ok: true, |
| 33 | + text: async () => 'event: complete\ndata: [{"url":"https://hf.space/img.png"}, 42]\n\n', |
| 34 | + } as Response) |
| 35 | + |
| 36 | + const res = await app.request('/v1/images/generations', { |
| 37 | + method: 'POST', |
| 38 | + headers: { 'Content-Type': 'application/json' }, |
| 39 | + body: JSON.stringify({ prompt: 'a cat', size: '1024x1024' }), |
| 40 | + }) |
| 41 | + |
| 42 | + expect(res.status).toBe(200) |
| 43 | + const json = (await res.json()) as { created: number; data: Array<{ url: string }> } |
| 44 | + expect(json.created).toBeTypeOf('number') |
| 45 | + expect(json.data[0]?.url).toBe('https://hf.space/img.png') |
| 46 | + |
| 47 | + expect(mockFetch.mock.calls[0]?.[0]).toContain('mrfakename-z-image-turbo.hf.space') |
| 48 | + }) |
| 49 | + |
| 50 | + it('POST /v1/images/generations supports gitee/ model prefix + gitee: token', async () => { |
| 51 | + const mockFetch = vi.mocked(fetch) |
| 52 | + mockFetch.mockResolvedValueOnce({ |
| 53 | + ok: true, |
| 54 | + json: async () => ({ data: [{ url: 'https://example.com/gitee.png' }] }), |
| 55 | + } as Response) |
| 56 | + |
| 57 | + const res = await app.request('/v1/images/generations', { |
| 58 | + method: 'POST', |
| 59 | + headers: { |
| 60 | + 'Content-Type': 'application/json', |
| 61 | + Authorization: 'Bearer gitee:test-api-key', |
| 62 | + }, |
| 63 | + body: JSON.stringify({ |
| 64 | + model: 'gitee/z-image-turbo', |
| 65 | + prompt: 'a bird', |
| 66 | + size: '1024x768', |
| 67 | + negative_prompt: 'blurry', |
| 68 | + }), |
| 69 | + }) |
| 70 | + |
| 71 | + expect(res.status).toBe(200) |
| 72 | + const json = (await res.json()) as { data: Array<{ url: string }> } |
| 73 | + expect(json.data[0]?.url).toBe('https://example.com/gitee.png') |
| 74 | + |
| 75 | + expect(mockFetch).toHaveBeenCalledWith( |
| 76 | + 'https://ai.gitee.com/v1/images/generations', |
| 77 | + expect.objectContaining({ |
| 78 | + method: 'POST', |
| 79 | + headers: expect.objectContaining({ |
| 80 | + Authorization: 'Bearer test-api-key', |
| 81 | + }), |
| 82 | + }) |
| 83 | + ) |
| 84 | + |
| 85 | + const body = JSON.parse((mockFetch.mock.calls[0]?.[1]?.body as string) || '{}') |
| 86 | + expect(body.width).toBe(1024) |
| 87 | + expect(body.height).toBe(768) |
| 88 | + expect(body.negative_prompt).toBe('blurry') |
| 89 | + }) |
| 90 | + |
| 91 | + it('POST /v1/images/generations supports ms/ model prefix + ms: token', async () => { |
| 92 | + const mockFetch = vi.mocked(fetch) |
| 93 | + |
| 94 | + // submit |
| 95 | + mockFetch.mockResolvedValueOnce({ |
| 96 | + ok: true, |
| 97 | + json: async () => ({ task_id: 'task-123' }), |
| 98 | + } as Response) |
| 99 | + |
| 100 | + // poll |
| 101 | + mockFetch.mockResolvedValueOnce({ |
| 102 | + ok: true, |
| 103 | + json: async () => ({ task_status: 'SUCCEED', output_images: ['https://example.com/ms.png'] }), |
| 104 | + } as Response) |
| 105 | + |
| 106 | + const res = await app.request('/v1/images/generations', { |
| 107 | + method: 'POST', |
| 108 | + headers: { |
| 109 | + 'Content-Type': 'application/json', |
| 110 | + Authorization: 'Bearer ms:token-12345678', |
| 111 | + }, |
| 112 | + body: JSON.stringify({ |
| 113 | + model: 'ms/flux-2', |
| 114 | + prompt: 'a robot', |
| 115 | + }), |
| 116 | + }) |
| 117 | + |
| 118 | + expect(res.status).toBe(200) |
| 119 | + const json = (await res.json()) as { data: Array<{ url: string }> } |
| 120 | + expect(json.data[0]?.url).toBe('https://example.com/ms.png') |
| 121 | + }) |
| 122 | + |
| 123 | + it('POST /v1/images/generations rejects n != 1', async () => { |
| 124 | + const res = await app.request('/v1/images/generations', { |
| 125 | + method: 'POST', |
| 126 | + headers: { 'Content-Type': 'application/json' }, |
| 127 | + body: JSON.stringify({ prompt: 'a cat', n: 2 }), |
| 128 | + }) |
| 129 | + |
| 130 | + expect(res.status).toBe(400) |
| 131 | + const json = (await res.json()) as { code: string } |
| 132 | + expect(json.code).toBe(ApiErrorCode.INVALID_PARAMS) |
| 133 | + }) |
| 134 | + |
| 135 | + it('GET /v1/models returns OpenAI-like list', async () => { |
| 136 | + const res = await app.request('/v1/models') |
| 137 | + expect(res.status).toBe(200) |
| 138 | + |
| 139 | + const json = (await res.json()) as { object: string; data: Array<{ id: string }> } |
| 140 | + expect(json.object).toBe('list') |
| 141 | + expect(json.data.map((m) => m.id)).toContain('z-image-turbo') |
| 142 | + expect(json.data.map((m) => m.id)).toContain('gitee/z-image-turbo') |
| 143 | + expect(json.data.map((m) => m.id)).toContain('ms/flux-2') |
| 144 | + }) |
| 145 | +}) |
| 146 | + |
0 commit comments