mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ceac25aa6 | ||
|
|
34b68e1f52 | ||
|
|
6428e7ad78 |
@@ -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
|
Finding an apartment or house in Germany can be stressful and
|
||||||
time-consuming.\
|
time-consuming.\
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const notificationAdapter = await Promise.all(
|
|||||||
*/
|
*/
|
||||||
export default async function notificationAdapterPlugin(fastify) {
|
export default async function notificationAdapterPlugin(fastify) {
|
||||||
fastify.get('/', async () => {
|
fastify.get('/', async () => {
|
||||||
return notificationAdapter.map((adapter) => adapter.config);
|
return notificationAdapter.map((adapter) => adapter.config).filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post('/try', async (request, reply) => {
|
fastify.post('/try', async (request, reply) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import fetch from 'node-fetch';
|
|||||||
import pThrottle from 'p-throttle';
|
import pThrottle from 'p-throttle';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
|
import { shouldUseMultipart, buildPhotoFormData } from './telegramPhotoUploader.js';
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
@@ -177,11 +178,13 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
||||||
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
|
// FormData (multipart) vs JSON. node-fetch sets its own multipart boundary
|
||||||
method: 'post',
|
// header, so we must NOT supply Content-Type ourselves in that case.
|
||||||
body: JSON.stringify(body),
|
const isFormData = body instanceof FormData;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!res.ok) {
|
||||||
const errorBody = await res.text();
|
const errorBody = await res.text();
|
||||||
@@ -208,16 +211,28 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await throttledCall('sendPhoto', {
|
const caption = plainText
|
||||||
chat_id: chatId,
|
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||||
photo: img,
|
: buildCaption(jobName, serviceName, o, baseUrl);
|
||||||
caption: plainText
|
const parseMode = plainText ? undefined : 'HTML';
|
||||||
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
|
||||||
: buildCaption(jobName, serviceName, o, baseUrl),
|
// .webp URLs (Immowelt/Cloudimage) fail Telegram's URL-based sendPhoto with
|
||||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
// "failed to get HTTP URL content". Upload the bytes via multipart instead;
|
||||||
...(message_thread_id ? { message_thread_id } : {}),
|
// the rendered chat message is identical.
|
||||||
}).catch(async (e) => {
|
const photoCall = shouldUseMultipart(img)
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
? 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) => {
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
106
lib/notification/adapter/telegramPhotoUploader.js
Normal file
106
lib/notification/adapter/telegramPhotoUploader.js
Normal file
@@ -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<token>/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<FormData>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -141,6 +141,43 @@ const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
|||||||
'barrierefreie-wohnung-mieten': { equipment: ['handicappedaccessible'] },
|
'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 = /^(?<type>wohnung|haus)-bis-(?<price>\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<string, string> } | 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) {
|
export function convertWebToMobile(webUrl) {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
@@ -164,7 +201,14 @@ export function convertWebToMobile(webUrl) {
|
|||||||
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
|
additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey];
|
||||||
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
|
realType = REAL_ESTATE_TYPE['wohnung-mieten'];
|
||||||
} else {
|
} 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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "22.2.1",
|
"version": "22.2.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
|||||||
360
test/notification/telegram.test.js
Normal file
360
test/notification/telegram.test.js
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
287
test/notification/telegramPhotoUploader.test.js
Normal file
287
test/notification/telegramPhotoUploader.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,60 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
expect(queryParams.get('equipment').split(',')).toEqual(expect.arrayContaining(['garden', 'balcony']));
|
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
|
// Test URL conversion with unsupported query parameters
|
||||||
it('should remove unsupported query parameters', () => {
|
it('should remove unsupported query parameters', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?minimuminternetspeed=100000';
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export default function NotificationAdapterMutator({
|
|||||||
const adapter = useSelector((state) => state.notificationAdapter);
|
const adapter = useSelector((state) => state.notificationAdapter);
|
||||||
|
|
||||||
const preFilledSelectedAdapter =
|
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);
|
spreadPrefilledAdapterWithValues(preFilledSelectedAdapter, editNotificationAdapter?.fields);
|
||||||
|
|
||||||
@@ -227,9 +229,9 @@ export default function NotificationAdapterMutator({
|
|||||||
className="providerMutator__fields"
|
className="providerMutator__fields"
|
||||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||||
optionList={adapter
|
optionList={adapter
|
||||||
|
.filter((a) => a != null)
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
return {
|
return {
|
||||||
otherKey: a.id,
|
|
||||||
value: a.id,
|
value: a.id,
|
||||||
label: a.name,
|
label: a.name,
|
||||||
};
|
};
|
||||||
@@ -238,7 +240,7 @@ export default function NotificationAdapterMutator({
|
|||||||
.filter((option) =>
|
.filter((option) =>
|
||||||
editNotificationAdapter != null
|
editNotificationAdapter != null
|
||||||
? true
|
? true
|
||||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null,
|
: selected.find((selectedOption) => selectedOption.id === option.value) == null,
|
||||||
)
|
)
|
||||||
.sort(sortAdapter)}
|
.sort(sortAdapter)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user