import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { IncomingMessage, ServerResponse } from 'http'; import { ZaloAdapter } from './adapter.js'; const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); function mockReq(body: string, token?: string): IncomingMessage { const req = { headers: token ? { 'x-zalo-token': token } : {}, on(event: string, handler: (...args: unknown[]) => void) { if (event === 'data') { handler(Buffer.from(body, 'utf8')); } if (event === 'end') { handler(); } return this; }, off: () => req, destroy: () => undefined, } as unknown as IncomingMessage; return req; } function mockRes() { const state = { statusCode: 0, body: '' }; const res = { writeHead: (code: number) => { state.statusCode = code; }, end: (chunk?: string) => { state.body = chunk ?? ''; }, } as unknown as ServerResponse; return { res, state }; } describe('ZaloAdapter', () => { beforeEach(() => { vi.clearAllMocks(); mockFetch.mockReset(); }); it('has name zalo and starts disconnected', () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); expect(adapter.name).toBe('zalo'); expect(adapter.status).toBe('disconnected'); }); it('send posts to zalo message API', async () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); await adapter.connect(); mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '', } as Response); await adapter.send('uid-1', { text: 'hello zalo' }); expect(mockFetch).toHaveBeenCalledTimes(1); expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/v3.0/oa/message/cs'); }); it('send emits URL attachments and warns for binary attachments', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); await adapter.connect(); mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '', } as Response); await adapter.send('uid-1', { text: 'hello zalo', attachments: [ { mimeType: 'text/plain', url: 'https://example.com/file.txt', filename: 'file.txt' }, { mimeType: 'application/pdf', data: 'aGVsbG8=' }, ], }); expect(mockFetch).toHaveBeenCalledTimes(3); const secondBody = JSON.parse(String(mockFetch.mock.calls[1]?.[1]?.body ?? '{}')); const thirdBody = JSON.parse(String(mockFetch.mock.calls[2]?.[1]?.body ?? '{}')); expect(secondBody.message?.text).toBe('file.txt: https://example.com/file.txt'); expect(thirdBody.message?.text).toBe('[Zalo] Binary attachment not uploaded yet: attachment (application/pdf).'); expect(warnSpy).toHaveBeenCalledWith('Zalo: skipping attachment data (application/pdf) — upload not implemented'); warnSpy.mockRestore(); }); it('uploads binary attachments to MinIO and sends share URL when configured', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const adapter = new ZaloAdapter({ oaAccessToken: 'token', minio: { enabled: true, endpoint: 'localhost:9000', accessKey: 'minio', secretKey: 'secret', bucket: 'flynn', prefix: 'channels/zalo', secure: false, }, minioExecRunner: vi.fn(async (_file, args) => { if (args[0] === 'share') { return { stdout: '{"share":"https://minio.local/share/file.pdf"}\n', stderr: '' }; } return { stdout: '', stderr: '' }; }), }); await adapter.connect(); mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '', } as Response); await adapter.send('uid-1', { text: '', attachments: [ { mimeType: 'application/pdf', data: 'aGVsbG8=', filename: 'file.pdf' }, ], }); expect(mockFetch).toHaveBeenCalledTimes(1); const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}')); expect(body.message?.text).toBe('file.pdf: https://minio.local/share/file.pdf'); expect(warnSpy).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); it('falls back to warning notice when MinIO upload fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const adapter = new ZaloAdapter({ oaAccessToken: 'token', minio: { enabled: true, endpoint: 'localhost:9000', accessKey: 'minio', secretKey: 'secret', bucket: 'flynn', prefix: 'channels/zalo', secure: false, }, minioExecRunner: vi.fn(async () => { throw new Error('mc unavailable'); }), }); await adapter.connect(); mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '', } as Response); await adapter.send('uid-1', { text: '', attachments: [{ mimeType: 'application/pdf', data: 'aGVsbG8=', filename: 'file.pdf' }], }); expect(mockFetch).toHaveBeenCalledTimes(1); const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}')); expect(body.message?.text).toBe('[Zalo] Binary attachment not uploaded yet: file.pdf (application/pdf).'); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Zalo: MinIO upload failed')); expect(warnSpy).toHaveBeenCalledWith('Zalo: skipping attachment data (application/pdf) — upload not implemented'); warnSpy.mockRestore(); }); it('send delivers URL attachment even when text is empty', async () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); await adapter.connect(); mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '', } as Response); await adapter.send('uid-1', { text: ' ', attachments: [{ mimeType: 'text/plain', url: 'https://example.com/file.txt', filename: 'file.txt' }], }); expect(mockFetch).toHaveBeenCalledTimes(1); const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}')); expect(body.message?.text).toBe('file.txt: https://example.com/file.txt'); }); it('handleEvent emits inbound message', async () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token', requireMention: false }); const inbound: Array<{ channel: string; senderId: string; text: string }> = []; adapter.onMessage((msg) => inbound.push({ channel: msg.channel, senderId: msg.senderId, text: msg.text })); await adapter.handleEvent({ sender: { id: 'uid-1' }, recipient: { id: 'oa-1' }, message: { msg_id: 'm1', text: 'ping' }, timestamp: 123, }); expect(inbound).toEqual([{ channel: 'zalo', senderId: 'uid-1', text: 'ping' }]); }); it('enforces webhook token when configured', async () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token', webhookToken: 'secret' }); const body = JSON.stringify({ sender: { id: 'uid-1' }, message: { msg_id: 'm1', text: 'ping' }, }); const req = mockReq(body, 'wrong'); const { res, state } = mockRes(); await adapter.handleRequest(req, res); expect(state.statusCode).toBe(401); }); it('drops messages missing required mention', async () => { const adapter = new ZaloAdapter({ oaAccessToken: 'token', requireMention: true, mentionName: 'flynn' }); const handler = vi.fn(); adapter.onMessage(handler); await adapter.handleEvent({ sender: { id: 'uid-1' }, message: { msg_id: 'm1', text: 'hello there' }, }); expect(handler).not.toHaveBeenCalled(); }); });