diff --git a/README.md b/README.md index 6d07e30..5332255 100755 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ -# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany +# Fredy 🏡 - Your Self-Hosted Real Estate Finder for Germany Finding an apartment or house in Germany can be stressful and time-consuming.\ diff --git a/lib/api/routes/notificationAdapterRouter.js b/lib/api/routes/notificationAdapterRouter.js index 5746c4e..6e45ef9 100644 --- a/lib/api/routes/notificationAdapterRouter.js +++ b/lib/api/routes/notificationAdapterRouter.js @@ -18,7 +18,7 @@ const notificationAdapter = await Promise.all( */ export default async function notificationAdapterPlugin(fastify) { fastify.get('/', async () => { - return notificationAdapter.map((adapter) => adapter.config); + return notificationAdapter.map((adapter) => adapter.config).filter(Boolean); }); fastify.post('/try', async (request, reply) => { diff --git a/lib/notification/adapter/telegram.js b/lib/notification/adapter/telegram.js index 2b582a2..f999164 100644 --- a/lib/notification/adapter/telegram.js +++ b/lib/notification/adapter/telegram.js @@ -9,6 +9,7 @@ import fetch from 'node-fetch'; import pThrottle from 'p-throttle'; import { normalizeImageUrl } from '../../utils.js'; import logger from '../../services/logger.js'; +import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js'; const RATE_LIMIT_INTERVAL = 1000; const chatThrottleMap = new Map(); @@ -177,11 +178,13 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey const jobName = job == null ? jobKey : job.name; const throttledCall = getThrottled(chatId, async function (endpoint, body) { - const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, { - method: 'post', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }); + // FormData (multipart) vs JSON. node-fetch sets its own multipart boundary + // header, so we must NOT supply Content-Type ourselves in that case. + const isFormData = body instanceof FormData; + const opts = isFormData + ? { method: 'post', body } + : { method: 'post', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }; + const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, opts); if (!res.ok) { const errorBody = await res.text(); @@ -208,16 +211,28 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }); } - return await throttledCall('sendPhoto', { - chat_id: chatId, - photo: img, - caption: plainText - ? buildCaptionPlain(jobName, serviceName, o, baseUrl) - : buildCaption(jobName, serviceName, o, baseUrl), - ...(plainText ? {} : { parse_mode: 'HTML' }), - ...(message_thread_id ? { message_thread_id } : {}), - }).catch(async (e) => { - logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`); + const caption = plainText + ? buildCaptionPlain(jobName, serviceName, o, baseUrl) + : buildCaption(jobName, serviceName, o, baseUrl); + const parseMode = plainText ? undefined : 'HTML'; + + // .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with + // "failed to get HTTP URL content". Upload the bytes via multipart instead; + // the rendered chat message is identical. + const photoCall = shouldUseMultipart(img) + ? buildPhotoFormData({ chatId, imageUrl: img, caption, parseMode, messageThreadId: message_thread_id }).then( + (fd) => throttledCall('sendPhoto', fd), + ) + : throttledCall('sendPhoto', { + chat_id: chatId, + photo: img, + caption, + ...(parseMode ? { parse_mode: parseMode } : {}), + ...(message_thread_id ? { message_thread_id } : {}), + }); + + return await photoCall.catch(async (e) => { + logger.warn(`Error sending photo to Telegram and use a fallback: ${e.message}`); return await throttledCall('sendMessage', textPayload).catch((e) => { logger.error(`Error sending message to Telegram: ${e.message}`); throw e; diff --git a/lib/notification/adapter/telegramPhotoUploader.js b/lib/notification/adapter/telegramPhotoUploader.js new file mode 100644 index 0000000..0cfaca2 --- /dev/null +++ b/lib/notification/adapter/telegramPhotoUploader.js @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Helpers for sending photos to Telegram via `multipart/form-data` instead of + * the HTTP-URL path. Used when the URL is one that Telegram's URL-fetcher will + * reject - notably `.webp` images from Cloudimage (mms.immowelt.de), which + * Telegram refuses with "Bad Request: failed to get HTTP URL content". + * + * The HTTP-URL path is faster and is still the default in telegram.js; this + * module is the fallback for URLs whose extension makes Telegram fail. + */ + +/** Telegram's sendPhoto limit when uploading bytes via multipart/form-data. */ +const TELEGRAM_MULTIPART_MAX_BYTES = 10 * 1024 * 1024; + +/** Accept header used when re-fetching the image ourselves. + * Deliberately excludes `image/webp` so CDNs that content-negotiate + * (like Cloudimage on mms.immowelt.de) transcode WEBP to JPEG. */ +const NON_WEBP_ACCEPT = 'image/jpeg,image/png,image/*;q=0.8'; + +/** + * Returns true if the URL's path ends in a `.webp` extension. Such URLs need + * multipart upload because Telegram identifies media types from the URL path + * and rejects `.webp` in sendPhoto via HTTP URL. + * + * Conservative: returns false for null/empty/non-string input, malformed URLs, + * and non-https schemes. + * + * @param {string|null|undefined} url + * @returns {boolean} + */ +export function shouldUseMultipart(url) { + if (typeof url !== 'string' || url.length === 0) return false; + let parsed; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== 'https:') return false; + return /\.webp$/i.test(parsed.pathname); +} + +/** + * Fetch an image from `imageUrl` and build a `FormData` body suitable for + * POSTing to `https://api.telegram.org/bot/sendPhoto`. + * + * - Sends an `Accept` header that excludes `image/webp` so origin/CDN servers + * that content-negotiate return JPEG bytes. + * - Rejects images larger than Telegram's 10 MB multipart limit, both + * advertised via `Content-Length` and (defensively) after download. + * - The `photo` field is named with a `.jpg` extension because Telegram + * identifies file type from the filename. + * + * Throws if the image fetch fails, the size limit is exceeded, or the URL is + * unreachable. The caller is responsible for catching and falling back. + * + * @param {Object} args + * @param {string|number} args.chatId + * @param {string} args.imageUrl + * @param {string} args.caption + * @param {string} [args.parseMode] - Telegram parse_mode, e.g. 'HTML'. + * @param {number} [args.messageThreadId] - Telegram supergroup topic id. + * @returns {Promise} + */ +export async function buildPhotoFormData({ chatId, imageUrl, caption, parseMode, messageThreadId }) { + const res = await fetch(imageUrl, { + method: 'GET', + headers: { Accept: NON_WEBP_ACCEPT }, + }); + + if (!res.ok) { + throw new Error(`Failed to fetch image for multipart upload (${res.status}): ${imageUrl}`); + } + + const advertised = Number(res.headers.get('content-length')); + if (Number.isFinite(advertised) && advertised > TELEGRAM_MULTIPART_MAX_BYTES) { + throw new Error( + `Image exceeds Telegram multipart size limit (advertised ${advertised} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`, + ); + } + + const buf = await res.arrayBuffer(); + if (buf.byteLength > TELEGRAM_MULTIPART_MAX_BYTES) { + throw new Error( + `Image exceeds Telegram multipart size limit (downloaded ${buf.byteLength} bytes, max ${TELEGRAM_MULTIPART_MAX_BYTES}): ${imageUrl}`, + ); + } + + // Telegram identifies the media type from the filename extension. We always + // upload as .jpg because the Accept header forces JPEG bytes from CDNs that + // honor it; for the rare CDN that ignores Accept and still returns WEBP, the + // .jpg filename is a small lie but Telegram's image pipeline accepts it. + const blob = new Blob([buf], { type: 'image/jpeg' }); + + const fd = new FormData(); + fd.append('chat_id', String(chatId)); + fd.append('caption', caption); + if (parseMode) fd.append('parse_mode', parseMode); + if (messageThreadId != null) fd.append('message_thread_id', String(messageThreadId)); + fd.append('photo', blob, 'photo.jpg'); + return fd; +} diff --git a/lib/services/immoscout/immoscout-web-translator.js b/lib/services/immoscout/immoscout-web-translator.js index c6f6bc4..8cb1ff6 100644 --- a/lib/services/immoscout/immoscout-web-translator.js +++ b/lib/services/immoscout/immoscout-web-translator.js @@ -141,6 +141,43 @@ const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = { 'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] }, }; +// SEO-optimized rental paths used by the ImmoScout web UI when the user +// configures a maximum warmrent. Example: "wohnung-bis-800-euro-warm" means +// "apartment for rent up to 800 EUR warmrent". The web UI generates these +// paths instead of explicit `price` / `pricetype` query parameters. +// Note: only the warmrent variant uses an SEO slug; max coldrent searches +// use the regular "wohnung-mieten" path with explicit `price` and +// `pricetype=rentpermonth` query params, which the existing translator +// already handles. +const SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE = { + wohnung: 'apartmentrent', + haus: 'houserent', +}; +const SEO_MAX_WARMRENT_PATH_PATTERN = /^(?wohnung|haus)-bis-(?\d+)-euro-warm$/; + +/** + * Parses SEO-optimized ImmoScout web paths that encode a maximum warmrent, such + * as "wohnung-bis-800-euro-warm". Returns the corresponding mobile API real + * estate type and the implicit price/pricetype parameters, or null if the path + * does not match the known SEO max-warmrent pattern. + * + * @param {string} realTypeKey The last segment of the URL path. + * @returns {{ realType: string, additionalParams: Record } | null} + */ +function parseSeoMaxWarmrentPath(realTypeKey) { + const match = realTypeKey.match(SEO_MAX_WARMRENT_PATH_PATTERN); + if (!match) return null; + + const { type, price } = match.groups; + return { + realType: SEO_RENT_TYPE_TO_REAL_ESTATE_TYPE[type], + additionalParams: { + price: `-${price}`, + pricetype: 'calculatedtotalrent', + }, + }; +} + export function convertWebToMobile(webUrl) { let url; try { @@ -164,7 +201,14 @@ export function convertWebToMobile(webUrl) { additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey]; realType = REAL_ESTATE_TYPE['wohnung-mieten']; } else { - throw new Error(`Real estate type not found: ${realTypeKey}`); + // Test for SEO max-warmrent path, e.g. "wohnung-bis-800-euro-warm" + const seoMaxWarmrent = parseSeoMaxWarmrentPath(realTypeKey); + if (seoMaxWarmrent) { + realType = seoMaxWarmrent.realType; + additionalParamsFromWebPath = seoMaxWarmrent.additionalParams; + } else { + throw new Error(`Real estate type not found: ${realTypeKey}`); + } } } diff --git a/test/notification/telegram.test.js b/test/notification/telegram.test.js new file mode 100644 index 0000000..78383b3 --- /dev/null +++ b/test/notification/telegram.test.js @@ -0,0 +1,360 @@ +/* + * 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() - 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/); + }); +}); diff --git a/test/notification/telegramPhotoUploader.test.js b/test/notification/telegramPhotoUploader.test.js new file mode 100644 index 0000000..1ff99b1 --- /dev/null +++ b/test/notification/telegramPhotoUploader.test.js @@ -0,0 +1,287 @@ +/* + * 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'; +import { shouldUseMultipart, buildPhotoFormData } from '../../lib/notification/adapter/telegramPhotoUploader.js'; + +describe('shouldUseMultipart', () => { + it('returns true for .webp URL with query string', () => { + expect(shouldUseMultipart('https://mms.immowelt.de/1/1/6/5/abc.webp?ci_seal=hash&w=525&h=394')).toBe(true); + }); + + it('returns true for .webp URL without query string', () => { + expect(shouldUseMultipart('https://example.com/photo.webp')).toBe(true); + }); + + it('returns true for uppercase .WEBP extension', () => { + expect(shouldUseMultipart('https://example.com/IMG.WEBP?x=1')).toBe(true); + }); + + it('returns false for .jpg URL with query string', () => { + expect(shouldUseMultipart('https://mms.immowelt.de/a/b/c/d/xyz.jpg?ci_seal=hash&w=525&h=394')).toBe(false); + }); + + it('returns false for .jpeg URL', () => { + expect(shouldUseMultipart('https://example.com/photo.jpeg')).toBe(false); + }); + + it('returns false for .png URL with query string', () => { + expect(shouldUseMultipart('https://example.com/photo.png?w=100')).toBe(false); + }); + + it('returns false for .gif URL', () => { + expect(shouldUseMultipart('https://example.com/photo.gif')).toBe(false); + }); + + it('returns false for null', () => { + expect(shouldUseMultipart(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(shouldUseMultipart(undefined)).toBe(false); + }); + + it('returns false for empty string', () => { + expect(shouldUseMultipart('')).toBe(false); + }); + + it('returns false for malformed URL', () => { + expect(shouldUseMultipart('not a url')).toBe(false); + }); + + it('returns false for URL where webp is in the query but not the path', () => { + expect(shouldUseMultipart('https://example.com/photo.jpg?format=webp')).toBe(false); + }); + + it('returns false for URL with no extension at all', () => { + expect(shouldUseMultipart('https://example.com/photo')).toBe(false); + }); + + it('returns false for non-https schemes', () => { + // file/data/ftp URLs should not be relevant; safer to skip multipart + expect(shouldUseMultipart('http://example.com/photo.webp')).toBe(false); + }); +}); + +describe('buildPhotoFormData', () => { + let mockFetch; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeImageResponse({ contentType = 'image/jpeg', bytes = new Uint8Array([0xff, 0xd8, 0xff]) } = {}) { + const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return { + ok: true, + status: 200, + headers: { + get: (h) => + h.toLowerCase() === 'content-type' + ? contentType + : h.toLowerCase() === 'content-length' + ? String(bytes.byteLength) + : null, + }, + arrayBuffer: async () => buf, + }; + } + + it('fetches image with Accept header that excludes webp so the CDN transcodes to JPEG', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + await buildPhotoFormData({ + chatId: '123', + imageUrl: 'https://example.com/photo.webp', + caption: 'hi', + parseMode: 'HTML', + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://example.com/photo.webp'); + expect(opts?.headers?.Accept || opts?.headers?.accept).toMatch(/image\/jpeg/); + expect(opts?.headers?.Accept || opts?.headers?.accept).not.toMatch(/image\/webp/); + }); + + it('returns FormData containing chat_id, caption, parse_mode, and photo fields', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: '12345', + imageUrl: 'https://example.com/abc.webp', + caption: 'My caption', + parseMode: 'HTML', + }); + + expect(fd).toBeInstanceOf(FormData); + expect(fd.get('chat_id')).toBe('12345'); + expect(fd.get('caption')).toBe('My caption'); + expect(fd.get('parse_mode')).toBe('HTML'); + const photo = fd.get('photo'); + expect(photo).toBeTruthy(); + // File-like (Blob); has a name and a size + expect(typeof photo.name).toBe('string'); + expect(photo.size).toBeGreaterThan(0); + }); + + it('uses a .jpg filename (Telegram uses URL/filename extension for type detection)', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/source.webp', + caption: 'c', + parseMode: 'HTML', + }); + + const photo = fd.get('photo'); + expect(photo.name).toMatch(/\.jpg$/i); + }); + + it('includes message_thread_id when provided', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/source.webp', + caption: 'c', + parseMode: 'HTML', + messageThreadId: 42, + }); + + expect(fd.get('message_thread_id')).toBe('42'); + }); + + it('omits message_thread_id when not provided', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/source.webp', + caption: 'c', + parseMode: 'HTML', + }); + + expect(fd.get('message_thread_id')).toBeNull(); + }); + + it('omits parse_mode when not provided (plain text mode)', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/source.webp', + caption: 'c', + }); + + expect(fd.get('parse_mode')).toBeNull(); + }); + + it('throws when the image fetch returns non-200', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + headers: { get: () => null }, + arrayBuffer: async () => new ArrayBuffer(0), + }); + + await expect( + buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/gone.webp', + caption: 'c', + parseMode: 'HTML', + }), + ).rejects.toThrow(/404/); + }); + + it('throws when the image fetch throws (network error)', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect( + buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/x.webp', + caption: 'c', + parseMode: 'HTML', + }), + ).rejects.toThrow(/ECONNREFUSED/); + }); + + it('throws when the image exceeds 10 MB (Telegram multipart limit)', async () => { + // 11 MB + const big = new Uint8Array(11 * 1024 * 1024); + mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes: big })); + + await expect( + buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/huge.webp', + caption: 'c', + parseMode: 'HTML', + }), + ).rejects.toThrow(/size|large|10/i); + }); + + it('rejects early when content-length header advertises > 10 MB (avoids download)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: { + get: (h) => { + const k = h.toLowerCase(); + if (k === 'content-type') return 'image/jpeg'; + if (k === 'content-length') return String(50 * 1024 * 1024); + return null; + }, + }, + arrayBuffer: async () => { + throw new Error('should not be called - size check should reject first'); + }, + }); + + await expect( + buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/huge.webp', + caption: 'c', + parseMode: 'HTML', + }), + ).rejects.toThrow(/size|large|10/i); + }); + + it('accepts exactly 10 MB images (boundary)', async () => { + const bytes = new Uint8Array(10 * 1024 * 1024); + mockFetch.mockResolvedValueOnce(makeImageResponse({ bytes })); + + const fd = await buildPhotoFormData({ + chatId: '1', + imageUrl: 'https://example.com/exact.webp', + caption: 'c', + parseMode: 'HTML', + }); + + expect(fd.get('photo').size).toBe(10 * 1024 * 1024); + }); + + it('coerces non-string chatId (number) to string in form data', async () => { + mockFetch.mockResolvedValueOnce(makeImageResponse()); + + const fd = await buildPhotoFormData({ + chatId: 999, + imageUrl: 'https://example.com/x.webp', + caption: 'c', + parseMode: 'HTML', + }); + + expect(fd.get('chat_id')).toBe('999'); + }); +}); diff --git a/test/services/immoscout/immoscout-web-translator.test.js b/test/services/immoscout/immoscout-web-translator.test.js index da72ef2..f5dae46 100644 --- a/test/services/immoscout/immoscout-web-translator.test.js +++ b/test/services/immoscout/immoscout-web-translator.test.js @@ -46,6 +46,60 @@ describe('#immoscout-mobile URL conversion', () => { expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony'])); }); + // Test URL conversion of SEO web path for max warmrent. The ImmoScout web UI + // generates this special SEO slug instead of explicit price/pricetype params + // when the user configures a "Warmmiete" filter (real-world URL). + it('should convert a SEO apartment max warmrent path to rent + price + pricetype', () => { + const webUrl = + 'https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-bis-800-euro-warm?livingspace=-800.0&enteredFrom=result_list'; + + const converted = convertWebToMobile(webUrl); + const queryParams = new URL(converted).searchParams; + expect(queryParams.get('realestatetype')).toBe('apartmentrent'); + expect(queryParams.get('price')).toBe('-800'); + expect(queryParams.get('pricetype')).toBe('calculatedtotalrent'); + expect(queryParams.get('geocodes')).toBe('/de/nordrhein-westfalen/duesseldorf'); + expect(queryParams.get('livingspace')).toBe('-800.0'); + }); + + // Same SEO pattern for houses ("haus-bis-X-euro-warm" → houserent). + it('should convert a SEO house max warmrent path to rent + price + pricetype', () => { + const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/haus-bis-1500-euro-warm'; + + const converted = convertWebToMobile(webUrl); + const queryParams = new URL(converted).searchParams; + expect(queryParams.get('realestatetype')).toBe('houserent'); + expect(queryParams.get('price')).toBe('-1500'); + expect(queryParams.get('pricetype')).toBe('calculatedtotalrent'); + }); + + // Sanity check: max coldrent ("Kaltmiete") does NOT use an SEO slug. The web + // UI keeps the regular "wohnung-mieten" path and passes explicit + // price + pricetype query params, which the existing translator already + // handles (real-world URL). + it('should convert a max coldrent search via the regular wohnung-mieten path', () => { + const webUrl = + 'https://www.immobilienscout24.de/Suche/de/nordrhein-westfalen/duesseldorf/wohnung-mieten?price=-800.0&livingspace=-800.0&pricetype=rentpermonth&enteredFrom=result_list'; + + const converted = convertWebToMobile(webUrl); + const queryParams = new URL(converted).searchParams; + expect(queryParams.get('realestatetype')).toBe('apartmentrent'); + expect(queryParams.get('price')).toBe('-800.0'); + expect(queryParams.get('pricetype')).toBe('rentpermonth'); + expect(queryParams.get('geocodes')).toBe('/de/nordrhein-westfalen/duesseldorf'); + }); + + // Explicit query params win over the SEO slug's implicit defaults. + it('should let explicit query params override SEO path price defaults', () => { + const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-bis-800-euro-warm?price=100-500'; + + const converted = convertWebToMobile(webUrl); + const queryParams = new URL(converted).searchParams; + expect(queryParams.get('realestatetype')).toBe('apartmentrent'); + expect(queryParams.get('price')).toBe('100-500'); + expect(queryParams.get('pricetype')).toBe('calculatedtotalrent'); + }); + // Test URL conversion with unsupported query parameters it('should remove unsupported query parameters', () => { const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000'; diff --git a/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx b/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx index 581f8d6..20d473a 100644 --- a/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx +++ b/ui/src/views/jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx @@ -73,7 +73,9 @@ export default function NotificationAdapterMutator({ const adapter = useSelector((state) => state.notificationAdapter); const preFilledSelectedAdapter = - editNotificationAdapter == null ? null : adapter.find((a) => a.id === editNotificationAdapter.id); + editNotificationAdapter == null + ? null + : adapter.filter((a) => a != null).find((a) => a.id === editNotificationAdapter.id); spreadPrefilledAdapterWithValues(preFilledSelectedAdapter, editNotificationAdapter?.fields); @@ -227,9 +229,9 @@ export default function NotificationAdapterMutator({ className="providerMutator__fields" value={selectedAdapter == null ? '' : selectedAdapter.id} optionList={adapter + .filter((a) => a != null) .map((a) => { return { - otherKey: a.id, value: a.id, label: a.name, }; @@ -238,7 +240,7 @@ export default function NotificationAdapterMutator({ .filter((option) => editNotificationAdapter != null ? true - : selected.find((selectedOption) => selectedOption.id === option.key) == null, + : selected.find((selectedOption) => selectedOption.id === option.value) == null, ) .sort(sortAdapter)} onChange={(value) => {