231 lines
7.6 KiB
TypeScript
231 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|