From 52340ee77db8ba46d74ebfb07b2e780845f21562 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 21 May 2026 22:31:55 +0700 Subject: [PATCH] feat: enhance file upload handling with improved file detection, size limits, and response formatting --- src/bot.ts | 116 ++++++++--------- src/db/files.ts | 26 ++++ src/db/migrate.ts | 5 +- src/routes/files.ts | 36 ++---- src/routes/health.ts | 8 +- src/routes/swagger.ts | 101 +++++++-------- src/routes/upload.ts | 278 ++++++++++++++--------------------------- src/utils/file.ts | 145 +++++++++++++++++++-- src/utils/telegram.ts | 233 +++++++++++++++++++++------------- test/bootstrap.test.ts | 17 ++- test/bot.test.ts | 103 ++++++++------- test/db.test.ts | 1 - test/env.test.ts | 5 +- test/file.test.ts | 1 - test/files.test.ts | 109 ++++++++++------ test/health.test.ts | 3 +- test/rateLimit.test.ts | 1 - test/swagger.test.ts | 6 +- test/telegram.test.ts | 5 +- test/upload.test.ts | 36 ++++-- 20 files changed, 696 insertions(+), 539 deletions(-) create mode 100644 src/db/files.ts diff --git a/src/bot.ts b/src/bot.ts index 1565ff7..5f1af65 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,11 +1,36 @@ -import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { type Context, Telegraf } from 'telegraf'; import { db, files as fileSchema } from './db'; +import { findFileByUniqueId } from './db/files'; import { config } from './env'; +import { + detectFileType, + extractFileFromMessage, + getErrorMessage, + getFileSizeLimit, + type TelegramMediaMessage, +} from './utils/file'; import logger from './utils/logger'; import { forwardToStorage } from './utils/telegram'; +type BotContext = { + message: TelegramMediaMessage; + from: { id: number }; + chat?: { id: number }; + reply: (text: string, extra?: { reply_parameters: { message_id: number } }) => Promise; +}; + +type MediaEventRegistrar = { + on: (events: string[], handler: (ctx: BotContext) => Promise) => void; +}; + +const replyWithDownloadUrl = async (ctx: BotContext, publicId: string): Promise => { + const url = `${config.baseUrl}/f/${publicId}`; + await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { + reply_parameters: { message_id: ctx.message.message_id }, + }); +}; + export const startBot = async (): Promise> => { try { const bot = new Telegraf(config.botToken); @@ -17,44 +42,15 @@ export const startBot = async (): Promise> => { ); }); - // Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax - (bot as any).on( + const mediaBot = bot as unknown as MediaEventRegistrar; + mediaBot.on( ['document', 'photo', 'video', 'audio', 'voice', 'animation', 'sticker', 'video_note'], - async (ctx: any) => { + async (ctx) => { try { - const fileType: - | 'document' - | 'photo' - | 'video' - | 'audio' - | 'voice' - | 'animation' - | 'sticker' - | 'video_note' = ctx.message.document - ? 'document' - : ctx.message.photo - ? 'photo' - : ctx.message.video - ? 'video' - : ctx.message.audio - ? 'audio' - : ctx.message.voice - ? 'voice' - : ctx.message.animation - ? 'animation' - : ctx.message.sticker - ? 'sticker' - : ctx.message.video_note - ? 'video_note' - : 'document'; - - const fileObj = - fileType === 'photo' - ? ctx.message.photo.slice(-1)[0] - : fileType === 'sticker' - ? ctx.message.sticker - : ctx.message[fileType]; - const { file_id, file_size, mime_type } = fileObj; + const fileType = detectFileType(ctx.message); + const fileObj = extractFileFromMessage(ctx.message, fileType); + const { file_id, mime_type } = fileObj; + const fileSize = fileObj.file_size || 0; const fileName = ctx.message.document?.file_name || ctx.message.photo?.slice(-1)[0]?.file_name || @@ -63,32 +59,18 @@ export const startBot = async (): Promise> => { ctx.message.voice?.file_name || 'file'; - const maxSize = - fileType === 'photo' - ? 10 * 1024 * 1024 - : fileType === 'audio' - ? 200 * 1024 * 1024 - : fileType === 'voice' - ? 200 * 1024 * 1024 - : 2 * 1024 * 1024 * 1024; + const maxSize = getFileSizeLimit(fileType); - if (file_size > maxSize) { + if (fileSize > maxSize) { return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); } - const existing = await db - .select() - .from(fileSchema) - .where(eq(fileSchema.telegramFileUniqueId, fileObj.file_unique_id)) - .limit(1); + const existing = await findFileByUniqueId(fileObj.file_unique_id); - if (existing && existing.length > 0) { - const url = `${config.baseUrl}/f/${existing[0].publicId}`; - await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { - reply_parameters: { message_id: ctx.message.message_id }, - }); + if (existing) { + await replyWithDownloadUrl(ctx, existing.publicId); logger.info('Duplicate file detected in bot, returned existing link', { - publicId: existing[0].publicId, + publicId: existing.publicId, fileType, fileName, uploader: ctx.from.id, @@ -107,7 +89,7 @@ export const startBot = async (): Promise> => { storageMessageId: result.storageMessageId, fileName: fileName, mimeType: mime_type || 'application/octet-stream', - sizeBytes: file_size, + sizeBytes: fileSize, fileType: fileType, uploaderId: ctx.from.id, createdAt: new Date(), @@ -116,10 +98,7 @@ export const startBot = async (): Promise> => { await db.insert(fileSchema).values(uploaded); - const url = `${config.baseUrl}/f/${publicId}`; - await ctx.reply(`File berhasil diupload! 📎\n\nDownload: ${url}`, { - reply_parameters: { message_id: ctx.message.message_id }, - }); + await replyWithDownloadUrl(ctx, publicId); logger.info('File uploaded via bot', { publicId, @@ -127,8 +106,11 @@ export const startBot = async (): Promise> => { fileName, uploader: ctx.from.id, }); - } catch (error: any) { - logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id }); + } catch (error: unknown) { + logger.error('Bot file handler error', { + error: getErrorMessage(error), + chat_id: ctx.chat?.id, + }); await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.'); } }, @@ -136,7 +118,7 @@ export const startBot = async (): Promise> => { bot.use((ctx, next) => { logger.info('Telegram event received', { - type: (ctx.update as any).type, + type: 'type' in ctx.update ? ctx.update.type : undefined, chat_id: ctx.chat?.id, }); return next(); @@ -147,8 +129,8 @@ export const startBot = async (): Promise> => { logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` }); return bot; - } catch (error: any) { - logger.error('Failed to start bot', { error: error.message }); + } catch (error: unknown) { + logger.error('Failed to start bot', { error: getErrorMessage(error) }); throw error; } }; diff --git a/src/db/files.ts b/src/db/files.ts new file mode 100644 index 0000000..6a0b722 --- /dev/null +++ b/src/db/files.ts @@ -0,0 +1,26 @@ +import { eq } from 'drizzle-orm'; +import { db, files as fileSchema } from './index'; +import type { File } from './schema'; + +export const findFileByHash = async (hash: string): Promise => { + const result = await db.select().from(fileSchema).where(eq(fileSchema.fileHash, hash)).limit(1); + return result[0] || null; +}; + +export const findFileByPublicId = async (publicId: string): Promise => { + const result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.publicId, publicId)) + .limit(1); + return result[0] || null; +}; + +export const findFileByUniqueId = async (telegramFileUniqueId: string): Promise => { + const result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.telegramFileUniqueId, telegramFileUniqueId)) + .limit(1); + return result[0] || null; +}; diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 5237417..24f0d77 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -1,5 +1,6 @@ import postgres from 'postgres'; import { config } from '../env'; +import { getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; const schemaSql = await Bun.file('schema.sql').text(); @@ -8,8 +9,8 @@ const sql = postgres(config.databaseUrl, { max: 1 }); try { await sql.unsafe(schemaSql); logger.info('Database migration completed'); -} catch (error: any) { - logger.error('Database migration failed', { error: error.message }); +} catch (error: unknown) { + logger.error('Database migration failed', { error: getErrorMessage(error) }); process.exitCode = 1; } finally { await sql.end(); diff --git a/src/routes/files.ts b/src/routes/files.ts index e114013..48259ca 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -1,5 +1,5 @@ -import { eq } from 'drizzle-orm'; -import { db, files as fileSchema } from '../db'; +import { findFileByPublicId } from '../db/files'; +import { formatCreatedAt, getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; import { checkRateLimit } from '../utils/rateLimit'; @@ -18,18 +18,13 @@ export const handleFileRedirect = async (req: RequestWithParams): Promise return Response.json({ error: 'Missing file id' }, { status: 400 }); } - const result = await db - .select() - .from(fileSchema) - .where(eq(fileSchema.publicId, public_id)) - .limit(1); + const file = await findFileByPublicId(public_id); - if (!result.length) { + if (!file) { logger.warn('File not found', { public_id }); return Response.json({ error: 'File not found' }, { status: 404 }); } - - const file = result[0]; return Response.json( { public_id: file.publicId, @@ -74,15 +63,12 @@ export const handleFileInfo = async (req: RequestWithParams): Promise size_bytes: file.sizeBytes, file_type: file.fileType, uploader_id: file.uploaderId, - created_at: - typeof file.createdAt === 'string' - ? file.createdAt - : (file.createdAt as Date).toISOString(), + created_at: formatCreatedAt(file.createdAt), }, { status: 200 }, ); - } catch (error: any) { - logger.error('File info error', { public_id, error: error.message }); + } catch (error: unknown) { + logger.error('File info error', { public_id, error: getErrorMessage(error) }); return Response.json({ error: 'Server error' }, { status: 500 }); } }; diff --git a/src/routes/health.ts b/src/routes/health.ts index 8eafc31..c4f029b 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,13 +1,15 @@ import { sql } from 'drizzle-orm'; import { db } from '../db'; +import { getErrorMessage } from '../utils/file'; import logger from '../utils/logger'; export const handleHealth = async (_req: Request): Promise => { try { await db.execute(sql`SELECT 1`); return Response.json({ status: 'ok' }, { status: 200 }); - } catch (error: any) { - logger.error('Health check failed', { error: error.message }); - return Response.json({ status: 'error', error: error.message }, { status: 500 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('Health check failed', { error: message }); + return Response.json({ status: 'error', error: message }, { status: 500 }); } }; diff --git a/src/routes/swagger.ts b/src/routes/swagger.ts index ec9da9e..4eea234 100644 --- a/src/routes/swagger.ts +++ b/src/routes/swagger.ts @@ -11,6 +11,45 @@ const jsonContent = (schema: object) => ({ 'application/json': { schema }, }); +const publicIdParameter = { + name: 'public_id', + in: 'path', + required: true, + description: 'Permanent public file ID.', + schema: { type: 'string' }, +}; + +const fileInfoProperties = { + public_id: { type: 'string', example: 'xYz123' }, + file_name: { type: 'string', example: 'document.pdf' }, + mime_type: { type: 'string', example: 'application/pdf' }, + size_bytes: { type: 'integer', example: 1048576 }, + file_type: { type: 'string', example: 'document' }, + uploader_id: { type: 'integer', example: 0 }, + created_at: { + type: 'string', + format: 'date-time', + example: '2026-05-18T10:00:00.000Z', + }, +}; + +const uploadProperties = { + ...fileInfoProperties, + telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' }, + telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' }, + storage_chat_id: { type: 'integer', example: -1001234567890 }, + storage_message_id: { type: 'integer', example: 42 }, + download_url: { + type: 'string', + example: `${config.baseUrl}/f/xYz123`, + }, +}; + +const objectSchema = (properties: object) => ({ + type: 'object', + properties, +}); + export const handleSwaggerJson = async (): Promise => { const spec = { openapi: '3.0.0', @@ -99,30 +138,7 @@ export const handleSwaggerJson = async (): Promise => { responses: { '200': { description: 'Successful upload metadata.', - content: jsonContent({ - type: 'object', - properties: { - public_id: { type: 'string', example: 'xYz123' }, - telegram_file_id: { type: 'string', example: 'BQACAgQAAxkBA...' }, - telegram_file_unique_id: { type: 'string', example: 'AgAD8w...' }, - storage_chat_id: { type: 'integer', example: -1001234567890 }, - storage_message_id: { type: 'integer', example: 42 }, - file_name: { type: 'string', example: 'document.pdf' }, - mime_type: { type: 'string', example: 'application/pdf' }, - size_bytes: { type: 'integer', example: 1048576 }, - file_type: { type: 'string', example: 'document' }, - uploader_id: { type: 'integer', example: 0 }, - created_at: { - type: 'string', - format: 'date-time', - example: '2026-05-18T10:00:00.000Z', - }, - download_url: { - type: 'string', - example: `${config.baseUrl}/f/xYz123`, - }, - }, - }), + content: jsonContent(objectSchema(uploadProperties)), }, '400': { description: 'Bad request.', @@ -140,15 +156,7 @@ export const handleSwaggerJson = async (): Promise => { summary: 'Redirect to Telegram File URL', description: 'Gets a fresh Telegram download URL and redirects with 302. Rate-limited by IP.', - parameters: [ - { - name: 'public_id', - in: 'path', - required: true, - description: 'Permanent public file ID.', - schema: { type: 'string' }, - }, - ], + parameters: [publicIdParameter], responses: { '302': { description: 'Redirect to Telegram CDN URL.', @@ -180,34 +188,11 @@ export const handleSwaggerJson = async (): Promise => { get: { summary: 'Get File Info', description: 'Gets saved file metadata by public ID.', - parameters: [ - { - name: 'public_id', - in: 'path', - required: true, - description: 'Permanent public file ID.', - schema: { type: 'string' }, - }, - ], + parameters: [publicIdParameter], responses: { '200': { description: 'File metadata.', - content: jsonContent({ - type: 'object', - properties: { - public_id: { type: 'string', example: 'xYz123' }, - file_name: { type: 'string', example: 'document.pdf' }, - mime_type: { type: 'string', example: 'application/pdf' }, - size_bytes: { type: 'integer', example: 1048576 }, - file_type: { type: 'string', example: 'document' }, - uploader_id: { type: 'integer', example: 0 }, - created_at: { - type: 'string', - format: 'date-time', - example: '2026-05-18T10:00:00.000Z', - }, - }, - }), + content: jsonContent(objectSchema(fileInfoProperties)), }, '400': { description: 'Missing public ID.', diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 17fb3d1..ea24498 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -1,18 +1,89 @@ import { createReadStream, unlinkSync } from 'node:fs'; -import { eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { db, files as fileSchema } from '../db'; +import { findFileByHash } from '../db/files'; +import type { NewFile } from '../db/schema'; import { config } from '../env'; import { + buildUploadResponse, checkFileSize, computeHash, ensureExtension, extractMimeType, + getErrorMessage, getFileType, } from '../utils/file'; import logger from '../utils/logger'; import { forwardToStorage, getBot } from '../utils/telegram'; +type UploadedFile = NewFile & { + createdAt: Date; + updatedAt: Date; +}; + +type TelegramFileLookup = { + mime_type?: string; + file_size?: number; +}; + +interface JsonUploadPayload { + file?: unknown; + fileName?: string; +} + +const parseBase64File = (file: string): { base64Data: string; mimeType: string } => { + if (!file.startsWith('data:')) { + return { base64Data: file, mimeType: 'application/octet-stream' }; + } + + const match = file.match(/^data:([^;]+);base64,(.+)$/); + return match + ? { base64Data: match[2], mimeType: match[1] } + : { base64Data: file, mimeType: 'application/octet-stream' }; +}; + +const normalizeFileType = (mimeType: string, fileName: string): string => { + const fileType = getFileType(mimeType, fileName); + return fileType === 'application' ? 'document' : fileType; +}; + +const performUpload = async ( + fileBuffer: Buffer, + fileName: string, + mimeType: string, +): Promise => { + const tempPath = `/tmp/teleuploader-${nanoid()}`; + try { + await Bun.write(tempPath, fileBuffer); + const fileStream = createReadStream(tempPath); + const result = await forwardToStorage(fileStream, fileName, getFileType(mimeType, fileName)); + const bot = getBot(); + const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as TelegramFileLookup; + + return { + publicId: nanoid(), + telegramFileId: result.telegramFileId, + telegramFileUniqueId: result.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: result.storageMessageId, + fileName, + mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream', + sizeBytes: fileInfo.file_size || fileBuffer.byteLength, + fileType: getFileType(mimeType, fileName), + uploaderId: 0, + fileHash: computeHash(fileBuffer), + createdAt: new Date(), + updatedAt: new Date(), + }; + } finally { + setTimeout(() => { + try { + unlinkSync(tempPath); + } catch {} + }, 50); + } +}; + export const handleUpload = async (req: Request): Promise => { try { const contentType = req.headers.get('content-type') || ''; @@ -27,14 +98,14 @@ export const handleUpload = async (req: Request): Promise => { { error: 'Unsupported content type. Use multipart/form-data or application/json' }, { status: 400 }, ); - } catch (error: any) { - logger.error('Upload error', { error: error.message }); - return Response.json({ error: error.message }, { status: 500 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('Upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); } }; const handleMultipartUpload = async (req: Request): Promise => { - let tempPath = ''; try { const formData = await req.formData(); const file = formData.get('file'); @@ -49,33 +120,9 @@ const handleMultipartUpload = async (req: Request): Promise => { const fileBuffer = Buffer.from(fileBytes); const hash = computeHash(fileBuffer); - // Check for duplicate in DB - const existing = await db - .select() - .from(fileSchema) - .where(eq(fileSchema.fileHash, hash)) - .limit(1); - - if (existing.length > 0) { - const existingFile = existing[0]; - const responsePayload = { - public_id: existingFile.publicId, - telegram_file_id: existingFile.telegramFileId, - telegram_file_unique_id: existingFile.telegramFileUniqueId, - storage_chat_id: existingFile.storageChatId, - storage_message_id: existingFile.storageMessageId, - file_name: existingFile.fileName, - mime_type: existingFile.mimeType, - size_bytes: existingFile.sizeBytes, - file_type: existingFile.fileType, - uploader_id: existingFile.uploaderId, - created_at: - existingFile.createdAt instanceof Date - ? existingFile.createdAt.toISOString() - : new Date(existingFile.createdAt).toISOString(), - download_url: `${config.baseUrl}/f/${existingFile.publicId}`, - }; - return Response.json(responsePayload, { status: 200 }); + const existingFile = await findFileByHash(hash); + if (existingFile) { + return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 }); } const rawMimeType = file.type || extractMimeType({}, req) || 'application/octet-stream'; @@ -90,68 +137,20 @@ const handleMultipartUpload = async (req: Request): Promise => { return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); } - // Write file to disk temporarily - tempPath = `/tmp/teleuploader-${nanoid()}`; - await Bun.write(tempPath, fileBuffer); - - const fileStream = createReadStream(tempPath); - const result = await forwardToStorage(fileStream, finalFileName, fileType); - const bot = getBot(); - const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any; - - const uploaded = { - publicId: nanoid(), - telegramFileId: result.telegramFileId, - telegramFileUniqueId: result.telegramFileUniqueId, - storageChatId: config.storageChatId, - storageMessageId: result.storageMessageId, - fileName: finalFileName, - mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream', - sizeBytes: fileInfo.file_size || fileBuffer.byteLength, - fileType: fileType, - uploaderId: 0, - fileHash: hash, - createdAt: new Date(), - updatedAt: new Date(), - }; - + const uploaded = await performUpload(fileBuffer, finalFileName, mimeType); await db.insert(fileSchema).values(uploaded); - const responsePayload = { - public_id: uploaded.publicId, - telegram_file_id: uploaded.telegramFileId, - telegram_file_unique_id: uploaded.telegramFileUniqueId, - storage_chat_id: uploaded.storageChatId, - storage_message_id: uploaded.storageMessageId, - file_name: uploaded.fileName, - mime_type: uploaded.mimeType, - size_bytes: uploaded.sizeBytes, - file_type: uploaded.fileType, - uploader_id: uploaded.uploaderId, - created_at: uploaded.createdAt.toISOString(), - download_url: `${config.baseUrl}/f/${uploaded.publicId}`, - }; - - return Response.json(responsePayload, { status: 200 }); - } catch (error: any) { - logger.error('Multipart upload error', { error: error.message }); - return Response.json({ error: error.message }, { status: 500 }); - } finally { - if (tempPath) { - const p = tempPath; - setTimeout(() => { - try { - unlinkSync(p); - } catch {} - }, 50); - } + return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('Multipart upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); } }; const handleJSONUpload = async (req: Request): Promise => { - let tempPath = ''; try { - const { file, fileName = 'file' } = (await req.json()) as any; + const { file, fileName = 'file' } = (await req.json()) as JsonUploadPayload; if (!file || typeof file !== 'string') { return Response.json( @@ -160,112 +159,29 @@ const handleJSONUpload = async (req: Request): Promise => { ); } - let base64Data = file; - let rawMimeType = 'application/octet-stream'; - if (file.startsWith('data:')) { - const match = file.match(/^data:([^;]+);base64,(.+)$/); - if (match) { - rawMimeType = match[1]; - base64Data = match[2]; - } - } - + const { base64Data, mimeType: rawMimeType } = parseBase64File(file); const fileBytes = Buffer.from(base64Data, 'base64'); const hash = computeHash(fileBytes); - // Check for duplicate in DB - const existing = await db - .select() - .from(fileSchema) - .where(eq(fileSchema.fileHash, hash)) - .limit(1); - - if (existing.length > 0) { - const existingFile = existing[0]; - const responsePayload = { - public_id: existingFile.publicId, - telegram_file_id: existingFile.telegramFileId, - telegram_file_unique_id: existingFile.telegramFileUniqueId, - storage_chat_id: existingFile.storageChatId, - storage_message_id: existingFile.storageMessageId, - file_name: existingFile.fileName, - mime_type: existingFile.mimeType, - size_bytes: existingFile.sizeBytes, - file_type: existingFile.fileType, - uploader_id: existingFile.uploaderId, - created_at: - existingFile.createdAt instanceof Date - ? existingFile.createdAt.toISOString() - : new Date(existingFile.createdAt).toISOString(), - download_url: `${config.baseUrl}/f/${existingFile.publicId}`, - }; - return Response.json(responsePayload, { status: 200 }); + const existingFile = await findFileByHash(hash); + if (existingFile) { + return Response.json(buildUploadResponse(existingFile, config.baseUrl), { status: 200 }); } const { fileName: finalFileName, mimeType } = ensureExtension(fileName, fileBytes, rawMimeType); - const fileType = - getFileType(mimeType, finalFileName) === 'application' - ? 'document' - : getFileType(mimeType, finalFileName); + const fileType = normalizeFileType(mimeType, finalFileName); if (!checkFileSize(fileBytes.byteLength, fileType)) { return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); } - // Write file to disk temporarily - tempPath = `/tmp/teleuploader-${nanoid()}`; - await Bun.write(tempPath, fileBytes); - - const fileStream = createReadStream(tempPath); - const result = await forwardToStorage(fileStream, finalFileName, fileType); - const bot = getBot(); - const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any; - - const uploaded = { - publicId: nanoid(), - telegramFileId: result.telegramFileId, - telegramFileUniqueId: result.telegramFileUniqueId, - storageChatId: config.storageChatId, - storageMessageId: result.storageMessageId, - fileName: finalFileName, - mimeType: fileInfo.mime_type || mimeType || 'application/octet-stream', - sizeBytes: fileInfo.file_size || fileBytes.byteLength, - fileType: fileType, - uploaderId: 0, - fileHash: hash, - createdAt: new Date(), - updatedAt: new Date(), - }; - + const uploaded = await performUpload(fileBytes, finalFileName, mimeType); await db.insert(fileSchema).values(uploaded); - const responsePayload = { - public_id: uploaded.publicId, - telegram_file_id: uploaded.telegramFileId, - telegram_file_unique_id: uploaded.telegramFileUniqueId, - storage_chat_id: uploaded.storageChatId, - storage_message_id: uploaded.storageMessageId, - file_name: uploaded.fileName, - mime_type: uploaded.mimeType, - size_bytes: uploaded.sizeBytes, - file_type: uploaded.fileType, - uploader_id: uploaded.uploaderId, - created_at: uploaded.createdAt.toISOString(), - download_url: `${config.baseUrl}/f/${uploaded.publicId}`, - }; - - return Response.json(responsePayload, { status: 200 }); - } catch (error: any) { - logger.error('JSON upload error', { error: error.message }); - return Response.json({ error: error.message }, { status: 500 }); - } finally { - if (tempPath) { - const p = tempPath; - setTimeout(() => { - try { - unlinkSync(p); - } catch {} - }, 50); - } + return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 }); + } catch (error: unknown) { + const message = getErrorMessage(error); + logger.error('JSON upload error', { error: message }); + return Response.json({ error: message }, { status: 500 }); } }; diff --git a/src/utils/file.ts b/src/utils/file.ts index 311c0bd..c875003 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,3 +1,21 @@ +export const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; + +interface FileMetadata { + publicId: string; + telegramFileId: string; + telegramFileUniqueId: string; + storageChatId: number; + storageMessageId: number; + fileName: string; + mimeType: string; + sizeBytes: number; + fileType: string; + uploaderId: number; + createdAt: Date | string | number; +} + const FILE_TYPES: Record = { document: 2 * 1024 * 1024 * 1024, // 2GB photo: 10 * 1024 * 1024, // 10MB @@ -72,10 +90,41 @@ export const ensureExtension = ( return { fileName: finalFileName, mimeType }; }; -export const extractFileName = (msg: any, request: any): string => { - if (request?.headers?.['x-file-name']) { - return request.headers['x-file-name']; - } +type HeaderMapRequest = { + headers?: + | { + get?: (name: string) => string | null; + } + | Record; +}; + +type FileLike = { + fileName?: string; + mimeType?: string; +}; + +type MessageLike = { + document?: FileLike; + photo?: FileLike[]; + audio?: FileLike; + voice?: FileLike; + animation?: FileLike; +}; + +const getHeader = (request: HeaderMapRequest | null, name: string): string | undefined => { + const headers = request?.headers; + if (!headers) return undefined; + + const get = 'get' in headers ? headers.get : undefined; + if (typeof get === 'function') return get(name) || undefined; + + return (headers as Record)[name]; +}; + +export const extractFileName = (msg: MessageLike, request: HeaderMapRequest | null): string => { + const headerFileName = getHeader(request, 'x-file-name'); + if (headerFileName) return headerFileName; + return ( msg.document?.fileName || msg.photo?.slice(-1)[0]?.fileName || @@ -86,10 +135,10 @@ export const extractFileName = (msg: any, request: any): string => { ); }; -export const extractMimeType = (msg: any, request: any): string => { - if (request?.headers?.['x-mime-type']) { - return request.headers['x-mime-type']; - } +export const extractMimeType = (msg: MessageLike, request: HeaderMapRequest | null): string => { + const headerMimeType = getHeader(request, 'x-mime-type'); + if (headerMimeType) return headerMimeType; + return ( msg.document?.mimeType || msg.photo?.slice(-1)[0]?.mimeType || @@ -105,3 +154,83 @@ export const computeHash = (buffer: Buffer): string => { hasher.update(buffer); return hasher.digest('hex'); }; + +export interface TelegramMessageFile { + file_id: string; + file_unique_id: string; + file_size?: number; + mime_type?: string; + file_name?: string; +} + +export interface TelegramMediaMessage { + message_id: number; + document?: TelegramMessageFile; + photo?: TelegramMessageFile[]; + video?: TelegramMessageFile; + audio?: TelegramMessageFile; + voice?: TelegramMessageFile; + animation?: TelegramMessageFile; + sticker?: TelegramMessageFile; + video_note?: TelegramMessageFile; +} + +export const extractFileFromMessage = ( + msg: TelegramMediaMessage, + fileType: string, +): TelegramMessageFile => { + if (fileType === 'photo') return msg.photo?.slice(-1)[0] as TelegramMessageFile; + if (fileType === 'sticker') return msg.sticker as TelegramMessageFile; + return msg[fileType as keyof TelegramMediaMessage] as TelegramMessageFile; +}; + +export const detectFileType = (msg: TelegramMediaMessage): string => { + if (msg.document) return 'document'; + if (msg.photo) return 'photo'; + if (msg.video) return 'video'; + if (msg.audio) return 'audio'; + if (msg.voice) return 'voice'; + if (msg.animation) return 'animation'; + if (msg.sticker) return 'sticker'; + if (msg.video_note) return 'video_note'; + return 'document'; +}; + +export const getFileSizeLimit = (fileType: string): number => + FILE_TYPES[fileType] || FILE_TYPES.document; + +export const formatCreatedAt = (createdAt: Date | string | number): string => { + return createdAt instanceof Date ? createdAt.toISOString() : new Date(createdAt).toISOString(); +}; + +export interface UploadResponse { + public_id: string; + telegram_file_id: string; + telegram_file_unique_id: string; + storage_chat_id: number; + storage_message_id: number; + file_name: string; + mime_type: string; + size_bytes: number; + file_type: string; + uploader_id: number; + created_at: string; + download_url: string; +} + +export const buildUploadResponse = (file: FileMetadata, baseUrl: string): UploadResponse => { + return { + public_id: file.publicId, + telegram_file_id: file.telegramFileId, + telegram_file_unique_id: file.telegramFileUniqueId, + storage_chat_id: file.storageChatId, + storage_message_id: file.storageMessageId, + file_name: file.fileName, + mime_type: file.mimeType, + size_bytes: file.sizeBytes, + file_type: file.fileType, + uploader_id: file.uploaderId, + created_at: formatCreatedAt(file.createdAt), + download_url: `${baseUrl}/f/${file.publicId}`, + }; +}; diff --git a/src/utils/telegram.ts b/src/utils/telegram.ts index e5533ab..f17ff4a 100644 --- a/src/utils/telegram.ts +++ b/src/utils/telegram.ts @@ -10,28 +10,35 @@ const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`; let currentBotIndex = 0; -const executeWithBotRetry = async ( - action: (botInstance: Telegraf) => Promise, +const rotateBot = (): { previousIndex: number; nextIndex: number } => { + const previousIndex = currentBotIndex; + currentBotIndex = (currentBotIndex + 1) % bots.length; + return { previousIndex, nextIndex: currentBotIndex }; +}; + +const sleep = (seconds: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +}; + +const executeWithBotRetry = async ( + action: (botInstance: Telegraf) => Promise, retries = 5, attemptedBots = 0, -): Promise => { +): Promise => { const currentBot = bots[currentBotIndex]; try { return await action(currentBot); - } catch (error: any) { - const errorStr = error.message || String(error); + } catch (error: unknown) { + const errorStr = error instanceof Error ? error.message : String(error); const match = errorStr.match(/retry after (\d+)/i); if (match) { - // 429 rate limit hit! Rotate bot index instantly - const prevIndex = currentBotIndex; - currentBotIndex = (currentBotIndex + 1) % bots.length; - const nextIndex = currentBotIndex; + const { previousIndex, nextIndex } = rotateBot(); attemptedBots++; if (attemptedBots < bots.length) { logger.info( - `Bot Index ${prevIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`, + `Bot Index ${previousIndex} hit 429. Instantly rotating to Bot Index ${nextIndex}...`, ); return executeWithBotRetry(action, retries, attemptedBots); } @@ -42,7 +49,7 @@ const executeWithBotRetry = async ( logger.warn(`All bots in the pool are rate-limited. Sleeping for ${seconds} seconds...`, { error: errorStr, }); - await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + await sleep(seconds); return executeWithBotRetry(action, retries - 1, 0); } } @@ -62,61 +69,121 @@ interface TelegramFileInfo { file_path: string; } +interface TelegramGetFileResponse { + ok: boolean; + description?: string; + result: { + file_id: string; + }; +} + +interface TelegramGetInfoResponse { + ok: boolean; + description?: string; + result: TelegramFileInfo; +} + +interface UploadedTelegramFile { + file_id?: string; + file_unique_id?: string; +} + +interface TelegramMessageResult { + message_id: number; + document?: UploadedTelegramFile; + photo?: UploadedTelegramFile[]; + video?: UploadedTelegramFile; + audio?: UploadedTelegramFile; + voice?: UploadedTelegramFile; + animation?: UploadedTelegramFile; + sticker?: UploadedTelegramFile; + video_note?: UploadedTelegramFile; + [key: string]: unknown; +} + +type FilePayload = { source: unknown; filename: string }; +type SendPayload = { caption?: string }; +type SendMethod = ( + chatId: number, + filePayload: FilePayload, + payload?: SendPayload, +) => Promise; + +const sendMethodMap: Record = { + photo: 'sendPhoto', + audio: 'sendAudio', + video: 'sendVideo', + voice: 'sendVoice', + animation: 'sendAnimation', + sticker: 'sendSticker', + document: 'sendDocument', + video_note: 'sendDocument', +}; + +const extractUploadedFile = ( + result: TelegramMessageResult, + fileType: string, +): UploadedTelegramFile | undefined => { + if (result.document) return result.document; + if (result.photo) return result.photo?.slice(-1)[0]; + if (result.video) return result.video; + if (result.audio) return result.audio; + if (result.voice) return result.voice; + if (result.animation) return result.animation; + if (result.sticker) return result.sticker; + if (result.video_note) return result.video_note; + return result[fileType] as UploadedTelegramFile | undefined; +}; + +const buildSendPayload = (fileType: string, fileName: string): SendPayload => { + const basePayload = { caption: fileName }; + if (fileType === 'sticker') return {}; + if (fileType === 'document') return { caption: `📁 ${fileName}` }; + return basePayload; +}; + +const getMediaGroupType = (fileType: string): string => { + if (fileType === 'photo') return 'photo'; + if (fileType === 'video') return 'video'; + if (fileType === 'audio') return 'audio'; + return 'document'; +}; + +interface MediaGroupPayloadItem { + type: string; + media: string; + caption: string; +} + +const buildMediaGroup = (items: MediaGroupItem[]): MediaGroupPayloadItem[] => { + return items.map((item) => ({ + type: getMediaGroupType(item.fileType), + media: item.fileId, + caption: item.fileName, + })); +}; + export const forwardToStorage = async ( - fileChunk: any, + fileChunk: unknown, fileName: string, fileType: string, ): Promise => { try { - const result: any = await enqueueUpload(async () => { + const result = await enqueueUpload(async (): Promise => { const filePayload = { source: fileChunk, filename: fileName }; + const sendMethod = sendMethodMap[fileType] || 'sendDocument'; + const payload = buildSendPayload(fileType, fileName); + const uploadResult = await executeWithBotRetry((activeBot) => { - if (fileType === 'photo') { - return activeBot.telegram.sendPhoto(config.storageChatId, filePayload, { - caption: fileName, - }); - } else if (fileType === 'audio') { - return activeBot.telegram.sendAudio(config.storageChatId, filePayload, { - caption: fileName, - }); - } else if (fileType === 'video') { - return activeBot.telegram.sendVideo(config.storageChatId, filePayload, { - caption: fileName, - }); - } else if (fileType === 'voice') { - return activeBot.telegram.sendVoice(config.storageChatId, filePayload, { - caption: fileName, - }); - } else if (fileType === 'animation') { - return activeBot.telegram.sendAnimation(config.storageChatId, filePayload, { - caption: fileName, - }); - } else if (fileType === 'sticker') { - return activeBot.telegram.sendSticker(config.storageChatId, filePayload); - } else { - return activeBot.telegram.sendDocument(config.storageChatId, filePayload, { - caption: `📁 ${fileName}`, - }); - } + const telegram = activeBot.telegram as unknown as Record; + return telegram[sendMethod](config.storageChatId, filePayload, payload); }); - // Advance round-robin index for next job currentBotIndex = (currentBotIndex + 1) % bots.length; - return uploadResult; }); - let uploadedFile: any; - if (result.document) uploadedFile = result.document; - else if (result.photo) uploadedFile = result.photo?.slice(-1)[0]; - else if (result.video) uploadedFile = result.video; - else if (result.audio) uploadedFile = result.audio; - else if (result.voice) uploadedFile = result.voice; - else if (result.animation) uploadedFile = result.animation; - else if (result.sticker) uploadedFile = result.sticker; - else if (result.video_note) uploadedFile = result.video_note; - else uploadedFile = result[fileType]; - + const uploadedFile = extractUploadedFile(result, fileType); logger.info('File forwarded to storage', { fileName, message: result.message_id }); return { @@ -124,8 +191,11 @@ export const forwardToStorage = async ( telegramFileUniqueId: uploadedFile?.file_unique_id || '', storageMessageId: result.message_id, }; - } catch (error: any) { - logger.error('Failed to forward file to storage', { fileName, error: error.message }); + } catch (error: unknown) { + logger.error('Failed to forward file to storage', { + fileName, + error: error instanceof Error ? error.message : String(error), + }); throw error; } }; @@ -144,26 +214,18 @@ export const forwardMediaGroupToStorage = async ( telegramFileUniqueIds: string[]; }> => { try { - const result: any = await enqueueUpload(async () => { - const mediaGroup: any = items.map((item) => { - let type: 'photo' | 'video' | 'audio' | 'document' = 'document'; - if (item.fileType === 'photo') type = 'photo'; - else if (item.fileType === 'video') type = 'video'; - else if (item.fileType === 'audio') type = 'audio'; - - return { - type, - media: item.fileId, - caption: item.fileName, - }; - }); + const result = await enqueueUpload(async (): Promise => { + const mediaGroup = buildMediaGroup(items); const uploadResult = await executeWithBotRetry((activeBot) => { - return activeBot.telegram.sendMediaGroup(config.storageChatId, mediaGroup); + const sendMediaGroup = activeBot.telegram.sendMediaGroup as unknown as ( + chatId: number, + media: MediaGroupPayloadItem[], + ) => Promise; + return sendMediaGroup(config.storageChatId, mediaGroup); }); currentBotIndex = (currentBotIndex + 1) % bots.length; - return uploadResult; }); @@ -174,20 +236,7 @@ export const forwardMediaGroupToStorage = async ( const telegramFileUniqueIds: string[] = []; for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; - const fileType = items[i]?.fileType || 'document'; - let uploadedFile: any; - - if (msg.document) uploadedFile = msg.document; - else if (msg.photo) uploadedFile = msg.photo?.slice(-1)[0]; - else if (msg.video) uploadedFile = msg.video; - else if (msg.audio) uploadedFile = msg.audio; - else if (msg.voice) uploadedFile = msg.voice; - else if (msg.animation) uploadedFile = msg.animation; - else if (msg.sticker) uploadedFile = msg.sticker; - else if (msg.video_note) uploadedFile = msg.video_note; - else uploadedFile = msg[fileType]; - + const uploadedFile = extractUploadedFile(messages[i], items[i]?.fileType || 'document'); telegramFileIds.push(uploadedFile?.file_id || ''); telegramFileUniqueIds.push(uploadedFile?.file_unique_id || ''); } @@ -197,8 +246,10 @@ export const forwardMediaGroupToStorage = async ( telegramFileIds, telegramFileUniqueIds, }; - } catch (error: any) { - logger.error('Failed to forward media group to storage', { error: error.message }); + } catch (error: unknown) { + logger.error('Failed to forward media group to storage', { + error: error instanceof Error ? error.message : String(error), + }); throw error; } }; @@ -209,7 +260,7 @@ export const getFileInfo = async ( ): Promise => { try { const result = await fetch(`${TELEGRAM_API_URL}getFile`); - const data: any = await result.json(); + const data = (await result.json()) as TelegramGetFileResponse; if (!data.ok) { throw new Error(data.description || 'Telegram API error'); @@ -221,7 +272,7 @@ export const getFileInfo = async ( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_id: fileId }), }); - const fileInfo: any = await fileResult.json(); + const fileInfo = (await fileResult.json()) as TelegramGetInfoResponse; if (!fileInfo.ok) { throw new Error(fileInfo.description || 'Telegram info error'); @@ -232,8 +283,10 @@ export const getFileInfo = async ( mime_type: fileInfo.result.mime_type, file_path: fileInfo.result.file_path, }; - } catch (error: any) { - logger.error('Failed to get file info', { error: error.message }); + } catch (error: unknown) { + logger.error('Failed to get file info', { + error: error instanceof Error ? error.message : String(error), + }); throw error; } }; diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index c495124..af2a329 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -1,7 +1,17 @@ -// @ts-nocheck import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; -const mockServe = mock((options) => { +type ServeOptions = { + port?: number; + routes?: Record; +}; + +type MockServer = { + port?: number; + routes?: Record; + stop: ReturnType; +}; + +const mockServe = mock((options: ServeOptions): MockServer => { return { port: options.port, routes: options.routes, @@ -10,7 +20,7 @@ const mockServe = mock((options) => { }); const originalServe = Bun.serve; -Bun.serve = mockServe; +Bun.serve = mockServe as unknown as typeof Bun.serve; const mockStartBot = mock(() => Promise.resolve({ @@ -58,6 +68,7 @@ describe('Bootstrap Server', () => { const serveCallArgs = mockServe.mock.calls[0][0]; expect(serveCallArgs).toHaveProperty('port'); expect(serveCallArgs).toHaveProperty('routes'); + expect(serveCallArgs.routes).toBeDefined(); expect(serveCallArgs.routes).toHaveProperty('/api/upload'); expect(serveCallArgs.routes).toHaveProperty('/f/:public_id'); expect(serveCallArgs.routes).toHaveProperty('/file/:public_id/info'); diff --git a/test/bot.test.ts b/test/bot.test.ts index bed6db6..470f0ad 100644 --- a/test/bot.test.ts +++ b/test/bot.test.ts @@ -1,5 +1,5 @@ -// @ts-nocheck import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { TelegramMediaMessage } from '../src/utils/file'; import logger from '../src/utils/logger'; // Mock environment @@ -7,48 +7,66 @@ process.env.BOT_TOKEN = process.env.BOT_TOKEN || '123456789:ABCdefGhIJKlmNoPQRsT process.env.STORAGE_CHANNEL_ID = process.env.STORAGE_CHANNEL_ID || '-1001234567890'; process.env.BASE_URL = process.env.BASE_URL || 'https://tele.asepharyana.tech'; +type BotTestContext = { + message: TelegramMediaMessage; + from: { id: number }; + reply: ReturnType; +}; + +type BotFileHandler = (ctx: BotTestContext) => Promise; +type StartHandler = (ctx: { reply: ReturnType }) => Promise; + +const getStartHandler = (): StartHandler => { + return mockCommand.mock.calls.find((call) => call[0] === 'start')?.[1] as StartHandler; +}; + +const getFileHandler = (): BotFileHandler => { + return mockOn.mock.calls[0][1] as BotFileHandler; +}; + // Mock Telegraf const mockLaunch = mock(() => Promise.resolve()); const mockCommand = mock(); const mockOn = mock(); const mockUse = mock(); -mock.module('telegraf', () => { - return { - Telegraf: class { - constructor(token) { - this.token = token; - this.launch = mockLaunch; - this.command = mockCommand; - this.on = mockOn; - this.use = mockUse; - } - }, - }; -}); +class MockTelegraf { + token: string; + launch = mockLaunch; + command = mockCommand; + on = mockOn; + use = mockUse; + + constructor(token: string) { + this.token = token; + } +} + +mock.module('telegraf', () => ({ + Telegraf: MockTelegraf, +})); // Mock database const mockInsert = mock(() => ({ values: mock(() => Promise.resolve()), })); -const mockLimit = mock(() => Promise.resolve([])); -const mockWhere = mock(() => ({ - limit: mockLimit, -})); -const mockFrom = mock(() => ({ - where: mockWhere, -})); -const mockSelect = mock(() => ({ - from: mockFrom, -})); +type ExistingFile = { + publicId: string; + telegramFileId: string; + telegramFileUniqueId: string; +}; + +const mockFindFileByUniqueId = mock((): Promise => Promise.resolve(null)); + mock.module('../src/db/index', () => ({ db: { insert: mockInsert, - select: mockSelect, - }, - files: { - telegramFileUniqueId: 'telegram_file_unique_id', }, + files: {}, +})); + +mock.module('../src/db/files', () => ({ + findFileByUniqueId: mockFindFileByUniqueId, })); // Mock forwardToStorage @@ -73,8 +91,8 @@ describe('Telegram Bot Handler', () => { mockOn.mockClear(); mockUse.mockClear(); mockInsert.mockClear(); - mockLimit.mockClear(); - mockLimit.mockResolvedValue([]); + mockFindFileByUniqueId.mockClear(); + mockFindFileByUniqueId.mockResolvedValue(null); mockForwardToStorage.mockClear(); infoSpy.mockClear(); errorSpy.mockClear(); @@ -98,7 +116,7 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const startHandler = mockCommand.mock.calls.find((call) => call[0] === 'start')[1]; + const startHandler = getStartHandler(); const replyMock = mock(() => Promise.resolve()); const ctx = { reply: replyMock, @@ -112,7 +130,7 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const fileHandler = mockOn.mock.calls[0][1]; + const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); const ctx = { message: { @@ -144,7 +162,7 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const fileHandler = mockOn.mock.calls[0][1]; + const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); const ctx = { message: { @@ -173,17 +191,14 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const fileHandler = mockOn.mock.calls[0][1]; + const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); - // Mock DB to return an existing match - mockLimit.mockResolvedValueOnce([ - { - publicId: 'already_exists_abc', - telegramFileId: 'stored_file_id', - telegramFileUniqueId: 'doc_uniq_123', - }, - ]); + mockFindFileByUniqueId.mockResolvedValueOnce({ + publicId: 'already_exists_abc', + telegramFileId: 'stored_file_id', + telegramFileUniqueId: 'doc_uniq_123', + }); const ctx = { message: { @@ -215,7 +230,7 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const fileHandler = mockOn.mock.calls[0][1]; + const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); const ctx = { message: { @@ -245,7 +260,7 @@ describe('Telegram Bot Handler', () => { const { startBot } = await import('../src/bot'); await startBot(); - const fileHandler = mockOn.mock.calls[0][1]; + const fileHandler = getFileHandler(); const replyMock = mock(() => Promise.resolve()); const ctx = { message: { diff --git a/test/db.test.ts b/test/db.test.ts index 6700a76..9b34995 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { describe, expect, it } from 'bun:test'; import { db, files } from '../src/db/index'; import { files as schemaFiles } from '../src/db/schema'; diff --git a/test/env.test.ts b/test/env.test.ts index 5707a75..6072bb0 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { describe, expect, it } from 'bun:test'; import { config } from '../src/env'; @@ -16,12 +15,12 @@ describe('Environment Variables Validation', () => { }); it('config.botToken should return BOT_TOKEN from process.env', () => { - expect(config.botToken).toBe(process.env.BOT_TOKEN); + expect(config.botToken).toBe(process.env.BOT_TOKEN || ''); }); it('config.storageChatId should be parsed as integer from STORAGE_CHANNEL_ID', () => { expect(typeof config.storageChatId).toBe('number'); - expect(config.storageChatId).toBe(parseInt(process.env.STORAGE_CHANNEL_ID, 10)); + expect(config.storageChatId).toBe(parseInt(process.env.STORAGE_CHANNEL_ID || '0', 10)); }); it('config.port should default to 3000 when not specified', () => { diff --git a/test/file.test.ts b/test/file.test.ts index a3cacdb..db2df46 100644 --- a/test/file.test.ts +++ b/test/file.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { describe, expect, it } from 'bun:test'; import { checkFileSize, diff --git a/test/files.test.ts b/test/files.test.ts index 66f3147..ab24bef 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -1,23 +1,62 @@ -// @ts-nocheck import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; -// Mock database layer -const mockSelect = mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - limit: mock(() => Promise.resolve([])), - })), - })), -})); +type RequestWithParams = Request & { + params?: { + public_id?: string; + }; +}; -mock.module('../src/db/index', () => ({ - db: { - select: mockSelect, - }, - files: { - publicId: { - equals: (val) => ({ type: 'equals', value: val }), - }, +type ErrorBody = { + error: string; +}; + +type FileInfoBody = { + public_id: string; + file_name: string; + mime_type: string; + size_bytes: number; + file_type: string; + uploader_id: number; + created_at: string; +}; + +type JsonBody = ErrorBody | FileInfoBody | Record; + +type MockFileRecord = Record; + +type MockSelectChain = { + from: () => { + where: () => { + limit: () => Promise; + }; + }; +}; + +const requestWithPublicId = (url: string, publicId: string): RequestWithParams => { + const req = new Request(url) as RequestWithParams; + req.params = { public_id: publicId }; + return req; +}; + +const responseJson = async (res: Response): Promise => { + return (await res.json()) as T; +}; + +// Mock database layer +const emptySelectChain = (): MockSelectChain => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]), + }), + }), +}); + +const mockSelect = mock(() => emptySelectChain()); + +mock.module('../src/db/files', () => ({ + findFileByPublicId: async () => { + const chain = mockSelect(); + return (await chain.from().where().limit())[0] || null; }, })); @@ -38,7 +77,8 @@ mock.module('../src/utils/rateLimit', () => ({ })); describe('File Route Handlers', () => { - let handleFileRedirect: any, handleFileInfo: any; + let handleFileRedirect: typeof import('../src/routes/files').handleFileRedirect; + let handleFileInfo: typeof import('../src/routes/files').handleFileInfo; beforeEach(async () => { mockSelect.mockClear(); @@ -56,12 +96,11 @@ describe('File Route Handlers', () => { describe('handleFileRedirect', () => { it('should return 429 if rate limit is exceeded', async () => { mockCheckRateLimit.mockImplementationOnce(() => false); - const req = new Request('http://localhost:3000/f/test-id'); - req.params = { public_id: 'test-id' }; + const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id'); const res = await handleFileRedirect(req); expect(res.status).toBe(429); - const body = await res.json(); + const body = await responseJson(res); expect(body.error).toBe('Rate limit exceeded'); }); @@ -74,11 +113,10 @@ describe('File Route Handlers', () => { }), })); - const req = new Request('http://localhost:3000/f/missing-id'); - req.params = { public_id: 'missing-id' }; + const req = requestWithPublicId('http://localhost:3000/f/missing-id', 'missing-id'); const res = await handleFileRedirect(req); expect(res.status).toBe(404); - const body = await res.json(); + const body = await responseJson(res); expect(body.error).toBe('File not found'); }); @@ -99,8 +137,7 @@ describe('File Route Handlers', () => { }), })); - const req = new Request('http://localhost:3000/f/test-id'); - req.params = { public_id: 'test-id' }; + const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id'); const res = await handleFileRedirect(req); expect(res.status).toBe(302); expect(res.headers.get('Location')).toBe( @@ -113,11 +150,10 @@ describe('File Route Handlers', () => { throw new Error('DB Connection Error'); }); - const req = new Request('http://localhost:3000/f/test-id'); - req.params = { public_id: 'test-id' }; + const req = requestWithPublicId('http://localhost:3000/f/test-id', 'test-id'); const res = await handleFileRedirect(req); expect(res.status).toBe(500); - const body = await res.json(); + const body = await responseJson(res); expect(body.error).toBe('Server error'); }); }); @@ -132,11 +168,10 @@ describe('File Route Handlers', () => { }), })); - const req = new Request('http://localhost:3000/file/missing-id/info'); - req.params = { public_id: 'missing-id' }; + const req = requestWithPublicId('http://localhost:3000/file/missing-id/info', 'missing-id'); const res = await handleFileInfo(req); expect(res.status).toBe(404); - const body = await res.json(); + const body = await responseJson(res); expect(body.error).toBe('File not found'); }); @@ -159,11 +194,10 @@ describe('File Route Handlers', () => { }), })); - const req = new Request('http://localhost:3000/file/test-id/info'); - req.params = { public_id: 'test-id' }; + const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id'); const res = await handleFileInfo(req); expect(res.status).toBe(200); - const body = await res.json(); + const body = await responseJson(res); expect(body).toEqual({ public_id: 'test-id', file_name: 'image.png', @@ -180,11 +214,10 @@ describe('File Route Handlers', () => { throw new Error('DB Connection Error'); }); - const req = new Request('http://localhost:3000/file/test-id/info'); - req.params = { public_id: 'test-id' }; + const req = requestWithPublicId('http://localhost:3000/file/test-id/info', 'test-id'); const res = await handleFileInfo(req); expect(res.status).toBe(500); - const body = await res.json(); + const body = await responseJson(res); expect(body.error).toBe('Server error'); }); }); diff --git a/test/health.test.ts b/test/health.test.ts index b0b8215..988317b 100644 --- a/test/health.test.ts +++ b/test/health.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock database layer @@ -11,7 +10,7 @@ mock.module('../src/db/index', () => ({ })); describe('Health Route Handler', () => { - let handleHealth: any; + let handleHealth: typeof import('../src/routes/health').handleHealth; beforeEach(async () => { mockExecute.mockClear(); diff --git a/test/rateLimit.test.ts b/test/rateLimit.test.ts index fc655be..06147e6 100644 --- a/test/rateLimit.test.ts +++ b/test/rateLimit.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; import logger from '../src/utils/logger'; import { checkRateLimit, cleanupRateLimitCache } from '../src/utils/rateLimit'; diff --git a/test/swagger.test.ts b/test/swagger.test.ts index 54468c8..b220cdf 100644 --- a/test/swagger.test.ts +++ b/test/swagger.test.ts @@ -8,7 +8,11 @@ describe('Swagger Documentation Endpoints', () => { expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('application/json'); - const body = (await res.json()) as any; + const body = (await res.json()) as { + openapi: string; + info: { title: string }; + paths: Record } } }>; + }; expect(body.openapi).toBe('3.0.0'); expect(body.info.title).toBe('TeleUploader API'); expect(body.paths).toHaveProperty('/health'); diff --git a/test/telegram.test.ts b/test/telegram.test.ts index ea43b39..e3c3209 100644 --- a/test/telegram.test.ts +++ b/test/telegram.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { config } from '../src/env'; import logger from '../src/utils/logger'; @@ -57,7 +56,9 @@ const infoSpy = spyOn(logger, 'info'); const errorSpy = spyOn(logger, 'error'); describe('Telegram API Utilities', () => { - let forwardToStorage: any, getFileInfo: any, getBot: any; + let forwardToStorage: typeof import('../src/utils/telegram').forwardToStorage; + let getFileInfo: typeof import('../src/utils/telegram').getFileInfo; + let getBot: typeof import('../src/utils/telegram').getBot; beforeEach(async () => { infoSpy.mockClear(); diff --git a/test/upload.test.ts b/test/upload.test.ts index 2827585..4dd49d7 100644 --- a/test/upload.test.ts +++ b/test/upload.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; let realPhotoBuffer: Buffer; @@ -21,7 +20,26 @@ beforeAll(async () => { }); // Mock db -let mockSelectResult: any[] = []; +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; + +let mockSelectResult: unknown[] = []; + +const uploadResponseJson = async (res: Response): Promise => { + return (await res.json()) as UploadJsonBody; +}; const mockLimit = mock(() => Promise.resolve(mockSelectResult)); const mockWhere = mock(() => ({ @@ -79,7 +97,7 @@ mock.module('../src/utils/telegram', () => ({ })); describe('Upload Route Handler', () => { - let handleUpload: any; + let handleUpload: typeof import('../src/routes/upload').handleUpload; beforeEach(async () => { mockInsert.mockClear(); @@ -105,7 +123,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(400); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.error).toContain('Unsupported content type'); }); @@ -123,7 +141,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(200); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.public_id).toContain('mocked-nanoid-id'); expect(body.telegram_file_id).toBe('tg-file-id-123'); @@ -145,7 +163,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(400); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.error).toContain('Invalid JSON'); }); @@ -161,7 +179,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(200); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.public_id).toContain('mocked-nanoid-id'); expect(body.file_name).toBe('test_multi.png'); }); @@ -195,7 +213,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(200); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.public_id).toBe('existing-id-123'); expect(body.telegram_file_id).toBe('existing-tg-id'); @@ -242,7 +260,7 @@ describe('Upload Route Handler', () => { const res = await handleUpload(req); expect(res.status).toBe(200); - const body = await res.json(); + const body = await uploadResponseJson(res); expect(body.public_id).toBe('existing-json-id'); expect(body.telegram_file_id).toBe('existing-tg-json-id');