import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { handleMeowaGameAssetsRoutes } from '@electron/api/routes/meowa-game-assets'; const getApiKeyMock = vi.hoisted(() => vi.fn()); const storeApiKeyMock = vi.hoisted(() => vi.fn()); const deleteApiKeyMock = vi.hoisted(() => vi.fn()); const readBundledMeowaApiKeyMock = vi.hoisted(() => vi.fn()); const readEmbeddedMeowaApiKeyMock = vi.hoisted(() => vi.fn()); const proxyAwareFetchMock = vi.hoisted(() => vi.fn()); vi.mock('@electron/utils/secure-storage', () => ({ getApiKey: (...args: unknown[]) => getApiKeyMock(...args), storeApiKey: (...args: unknown[]) => storeApiKeyMock(...args), deleteApiKey: (...args: unknown[]) => deleteApiKeyMock(...args), })); vi.mock('@electron/services/meowa-game-assets-release-credential', () => ({ readBundledMeowaApiKey: (...args: unknown[]) => readBundledMeowaApiKeyMock(...args), readEmbeddedMeowaApiKey: (...args: unknown[]) => readEmbeddedMeowaApiKeyMock(...args), })); vi.mock('@electron/utils/proxy-fetch', () => ({ proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args), })); function createResponse() { const chunks: string[] = []; const response = { statusCode: 0, setHeader: vi.fn(), end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), } as unknown as ServerResponse; return { response, json: () => JSON.parse(chunks.join('')) as Record, }; } function createRequest(method: string, body?: unknown): IncomingMessage { const request = new EventEmitter(); Object.assign(request, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, [Symbol.asyncIterator]: async function* () { if (body !== undefined) yield Buffer.from(JSON.stringify(body)); }, }); return request as IncomingMessage; } describe('Meowa game-assets Host API routes', () => { beforeEach(() => { vi.clearAllMocks(); delete process.env.MEOWART_API_KEY; getApiKeyMock.mockResolvedValue(null); readBundledMeowaApiKeyMock.mockResolvedValue(null); readEmbeddedMeowaApiKeyMock.mockReturnValue(null); storeApiKeyMock.mockResolvedValue(true); deleteApiKeyMock.mockResolvedValue(true); }); it('reports configuration without returning the stored credential', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/config'), {} as never, ); expect(response.statusCode).toBe(200); expect(json()).toMatchObject({ configured: true, credentialSource: 'secure-store', provider: 'meowa', }); expect(json()).not.toHaveProperty('quota'); expect(JSON.stringify(json())).not.toContain('meowa-secret'); }); it('hydrates and reports a packaged release credential without returning it', async () => { readBundledMeowaApiKeyMock.mockResolvedValue('meowa-release-secret'); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/config'), {} as never, ); expect(response.statusCode).toBe(200); expect(json()).toMatchObject({ configured: true, credentialSource: 'release-bundle', }); expect(JSON.stringify(json())).not.toContain('meowa-release-secret'); expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'meowa-release-secret'); }); it('persists and reports the embedded credential without returning it', async () => { readEmbeddedMeowaApiKeyMock.mockReturnValue('embedded-meowa-secret'); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/config'), {} as never, ); expect(response.statusCode).toBe(200); expect(json()).toMatchObject({ configured: true, credentialSource: 'embedded' }); expect(JSON.stringify(json())).not.toContain('embedded-meowa-secret'); expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'embedded-meowa-secret'); }); it('prefers the packaged release credential over the embedded fallback', async () => { readBundledMeowaApiKeyMock.mockResolvedValue('release-secret'); readEmbeddedMeowaApiKeyMock.mockReturnValue('embedded-secret'); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/config'), {} as never, ); expect(json()).toMatchObject({ credentialSource: 'release-bundle' }); expect(storeApiKeyMock).toHaveBeenCalledWith('meowa-game-assets', 'release-secret'); expect(readEmbeddedMeowaApiKeyMock).not.toHaveBeenCalled(); }); it('does not expose the removed local quota route', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); const { response } = createResponse(); const handled = await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/quota'), {} as never, ); expect(handled).toBe(false); expect(response.end).not.toHaveBeenCalled(); }); it('proxies template info with the stored credential kept in Main', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ templates: [{ name: 'pixel-character' }] }), { status: 200, headers: { 'content-type': 'application/json' }, })); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/template-info?kind=pixel'), {} as never, ); expect(response.statusCode).toBe(200); expect(json()).toEqual({ templates: [{ name: 'pixel-character' }] }); expect(proxyAwareFetchMock).toHaveBeenCalledWith( 'https://api.meowa.ai/api/pixel-gen/template-info', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer meowa-secret' }) }), ); }); it('converts generation JSON into Meowa multipart form data', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ api_job_id: 'job-1' }), { status: 202, headers: { 'content-type': 'application/json' }, })); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('POST', { kind: 'pixel', templateName: 'pixel-character', requirement: '透明背景的像素角色', templateConfig: { size: 64 }, }), response, new URL('http://127.0.0.1/api/meowa/game-assets/generate'), {} as never, ); expect(response.statusCode).toBe(202); expect(json()).toEqual({ api_job_id: 'job-1' }); const [, init] = proxyAwareFetchMock.mock.calls[0] as [string, RequestInit]; const form = init.body as FormData; expect(form.get('template_name')).toBe('pixel-character'); expect(form.get('requirement')).toBe('透明背景的像素角色'); expect(form.get('template_config')).toBe('{"size":64}'); expect(JSON.stringify([...form.entries()])).not.toContain('meowa-secret'); }); it('forwards generation without a local daily quota check', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ api_job_id: 'job-after-ten' }), { status: 202, headers: { 'content-type': 'application/json' }, })); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('POST', { kind: 'pixel', templateName: 'pixel-character', requirement: '透明背景的像素角色', }), response, new URL('http://127.0.0.1/api/meowa/game-assets/generate'), {} as never, ); expect(response.statusCode).toBe(202); expect(json()).toEqual({ api_job_id: 'job-after-ten' }); expect(proxyAwareFetchMock).toHaveBeenCalledOnce(); }); it('forwards Meowa rejection when a generation submission fails', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); proxyAwareFetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'invalid template' }), { status: 400, headers: { 'content-type': 'application/json' }, })); const { response } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('POST', { kind: 'pixel', templateName: 'pixel-character', requirement: '透明背景的像素角色', }), response, new URL('http://127.0.0.1/api/meowa/game-assets/generate'), {} as never, ); expect(response.statusCode).toBe(400); }); it('downloads bytes through Main without exposing a signed upstream URL', async () => { getApiKeyMock.mockResolvedValue('meowa-secret'); proxyAwareFetchMock.mockResolvedValue(new Response(Buffer.from('png-bytes'), { status: 200, headers: { 'content-type': 'image/png' }, })); const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('GET'), response, new URL('http://127.0.0.1/api/meowa/game-assets/download?kind=pixel&id=job-1'), {} as never, ); expect(response.statusCode).toBe(200); expect(json()).toMatchObject({ success: true, mimeType: 'image/png', dataBase64: Buffer.from('png-bytes').toString('base64') }); expect(JSON.stringify(json())).not.toContain('meowa-secret'); }); it('blocks generation before network access when no credential is configured', async () => { const { response, json } = createResponse(); await handleMeowaGameAssetsRoutes( createRequest('POST', { kind: 'pixel', templateName: 'pixel-character', requirement: 'test' }), response, new URL('http://127.0.0.1/api/meowa/game-assets/generate'), {} as never, ); expect(response.statusCode).toBe(409); expect(json()).toMatchObject({ code: 'MEOWA_API_KEY_MISSING', error: 'Meowa 素材服务尚未配置,请联系管理员。', }); expect(proxyAwareFetchMock).not.toHaveBeenCalled(); }); });