2026-05-18 21:54:39 +07:00
|
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
|
|
|
|
|
|
|
|
let realPhotoBuffer: Buffer;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
'https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png',
|
|
|
|
|
);
|
|
|
|
|
if (!res.ok) throw new Error('Wikimedia download failed');
|
|
|
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
|
|
|
realPhotoBuffer = Buffer.from(arrayBuffer);
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback 1x1px JPEG
|
|
|
|
|
realPhotoBuffer = Buffer.from(
|
|
|
|
|
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9',
|
|
|
|
|
'hex',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-18 07:08:13 +07:00
|
|
|
|
|
|
|
|
// Mock db
|
2026-05-21 22:31:55 +07:00
|
|
|
type UploadResponseBody = {
|
|
|
|
|
public_id: string;
|
|
|
|
|
telegram_file_id: string;
|
|
|
|
|
telegram_file_unique_id: string;
|
|
|
|
|
file_name: string;
|
|
|
|
|
file_type: string;
|
|
|
|
|
download_url: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ErrorResponseBody = {
|
|
|
|
|
error: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type UploadJsonBody = UploadResponseBody & Partial<ErrorResponseBody>;
|
|
|
|
|
|
|
|
|
|
let mockSelectResult: unknown[] = [];
|
|
|
|
|
|
|
|
|
|
const uploadResponseJson = async (res: Response): Promise<UploadJsonBody> => {
|
|
|
|
|
return (await res.json()) as UploadJsonBody;
|
|
|
|
|
};
|
2026-05-18 19:39:55 +07:00
|
|
|
|
|
|
|
|
const mockLimit = mock(() => Promise.resolve(mockSelectResult));
|
|
|
|
|
const mockWhere = mock(() => ({
|
|
|
|
|
limit: mockLimit,
|
|
|
|
|
}));
|
|
|
|
|
const mockFrom = mock(() => ({
|
|
|
|
|
where: mockWhere,
|
|
|
|
|
}));
|
|
|
|
|
const mockSelect = mock(() => ({
|
|
|
|
|
from: mockFrom,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-18 07:08:13 +07:00
|
|
|
const mockInsert = mock(() => ({
|
2026-05-18 07:29:01 +07:00
|
|
|
values: mock(() => Promise.resolve()),
|
2026-05-18 07:08:13 +07:00
|
|
|
}));
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
mock.module('../src/db/index', () => ({
|
2026-05-18 07:08:13 +07:00
|
|
|
db: {
|
2026-05-18 07:29:01 +07:00
|
|
|
insert: mockInsert,
|
2026-05-18 19:39:55 +07:00
|
|
|
select: mockSelect,
|
2026-05-18 07:08:13 +07:00
|
|
|
},
|
2026-05-18 07:29:01 +07:00
|
|
|
files: {},
|
2026-05-18 07:08:13 +07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock nanoid
|
2026-05-18 22:01:26 +07:00
|
|
|
let nanoidCounter = 0;
|
2026-05-18 07:29:01 +07:00
|
|
|
mock.module('nanoid', () => ({
|
2026-05-18 22:01:26 +07:00
|
|
|
nanoid: () => `mocked-nanoid-id-${nanoidCounter++}`,
|
2026-05-18 07:08:13 +07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock telegram utils
|
2026-05-18 19:39:55 +07:00
|
|
|
const mockForwardToStorage = mock(() =>
|
|
|
|
|
Promise.resolve({
|
|
|
|
|
telegramFileId: 'tg-file-id-123',
|
|
|
|
|
telegramFileUniqueId: 'tg-unique-id-abc',
|
|
|
|
|
storageMessageId: 98765,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const mockGetFile = mock(() =>
|
|
|
|
|
Promise.resolve({
|
|
|
|
|
file_id: 'tg-file-id-123',
|
|
|
|
|
file_size: 1000,
|
|
|
|
|
mime_type: 'image/jpeg',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
mock.module('../src/utils/telegram', () => ({
|
2026-05-18 19:39:55 +07:00
|
|
|
forwardToStorage: mockForwardToStorage,
|
2026-05-18 07:08:13 +07:00
|
|
|
getBot: () => ({
|
2026-05-18 07:27:06 +07:00
|
|
|
telegram: {
|
2026-05-18 19:39:55 +07:00
|
|
|
getFile: mockGetFile,
|
2026-05-18 07:29:01 +07:00
|
|
|
},
|
|
|
|
|
}),
|
2026-05-18 07:08:13 +07:00
|
|
|
}));
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
describe('Upload Route Handler', () => {
|
2026-05-21 22:31:55 +07:00
|
|
|
let handleUpload: typeof import('../src/routes/upload').handleUpload;
|
2026-05-18 07:08:13 +07:00
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
mockInsert.mockClear();
|
2026-05-18 19:39:55 +07:00
|
|
|
mockSelect.mockClear();
|
|
|
|
|
mockFrom.mockClear();
|
|
|
|
|
mockWhere.mockClear();
|
|
|
|
|
mockLimit.mockClear();
|
|
|
|
|
mockForwardToStorage.mockClear();
|
|
|
|
|
mockGetFile.mockClear();
|
|
|
|
|
mockSelectResult = [];
|
2026-05-18 07:29:01 +07:00
|
|
|
const uploadRoute = await import('../src/routes/upload');
|
2026-05-18 07:08:13 +07:00
|
|
|
handleUpload = uploadRoute.handleUpload;
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
it('should reject unsupported content types with 400 status', async () => {
|
|
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
2026-05-18 07:08:13 +07:00
|
|
|
headers: {
|
2026-05-18 07:29:01 +07:00
|
|
|
'content-type': 'text/plain',
|
2026-05-18 07:08:13 +07:00
|
|
|
},
|
2026-05-18 07:29:01 +07:00
|
|
|
body: 'plain text data',
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(400);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 07:29:01 +07:00
|
|
|
expect(body.error).toContain('Unsupported content type');
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
it('should process JSON upload (base64) successfully', async () => {
|
|
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
2026-05-18 07:08:13 +07:00
|
|
|
headers: {
|
2026-05-18 07:29:01 +07:00
|
|
|
'content-type': 'application/json',
|
2026-05-18 07:08:13 +07:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
2026-05-18 21:54:39 +07:00
|
|
|
file: realPhotoBuffer.toString('base64'),
|
|
|
|
|
fileName: 'test.png',
|
2026-05-18 07:29:01 +07:00
|
|
|
}),
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(200);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 07:08:13 +07:00
|
|
|
|
2026-05-18 22:01:26 +07:00
|
|
|
expect(body.public_id).toContain('mocked-nanoid-id');
|
2026-05-18 07:29:01 +07:00
|
|
|
expect(body.telegram_file_id).toBe('tg-file-id-123');
|
|
|
|
|
expect(body.telegram_file_unique_id).toBe('tg-unique-id-abc');
|
2026-05-18 21:54:39 +07:00
|
|
|
expect(body.file_name).toBe('test.png');
|
|
|
|
|
expect(body.file_type).toBe('photo');
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
it('should reject JSON upload without file key', async () => {
|
|
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
2026-05-18 07:08:13 +07:00
|
|
|
headers: {
|
2026-05-18 07:29:01 +07:00
|
|
|
'content-type': 'application/json',
|
2026-05-18 07:08:13 +07:00
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
2026-05-18 07:29:01 +07:00
|
|
|
fileName: 'test.txt',
|
|
|
|
|
}),
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(400);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 07:29:01 +07:00
|
|
|
expect(body.error).toContain('Invalid JSON');
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
it('should process multipart upload successfully', async () => {
|
2026-05-18 07:08:13 +07:00
|
|
|
const formData = new FormData();
|
2026-05-18 21:54:39 +07:00
|
|
|
const fileBlob = new Blob([realPhotoBuffer], { type: 'image/png' });
|
|
|
|
|
formData.append('file', fileBlob, 'test_multi.png');
|
2026-05-18 07:08:13 +07:00
|
|
|
|
2026-05-18 07:29:01 +07:00
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(200);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 22:01:26 +07:00
|
|
|
expect(body.public_id).toContain('mocked-nanoid-id');
|
2026-05-18 21:54:39 +07:00
|
|
|
expect(body.file_name).toBe('test_multi.png');
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|
2026-05-18 07:16:30 +07:00
|
|
|
|
2026-05-18 19:39:55 +07:00
|
|
|
it('should deduplicate multipart upload if hash exists', async () => {
|
|
|
|
|
mockSelectResult = [
|
|
|
|
|
{
|
|
|
|
|
publicId: 'existing-id-123',
|
|
|
|
|
telegramFileId: 'existing-tg-id',
|
|
|
|
|
telegramFileUniqueId: 'existing-tg-unique',
|
|
|
|
|
storageChatId: 12345,
|
|
|
|
|
storageMessageId: 67890,
|
|
|
|
|
fileName: 'existing_name.txt',
|
|
|
|
|
mimeType: 'text/plain',
|
|
|
|
|
sizeBytes: 100,
|
|
|
|
|
fileType: 'document',
|
|
|
|
|
uploaderId: 0,
|
|
|
|
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
|
|
|
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
const fileBlob = new Blob([Buffer.from('multipart hello')], { type: 'text/plain' });
|
|
|
|
|
formData.append('file', fileBlob, 'test_multi.txt');
|
|
|
|
|
|
|
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(200);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 19:39:55 +07:00
|
|
|
|
|
|
|
|
expect(body.public_id).toBe('existing-id-123');
|
|
|
|
|
expect(body.telegram_file_id).toBe('existing-tg-id');
|
|
|
|
|
expect(body.telegram_file_unique_id).toBe('existing-tg-unique');
|
|
|
|
|
expect(body.file_name).toBe('existing_name.txt');
|
|
|
|
|
expect(body.download_url).toContain('/f/existing-id-123');
|
|
|
|
|
|
|
|
|
|
// DB query happened
|
|
|
|
|
expect(mockSelect).toHaveBeenCalled();
|
|
|
|
|
// No telegram upload happened
|
|
|
|
|
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
|
|
|
|
// No db insertion happened
|
|
|
|
|
expect(mockInsert).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should deduplicate JSON upload if hash exists', async () => {
|
|
|
|
|
mockSelectResult = [
|
|
|
|
|
{
|
|
|
|
|
publicId: 'existing-json-id',
|
|
|
|
|
telegramFileId: 'existing-tg-json-id',
|
|
|
|
|
telegramFileUniqueId: 'existing-tg-json-unique',
|
|
|
|
|
storageChatId: 12345,
|
|
|
|
|
storageMessageId: 67890,
|
|
|
|
|
fileName: 'existing_json.txt',
|
|
|
|
|
mimeType: 'text/plain',
|
|
|
|
|
sizeBytes: 200,
|
|
|
|
|
fileType: 'document',
|
|
|
|
|
uploaderId: 0,
|
|
|
|
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
|
|
|
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const req = new Request('http://localhost:3000/api/upload', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'content-type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
file: Buffer.from('hello world').toString('base64'),
|
|
|
|
|
fileName: 'test.txt',
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const res = await handleUpload(req);
|
|
|
|
|
expect(res.status).toBe(200);
|
2026-05-21 22:31:55 +07:00
|
|
|
const body = await uploadResponseJson(res);
|
2026-05-18 19:39:55 +07:00
|
|
|
|
|
|
|
|
expect(body.public_id).toBe('existing-json-id');
|
|
|
|
|
expect(body.telegram_file_id).toBe('existing-tg-json-id');
|
|
|
|
|
expect(body.file_name).toBe('existing_json.txt');
|
|
|
|
|
expect(body.download_url).toContain('/f/existing-json-id');
|
|
|
|
|
|
|
|
|
|
// DB query happened
|
|
|
|
|
expect(mockSelect).toHaveBeenCalled();
|
|
|
|
|
// No telegram upload happened
|
|
|
|
|
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
|
|
|
|
// No db insertion happened
|
|
|
|
|
expect(mockInsert).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 07:16:30 +07:00
|
|
|
afterAll(() => {
|
|
|
|
|
mock.restore();
|
|
|
|
|
});
|
2026-05-18 07:08:13 +07:00
|
|
|
});
|