Files
flynn/src/channels/line/adapter.test.ts
T

241 lines
7.3 KiB
TypeScript

import { createHmac } from 'crypto';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
import { LineAdapter } from './adapter.js';
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function mockReq(body: string, secret: string): IncomingMessage {
const signature = createHmac('sha256', secret).update(body).digest('base64');
const req = {
headers: { 'x-line-signature': signature },
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('LineAdapter', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockReset();
});
it('has name line and starts disconnected', () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
});
expect(adapter.name).toBe('line');
expect(adapter.status).toBe('disconnected');
});
it('send posts LINE push API', async () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
});
await adapter.connect();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '',
} as Response);
await adapter.send('U123', { text: 'hello line' });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe('https://api.line.me/v2/bot/message/push');
});
it('send emits URL attachments and warns for binary attachments', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
});
await adapter.connect();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '',
} as Response);
await adapter.send('U123', {
text: 'hello line',
attachments: [
{ mimeType: 'text/plain', url: 'https://example.com/file.txt', filename: 'file.txt' },
{ mimeType: 'image/png', 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.messages?.[0]?.text).toBe('file.txt: https://example.com/file.txt');
expect(thirdBody.messages?.[0]?.text).toBe('[LINE] Binary attachment not uploaded yet: attachment (image/png).');
expect(warnSpy).toHaveBeenCalledWith('LINE: skipping attachment data (image/png) — 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 LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
minio: {
enabled: true,
endpoint: 'localhost:9000',
accessKey: 'minio',
secretKey: 'secret',
bucket: 'flynn',
prefix: 'channels/line',
secure: false,
},
minioExecRunner: vi.fn(async (_file, args) => {
if (args[0] === 'share') {
return { stdout: '{"share":"https://minio.local/share/file.png"}\n', stderr: '' };
}
return { stdout: '', stderr: '' };
}),
});
await adapter.connect();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '',
} as Response);
await adapter.send('U123', {
text: '',
attachments: [
{ mimeType: 'image/png', data: 'aGVsbG8=', filename: 'file.png' },
],
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body ?? '{}'));
expect(body.messages?.[0]?.text).toBe('file.png: https://minio.local/share/file.png');
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('send delivers URL attachment even when text is empty', async () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
});
await adapter.connect();
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '',
} as Response);
await adapter.send('U123', {
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.messages?.[0]?.text).toBe('file.txt: https://example.com/file.txt');
});
it('handleRequest validates signature and dispatches text event', async () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
requireMention: false,
});
const inbound: Array<{ channel: string; text: string; senderId: string }> = [];
adapter.onMessage((msg) => inbound.push({ channel: msg.channel, text: msg.text, senderId: msg.senderId }));
const body = JSON.stringify({
events: [{
type: 'message',
timestamp: 1,
source: { type: 'user', userId: 'U123' },
message: { id: 'm1', type: 'text', text: 'ping' },
}],
});
const req = mockReq(body, 'secret');
const { res, state } = mockRes();
await adapter.handleRequest(req, res);
expect(state.statusCode).toBe(200);
expect(inbound).toEqual([{ channel: 'line', text: 'ping', senderId: 'U123' }]);
});
it('drops group messages without mention when require_mention=true', async () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
requireMention: true,
mentionName: 'flynn',
});
const handler = vi.fn();
adapter.onMessage(handler);
await adapter.handleEvent({
type: 'message',
source: { type: 'group', groupId: 'G123', userId: 'U123' },
message: { id: 'm1', type: 'text', text: 'hello there' },
});
expect(handler).not.toHaveBeenCalled();
});
it('rejects invalid signature', async () => {
const adapter = new LineAdapter({
channelAccessToken: 'token',
channelSecret: 'secret',
});
const body = JSON.stringify({ events: [] });
const req = {
headers: { 'x-line-signature': 'invalid' },
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;
const { res, state } = mockRes();
await adapter.handleRequest(req, res);
expect(state.statusCode).toBe(401);
});
});