mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
416 lines
12 KiB
JavaScript
416 lines
12 KiB
JavaScript
/*
|
||
* Copyright (c) 2026 by Christian Kellner.
|
||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
// Mock external deps BEFORE importing the module under test.
|
||
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
||
vi.mock('../../lib/services/storage/jobStorage.js', () => ({
|
||
getJob: (jobKey) => ({ id: jobKey, name: jobKey }),
|
||
}));
|
||
vi.mock('../../lib/services/markdown.js', () => ({
|
||
markdown2Html: () => '',
|
||
}));
|
||
|
||
// Helpers to build mock fetch responses.
|
||
function jsonOk(body = { ok: true }) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify(body),
|
||
};
|
||
}
|
||
|
||
function jsonErr(status, body) {
|
||
return {
|
||
ok: false,
|
||
status,
|
||
text: async () => JSON.stringify(body),
|
||
};
|
||
}
|
||
|
||
function imageOk(bytes = new Uint8Array([0xff, 0xd8, 0xff])) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
headers: {
|
||
get: (h) => {
|
||
const k = h.toLowerCase();
|
||
if (k === 'content-type') return 'image/jpeg';
|
||
if (k === 'content-length') return String(bytes.byteLength);
|
||
return null;
|
||
},
|
||
},
|
||
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||
};
|
||
}
|
||
|
||
// Globals are mocked too so buildPhotoFormData (which uses global fetch) can be
|
||
// intercepted by the same single mock.
|
||
let mockNodeFetch;
|
||
let mockGlobalFetch;
|
||
let send;
|
||
|
||
beforeEach(async () => {
|
||
// Reset modules to get a fresh import with our mocks applied.
|
||
vi.resetModules();
|
||
const nodeFetchMod = await import('node-fetch');
|
||
mockNodeFetch = nodeFetchMod.default;
|
||
mockNodeFetch.mockReset();
|
||
|
||
mockGlobalFetch = vi.fn();
|
||
vi.stubGlobal('fetch', mockGlobalFetch);
|
||
|
||
({ send } = await import('../../lib/notification/adapter/telegram.js'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
const baseConfig = {
|
||
id: 'telegram',
|
||
fields: { token: 'TKN', chatId: '999' },
|
||
};
|
||
|
||
describe('telegram send() - HTTP URL path (default for .jpg / .png)', () => {
|
||
it('POSTs JSON to sendPhoto for a .jpg image URL', async () => {
|
||
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 'Listing',
|
||
link: 'https://example.com/a',
|
||
address: 'Addr',
|
||
price: '500€',
|
||
size: '50m²',
|
||
image: 'https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||
const [url, opts] = mockNodeFetch.mock.calls[0];
|
||
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||
expect(opts.method).toBe('post');
|
||
expect(opts.headers?.['Content-Type']).toBe('application/json');
|
||
const body = JSON.parse(opts.body);
|
||
expect(body.chat_id).toBe('999');
|
||
expect(body.photo).toBe('https://mms.immowelt.de/x/y/z/w/abc.jpg?ci_seal=hash&w=525&h=394');
|
||
expect(body.parse_mode).toBe('HTML');
|
||
});
|
||
|
||
it('does NOT pre-fetch the image when using HTTP URL path', async () => {
|
||
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 't',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/x.jpg',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
// global fetch (used by buildPhotoFormData) must not be called
|
||
expect(mockGlobalFetch).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('falls back to sendMessage when sendPhoto fails', async () => {
|
||
mockNodeFetch
|
||
.mockResolvedValueOnce(jsonErr(400, { ok: false, description: 'boom' }))
|
||
.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 't',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/x.jpg',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||
});
|
||
});
|
||
|
||
describe('telegram send() - multipart path (.webp URLs)', () => {
|
||
it('pre-fetches the image then POSTs FormData to sendPhoto for a .webp URL', async () => {
|
||
// 1st: GET image via global fetch
|
||
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||
// 2nd: POST sendPhoto via node-fetch
|
||
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 'Listing',
|
||
link: 'https://example.com/a',
|
||
address: 'Addr',
|
||
price: '500€',
|
||
size: '50m²',
|
||
image: 'https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
// image was fetched
|
||
expect(mockGlobalFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockGlobalFetch.mock.calls[0][0]).toBe('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394');
|
||
|
||
// sendPhoto called via node-fetch with FormData
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||
const [url, opts] = mockNodeFetch.mock.calls[0];
|
||
expect(url).toBe('https://api.telegram.org/botTKN/sendPhoto');
|
||
expect(opts.method).toBe('post');
|
||
expect(opts.body).toBeInstanceOf(FormData);
|
||
// No explicit Content-Type header - fetch sets multipart boundary itself
|
||
expect(opts.headers).toBeUndefined();
|
||
expect(opts.body.get('chat_id')).toBe('999');
|
||
expect(opts.body.get('parse_mode')).toBe('HTML');
|
||
const photo = opts.body.get('photo');
|
||
expect(photo).toBeTruthy();
|
||
expect(photo.size).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('falls back to sendMessage when the image pre-fetch fails for a .webp URL', async () => {
|
||
// image fetch fails (404 from CDN)
|
||
mockGlobalFetch.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 404,
|
||
headers: { get: () => null },
|
||
arrayBuffer: async () => new ArrayBuffer(0),
|
||
});
|
||
// then sendMessage succeeds via node-fetch
|
||
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 't',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/gone.webp',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||
});
|
||
|
||
it('falls back to sendMessage when multipart sendPhoto returns a Telegram error', async () => {
|
||
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||
mockNodeFetch
|
||
.mockResolvedValueOnce(jsonErr(400, { description: 'broke' })) // multipart sendPhoto
|
||
.mockResolvedValueOnce(jsonOk()); // sendMessage fallback
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 't',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/x.webp',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||
expect(mockNodeFetch.mock.calls[1][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||
});
|
||
});
|
||
|
||
describe('telegram send() - mixed batch (regression-safety)', () => {
|
||
it('handles a batch with both .jpg and .webp - jpg uses URL, webp uses multipart', async () => {
|
||
// .webp image fetch
|
||
mockGlobalFetch.mockResolvedValueOnce(imageOk());
|
||
// both sendPhoto calls succeed
|
||
mockNodeFetch
|
||
.mockResolvedValueOnce(jsonOk()) // could be either listing first
|
||
.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'jpg-listing',
|
||
title: 'a',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/a.jpg',
|
||
},
|
||
{
|
||
id: 'webp-listing',
|
||
title: 'b',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: 'https://example.com/b.webp',
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockGlobalFetch).toHaveBeenCalledTimes(1); // only webp pre-fetches
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||
|
||
// Verify one call had FormData and one had JSON body
|
||
const bodies = mockNodeFetch.mock.calls.map((c) => c[1].body);
|
||
const hasFormData = bodies.some((b) => b instanceof FormData);
|
||
const hasJson = bodies.some((b) => typeof b === 'string' && b.startsWith('{'));
|
||
expect(hasFormData).toBe(true);
|
||
expect(hasJson).toBe(true);
|
||
});
|
||
|
||
it('uses sendMessage (not sendPhoto) when image is null', async () => {
|
||
mockNodeFetch.mockResolvedValueOnce(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immowelt',
|
||
newListings: [
|
||
{
|
||
id: 'a',
|
||
title: 't',
|
||
link: 'l',
|
||
address: 'a',
|
||
price: '',
|
||
size: '',
|
||
image: null,
|
||
},
|
||
],
|
||
notificationConfig: [baseConfig],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(1);
|
||
expect(mockNodeFetch.mock.calls[0][0]).toBe('https://api.telegram.org/botTKN/sendMessage');
|
||
expect(mockGlobalFetch).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('telegram send() - multiple chat IDs', () => {
|
||
const listing = {
|
||
id: '1',
|
||
title: 'Flat',
|
||
link: 'https://ex.com',
|
||
address: 'Berlin',
|
||
price: '800',
|
||
size: '50',
|
||
image: 'https://ex.com/img.jpg',
|
||
};
|
||
|
||
it('sends to every chat ID in a comma-separated list', async () => {
|
||
mockNodeFetch.mockResolvedValue(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immoscout',
|
||
newListings: [listing],
|
||
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '111, 222' } }],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
|
||
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['111', '222']));
|
||
});
|
||
|
||
it('trims whitespace around each chat ID', async () => {
|
||
mockNodeFetch.mockResolvedValue(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immoscout',
|
||
newListings: [listing],
|
||
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: ' 333 , 444 ' } }],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(2);
|
||
const bodies = mockNodeFetch.mock.calls.map((c) => JSON.parse(c[1].body));
|
||
expect(bodies.map((b) => b.chat_id)).toEqual(expect.arrayContaining(['333', '444']));
|
||
});
|
||
|
||
it('sends each listing to each chat ID (N listings × M chats)', async () => {
|
||
mockNodeFetch.mockResolvedValue(jsonOk());
|
||
|
||
await send({
|
||
serviceName: 'immoscout',
|
||
newListings: [listing, { ...listing, id: '2' }],
|
||
notificationConfig: [{ id: 'telegram', fields: { token: 'TKN', chatId: '555, 666' } }],
|
||
jobKey: 'Berlin',
|
||
});
|
||
|
||
expect(mockNodeFetch).toHaveBeenCalledTimes(4);
|
||
});
|
||
});
|
||
|
||
describe('telegram send() - config validation', () => {
|
||
it('throws when telegram adapter config is missing', () => {
|
||
expect(() =>
|
||
send({
|
||
serviceName: 's',
|
||
newListings: [],
|
||
notificationConfig: [],
|
||
jobKey: 'k',
|
||
}),
|
||
).toThrow(/configuration missing/);
|
||
});
|
||
|
||
it('throws when token or chatId is missing', () => {
|
||
expect(() =>
|
||
send({
|
||
serviceName: 's',
|
||
newListings: [],
|
||
notificationConfig: [{ id: 'telegram', fields: { token: '' } }],
|
||
jobKey: 'k',
|
||
}),
|
||
).toThrow(/token.*chatId/);
|
||
});
|
||
});
|