2026-06-02 20:11:43 +02:00
|
|
|
|
/*
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 09:46:56 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 20:11:43 +02:00
|
|
|
|
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/);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|