diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index fe21af09f4..fa4792d638 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -4,7 +4,6 @@ */ import { Injectable, Inject } from '@nestjs/common'; -import fetch from 'node-fetch'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -131,9 +130,6 @@ export class AiService { @bindThis private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeout); - try { const form = new FormData(); for (let i = 0; i < chunk.length; i++) { @@ -148,14 +144,13 @@ export class AiService { headers['Authorization'] = `Bearer ${apiKey}`; } - const res = await fetch(url, { + const res = await this.httpRequestService.send(url, { method: 'POST', headers, body: form, - // 外部サービスとして通常の proxy / private address 制限を適用する。 - // サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。 - agent: (u) => this.httpRequestService.getAgentByUrl(u), - signal: controller.signal, + timeout, + }, { + throwErrorWhenResponseNotOk: false, }); if (!res.ok) { @@ -181,8 +176,6 @@ export class AiService { } catch (err) { this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`); return chunk.map(() => null); - } finally { - clearTimeout(timer); } } } diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 5714bde8bf..f7c76f4705 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from '@/core/activitypub/type.js'; -import type { Response } from 'node-fetch'; +import type { BodyInit, Response } from 'node-fetch'; import type { URL } from 'node:url'; export type HttpRequestSendOptions = { @@ -311,7 +311,7 @@ export class HttpRequestService { url: string, args: { method?: string, - body?: string, + body?: BodyInit, headers?: Record, timeout?: number, size?: number, diff --git a/packages/backend/test/unit/AiService.ts b/packages/backend/test/unit/AiService.ts index 1933bd7271..83bf33c079 100644 --- a/packages/backend/test/unit/AiService.ts +++ b/packages/backend/test/unit/AiService.ts @@ -4,19 +4,12 @@ */ import { describe, test, expect, vi, beforeEach } from 'vitest'; -import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js'; import type { MiMeta } from '@/models/_.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js'; import type { LoggerService } from '@/core/LoggerService.js'; +import { AiService, type Prediction } from '@/core/AiService.js'; -// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と -// レスポンス解釈を検証する。 -const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() })); -vi.mock('node-fetch', () => ({ default: fetchMock })); - -const { AiService } = await import('@/core/AiService.js'); - -let getAgentByUrlMock: ReturnType; +const sendMock = vi.fn(); const DEFAULT_META = { sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null, @@ -25,10 +18,9 @@ const DEFAULT_META = { sensitiveMediaDetectionMaxImagesPerRequest: 4, }; -function makeService(metaOverrides: Partial = {}): AiServiceType { +function makeService(metaOverrides: Partial = {}): AiService { const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta; - getAgentByUrlMock = vi.fn(() => undefined); - const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService; + const httpRequestService = { send: sendMock } as unknown as HttpRequestService; const loggerService = { getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }), } as unknown as LoggerService; @@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s); describe('AiService', () => { beforeEach(() => { - fetchMock.mockReset(); + sendMock.mockReset(); }); test('正常: 送信順を保った予測値配列を返す', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: true, predictions: [{ className: 'Porn', probability: 0.8 }] }, ])); @@ -66,30 +58,35 @@ describe('AiService', () => { [{ className: 'Neutral', probability: 0.99 }], [{ className: 'Porn', probability: 0.8 }], ]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images'); }); - test('外部サービス: 通常の outbound agent を使用する', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + test('外部サービス: HttpRequestService を使用する', async () => { + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' }); await svc.detectSensitiveMany([buf('a')]); - const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown }; - requestOptions.agent(new URL('https://detector.example.com/v1/detect-images')); - expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL)); + expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', { + method: 'POST', + headers: {}, + body: expect.any(FormData), + timeout: 5000, + }, { + throwErrorWhenResponseNotOk: false, + }); }); test('detectSensitive: 単一画像はバッチの先頭を返す', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const svc = makeService(); const res = await svc.detectSensitive(buf('a')); expect(res).toEqual(neutral()); }); test('部分失敗: 失敗パーツのみ null になる', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } }, ])); @@ -100,14 +97,14 @@ describe('AiService', () => { }); test('非200: チャンク全件 null(例外を投げない)', async () => { - fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); + sendMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) }); const svc = makeService(); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); expect(res).toEqual([null, null]); }); test('通信エラー: チャンク全件 null(例外を投げない)', async () => { - fetchMock.mockRejectedValue(new Error('network down')); + sendMock.mockRejectedValue(new Error('network down')); const svc = makeService(); const res = await svc.detectSensitiveMany([buf('a')]); expect(res).toEqual([null]); @@ -117,11 +114,11 @@ describe('AiService', () => { const svc = makeService({ sensitiveMediaDetectionApiUrl: null }); const res = await svc.detectSensitiveMany([buf('a'), buf('b')]); expect(res).toEqual([null, null]); - expect(fetchMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); }); test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => { - fetchMock.mockResolvedValue(okResponse([ + sendMock.mockResolvedValue(okResponse([ { success: true, predictions: neutral() }, { success: true, predictions: neutral() }, { success: true, predictions: neutral() }, @@ -130,22 +127,24 @@ describe('AiService', () => { const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 }); const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]); // 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。 - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sendMock).toHaveBeenCalledTimes(3); expect(res).toHaveLength(5); expect(res.every(x => x != null)).toBe(true); }); test('APIキー設定時のみ Authorization: Bearer を付与する', async () => { - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' }); await withKey.detectSensitiveMany([buf('a')]); - expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret'); + const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record }).headers; + expect(withKeyHeaders.Authorization).toBe('Bearer secret'); - fetchMock.mockClear(); - fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); + sendMock.mockClear(); + sendMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }])); const withoutKey = makeService(); await withoutKey.detectSensitiveMany([buf('a')]); - expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined(); + const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record }).headers; + expect(withoutKeyHeaders.Authorization).toBeUndefined(); }); });