mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
288 lines
8.5 KiB
JavaScript
288 lines
8.5 KiB
JavaScript
|
|
/*
|
||
|
|
* Copyright (c) 2026 by Christian Kellner.
|
||
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
|
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');
|
||
|
|
});
|
||
|
|
});
|