diff --git a/biome.json b/biome.json index c06aa39..ea9be48 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,11 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", - "organizeImports": { - "enabled": true + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } }, "linter": { "enabled": true, @@ -20,13 +24,12 @@ "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, - "lineWidth": 100, - "quoteStyle": "single", - "semicolons": "always" + "lineWidth": 100 }, "javascript": { "formatter": { - "quoteStyle": "single" + "quoteStyle": "single", + "semicolons": "always" } } } diff --git a/src/bot.ts b/src/bot.ts index 8d84df3..5e40b03 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,8 +1,8 @@ -import { Telegraf, type Context } from 'telegraf'; -import logger from './utils/logger'; -import { config } from './env'; -import { db, files as fileSchema } from './db'; import { nanoid } from 'nanoid'; +import { type Context, Telegraf } from 'telegraf'; +import { db, files as fileSchema } from './db'; +import { config } from './env'; +import logger from './utils/logger'; import { forwardToStorage } from './utils/telegram'; export const startBot = async (): Promise> => { @@ -12,76 +12,101 @@ export const startBot = async (): Promise> => { bot.command('start', async (ctx) => { await ctx.reply( `👋 Halo! Kirimkan file (document, photo, video, audio, voice, animation) ke bot ini. ` + - `File akan disimpan di private channel dan kamu dapat download link permanen.` + `File akan disimpan di private channel dan kamu dapat download link permanen.`, ); }); // Cast bot.on elements individually or explicitly as any to bypass Telegraf v4 typescript deprecation warnings on array syntax - (bot as any).on(['document', 'photo', 'video', 'audio', 'voice', 'animation'], async (ctx: any) => { - try { - const fileType: 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx.message.document ? 'document' : - ctx.message.photo ? 'photo' : - ctx.message.video ? 'video' : - ctx.message.audio ? 'audio' : - ctx.message.voice ? 'voice' : 'animation'; + (bot as any).on( + ['document', 'photo', 'video', 'audio', 'voice', 'animation'], + async (ctx: any) => { + try { + const fileType: 'document' | 'photo' | 'video' | 'audio' | 'voice' | 'animation' = ctx + .message.document + ? 'document' + : ctx.message.photo + ? 'photo' + : ctx.message.video + ? 'video' + : ctx.message.audio + ? 'audio' + : ctx.message.voice + ? 'voice' + : 'animation'; - const fileObj = fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType]; - const { file_id, file_size, mime_type } = fileObj; - const fileName = ctx.message.document?.file_name || - ctx.message.photo?.slice(-1)[0]?.file_name || - ctx.message.video?.file_name || - ctx.message.audio?.file_name || - ctx.message.voice?.file_name || - 'file'; + const fileObj = + fileType === 'photo' ? ctx.message.photo.slice(-1)[0] : ctx.message[fileType]; + const { file_id, file_size, mime_type } = fileObj; + const fileName = + ctx.message.document?.file_name || + ctx.message.photo?.slice(-1)[0]?.file_name || + ctx.message.video?.file_name || + ctx.message.audio?.file_name || + 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 = + fileType === 'photo' + ? 10 * 1024 * 1024 + : fileType === 'audio' + ? 200 * 1024 * 1024 + : fileType === 'voice' + ? 200 * 1024 * 1024 + : 2 * 1024 * 1024 * 1024; - if (file_size > maxSize) { - return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); + if (file_size > maxSize) { + return ctx.reply(`File size exceeds ${maxSize / (1024 * 1024)}MB limit`); + } + + const result = await forwardToStorage(file_id, fileName); + const publicId = nanoid(); + + const uploaded = { + publicId: publicId, + telegramFileId: result.telegramFileId, + telegramFileUniqueId: result.telegramFileUniqueId, + storageChatId: config.storageChatId, + storageMessageId: result.storageMessageId, + fileName: fileName, + mimeType: mime_type || 'application/octet-stream', + sizeBytes: file_size, + fileType: fileType, + uploaderId: ctx.from.id, + createdAt: new Date(), + updatedAt: new Date(), + }; + + 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 }, + }); + + logger.info('File uploaded via bot', { + publicId, + fileType, + fileName, + uploader: ctx.from.id, + }); + } catch (error: any) { + logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id }); + await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.'); } - - const result = await forwardToStorage(file_id, fileName); - const publicId = nanoid(); - - const uploaded = { - publicId: publicId, - telegramFileId: result.telegramFileId, - telegramFileUniqueId: result.telegramFileUniqueId, - storageChatId: config.storageChatId, - storageMessageId: result.storageMessageId, - fileName: fileName, - mimeType: mime_type || 'application/octet-stream', - sizeBytes: file_size, - fileType: fileType, - uploaderId: ctx.from.id, - createdAt: new Date(), - updatedAt: new Date() - }; - - 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 } - }); - - logger.info('File uploaded via bot', { publicId, fileType, fileName, uploader: ctx.from.id }); - } catch (error: any) { - logger.error('Bot file handler error', { error: error.message, chat_id: ctx.chat?.id }); - await ctx.reply('❌ Gagal mengupload file. Coba lagi nanti.'); - } - }); + }, + ); bot.use((ctx, next) => { - logger.info('Telegram event received', { type: (ctx.update as any).type, chat_id: ctx.chat?.id }); + logger.info('Telegram event received', { + type: (ctx.update as any).type, + chat_id: ctx.chat?.id, + }); return next(); }); await bot.launch(); - logger.info('Telegram bot started', { botToken: config.botToken?.substring(0, 10) + '...' }); + logger.info('Telegram bot started', { botToken: `${config.botToken?.substring(0, 10)}...` }); return bot; } catch (error: any) { diff --git a/src/db/index.ts b/src/db/index.ts index 15b1ebd..cb76516 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,9 +5,9 @@ import { files } from './schema'; const client = postgres(process.env.DATABASE_URL!, { max: 10, idle_timeout: 20, - connect_timeout: 10 + connect_timeout: 10, }); export const db = drizzle(client, { schema: { files } }); export { files }; -export default db; \ No newline at end of file +export default db; diff --git a/src/db/schema.ts b/src/db/schema.ts index 25531d6..fed2842 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,5 @@ -import { pgTable, text, bigint, timestamp, uuid } from 'drizzle-orm/pg-core'; -import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'; +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'; +import { bigint, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; export const files = pgTable('files', { id: uuid('id').primaryKey().defaultRandom(), @@ -14,8 +14,8 @@ export const files = pgTable('files', { fileType: text('file_type').notNull(), uploaderId: bigint('uploader_id', { mode: 'number' }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull() + updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export type File = InferSelectModel; -export type NewFile = InferInsertModel; \ No newline at end of file +export type NewFile = InferInsertModel; diff --git a/src/env.ts b/src/env.ts index 1176855..1823ae2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,7 +17,7 @@ const requiredEnv = { STORAGE_CHANNEL_ID: process.env.STORAGE_CHANNEL_ID, BASE_URL: process.env.BASE_URL, DATABASE_URL: process.env.DATABASE_URL, - PORT: process.env.PORT + PORT: process.env.PORT, }; const missing = Object.entries(requiredEnv) @@ -38,7 +38,9 @@ export const config: AppConfig = { nodeEnv: process.env.NODE_ENV || 'development', logLevel: process.env.LOG_LEVEL || 'info', rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS!, 10) || 60000, - rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS!, 10) || 30 + rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS!, 10) || 30, }; -logger.info('Environment variables loaded', { config: { ...config, botToken: config.botToken?.substring(0, 10) + '...' } }); +logger.info('Environment variables loaded', { + config: { ...config, botToken: `${config.botToken?.substring(0, 10)}...` }, +}); diff --git a/src/index.ts b/src/index.ts index ca6f4e7..1d6d7b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,28 @@ import { serve } from 'bun'; -import logger from './utils/logger'; -import { config } from './env'; import { startBot } from './bot'; -import { handleUpload } from './routes/upload'; -import { handleFileRedirect, handleFileInfo } from './routes/files'; +import { config } from './env'; +import { handleFileInfo, handleFileRedirect } from './routes/files'; import { handleHealth } from './routes/health'; +import { handleUpload } from './routes/upload'; +import logger from './utils/logger'; import { cleanupRateLimitCache } from './utils/rateLimit'; const server = serve({ port: config.port, routes: { '/api/upload': { - POST: handleUpload + POST: handleUpload, }, '/f/:public_id': { - GET: handleFileRedirect + GET: handleFileRedirect, }, '/file/:public_id/info': { - GET: handleFileInfo + GET: handleFileInfo, }, '/health': { - GET: handleHealth - } - } + GET: handleHealth, + }, + }, }); const bot = await startBot(); diff --git a/src/routes/files.ts b/src/routes/files.ts index 471491c..e114013 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -1,7 +1,7 @@ -import logger from '../utils/logger'; -import { db, files as fileSchema } from '../db'; -import { checkRateLimit } from '../utils/rateLimit'; import { eq } from 'drizzle-orm'; +import { db, files as fileSchema } from '../db'; +import logger from '../utils/logger'; +import { checkRateLimit } from '../utils/rateLimit'; type RequestWithParams = Request & { params?: { @@ -18,7 +18,11 @@ 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 result = await db + .select() + .from(fileSchema) + .where(eq(fileSchema.publicId, public_id)) + .limit(1); if (!result.length) { logger.warn('File not found', { public_id }); @@ -58,15 +66,21 @@ export const handleFileInfo = async (req: RequestWithParams): Promise } const file = result[0]; - return Response.json({ - public_id: file.publicId, - file_name: file.fileName, - mime_type: file.mimeType, - 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() - }, { status: 200 }); + return Response.json( + { + public_id: file.publicId, + file_name: file.fileName, + mime_type: file.mimeType, + 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(), + }, + { status: 200 }, + ); } catch (error: any) { logger.error('File info error', { public_id, error: error.message }); return Response.json({ error: 'Server error' }, { status: 500 }); diff --git a/src/routes/health.ts b/src/routes/health.ts index 41d90c6..8eafc31 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,6 +1,6 @@ -import logger from '../utils/logger'; -import { db } from '../db'; import { sql } from 'drizzle-orm'; +import { db } from '../db'; +import logger from '../utils/logger'; export const handleHealth = async (_req: Request): Promise => { try { diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 9e7c3b6..0726dc2 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -1,9 +1,9 @@ -import logger from '../utils/logger'; -import { db, files as fileSchema } from '../db'; import { nanoid } from 'nanoid'; -import { forwardToStorage, getBot } from '../utils/telegram'; -import { getFileType, checkFileSize, extractMimeType } from '../utils/file'; +import { db, files as fileSchema } from '../db'; import { config } from '../env'; +import { checkFileSize, extractMimeType, getFileType } from '../utils/file'; +import logger from '../utils/logger'; +import { forwardToStorage, getBot } from '../utils/telegram'; export const handleUpload = async (req: Request): Promise => { try { @@ -17,7 +17,7 @@ export const handleUpload = async (req: Request): Promise => { return Response.json( { error: 'Unsupported content type. Use multipart/form-data or application/json' }, - { status: 400 } + { status: 400 }, ); } catch (error: any) { logger.error('Upload error', { error: error.message }); @@ -29,7 +29,8 @@ const handleMultipartUpload = async (req: Request): Promise => { try { const formData = await req.formData(); const file = formData.get('file'); - const fileName = (formData.get('fileName') as string) || (file instanceof File ? file.name : null) || 'file'; + const fileName = + (formData.get('fileName') as string) || (file instanceof File ? file.name : null) || 'file'; if (!file || !(file instanceof File)) { return Response.json({ error: 'No file provided' }, { status: 400 }); @@ -44,7 +45,10 @@ const handleMultipartUpload = async (req: Request): Promise => { return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); } - const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); + const isDocument = + fileName.endsWith('.pdf') || + fileName.endsWith('.txt') || + !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); const result = await forwardToStorage(fileBuffer, fileName, isDocument); const bot = getBot(); const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any; @@ -61,7 +65,7 @@ const handleMultipartUpload = async (req: Request): Promise => { fileType: fileType, uploaderId: 0, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; await db.insert(fileSchema).values(uploaded); @@ -78,7 +82,7 @@ const handleMultipartUpload = async (req: Request): Promise => { file_type: uploaded.fileType, uploader_id: uploaded.uploaderId, created_at: uploaded.createdAt.toISOString(), - download_url: `${config.baseUrl}/f/${uploaded.publicId}` + download_url: `${config.baseUrl}/f/${uploaded.publicId}`, }; return Response.json(responsePayload, { status: 200 }); @@ -95,19 +99,25 @@ const handleJSONUpload = async (req: Request): Promise => { if (!file || typeof file !== 'string') { return Response.json( { error: 'Invalid JSON. Must include "file" (base64) and optional "fileName"' }, - { status: 400 } + { status: 400 }, ); } const fileBytes = Buffer.from(file, 'base64'); const mimeType = 'application/octet-stream'; - const fileType = getFileType(mimeType, fileName) === 'application' ? 'document' : getFileType(mimeType, fileName); + const fileType = + getFileType(mimeType, fileName) === 'application' + ? 'document' + : getFileType(mimeType, fileName); if (!checkFileSize(fileBytes.byteLength, fileType)) { return Response.json({ error: `File size exceeds ${fileType} limit` }, { status: 400 }); } - const isDocument = fileName.endsWith('.pdf') || fileName.endsWith('.txt') || !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); + const isDocument = + fileName.endsWith('.pdf') || + fileName.endsWith('.txt') || + !['photo', 'video', 'audio', 'voice', 'animation'].includes(fileType); const result = await forwardToStorage(fileBytes, fileName, isDocument); const bot = getBot(); const fileInfo = (await bot.telegram.getFile(result.telegramFileId)) as any; @@ -124,7 +134,7 @@ const handleJSONUpload = async (req: Request): Promise => { fileType: fileType, uploaderId: 0, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), }; await db.insert(fileSchema).values(uploaded); @@ -141,7 +151,7 @@ const handleJSONUpload = async (req: Request): Promise => { file_type: uploaded.fileType, uploader_id: uploaded.uploaderId, created_at: uploaded.createdAt.toISOString(), - download_url: `${config.baseUrl}/f/${uploaded.publicId}` + download_url: `${config.baseUrl}/f/${uploaded.publicId}`, }; return Response.json(responsePayload, { status: 200 }); diff --git a/src/utils/file.ts b/src/utils/file.ts index 28cf679..2ecb56f 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -4,7 +4,7 @@ const FILE_TYPES: Record = { video: 2 * 1024 * 1024 * 1024, // 2GB audio: 200 * 1024 * 1024, // 200MB voice: 200 * 1024 * 1024, // 200MB - animation: 2 * 1024 * 1024 * 1024 // 2GB + animation: 2 * 1024 * 1024 * 1024, // 2GB }; export const getFileType = (mime: string | null, caption?: string): string => { @@ -30,14 +30,26 @@ export const extractFileName = (msg: any, request: any): string => { if (request?.headers?.['x-file-name']) { return request.headers['x-file-name']; } - return msg.document?.fileName || msg.photo?.slice(-1)[0]?.fileName || msg.audio?.fileName || - msg.voice?.fileName || msg.animation?.fileName || 'file'; + return ( + msg.document?.fileName || + msg.photo?.slice(-1)[0]?.fileName || + msg.audio?.fileName || + msg.voice?.fileName || + msg.animation?.fileName || + 'file' + ); }; export const extractMimeType = (msg: any, request: any): string => { if (request?.headers?.['x-mime-type']) { return request.headers['x-mime-type']; } - return msg.document?.mimeType || msg.photo?.slice(-1)[0]?.mimeType || msg.audio?.mimeType || - msg.voice?.mimeType || msg.animation?.mimeType || 'application/octet-stream'; + return ( + msg.document?.mimeType || + msg.photo?.slice(-1)[0]?.mimeType || + msg.audio?.mimeType || + msg.voice?.mimeType || + msg.animation?.mimeType || + 'application/octet-stream' + ); }; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 67ab549..910d666 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -5,24 +5,23 @@ const logger = winston.createLogger({ format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), - winston.format.json() + winston.format.json(), ), defaultMeta: { service: 'teleuploader' }, transports: [ // Write all logs including error logs to file new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), - new winston.transports.File({ filename: 'logs/combined.log' }) - ] + new winston.transports.File({ filename: 'logs/combined.log' }), + ], }); // If not production, also log to console if (process.env.NODE_ENV !== 'production') { - logger.add(new winston.transports.Console({ - format: winston.format.combine( - winston.format.colorize(), - winston.format.simple() - ) - })); + logger.add( + new winston.transports.Console({ + format: winston.format.combine(winston.format.colorize(), winston.format.simple()), + }), + ); } export default logger; diff --git a/src/utils/telegram.ts b/src/utils/telegram.ts index b86933f..f1fec49 100644 --- a/src/utils/telegram.ts +++ b/src/utils/telegram.ts @@ -1,6 +1,6 @@ import { Telegraf } from 'telegraf'; -import logger from './logger'; import { config } from '../env'; +import logger from './logger'; const bot = new Telegraf(config.botToken); const TELEGRAM_API_URL = `https://api.telegram.org/bot${config.botToken}/`; @@ -20,11 +20,13 @@ interface TelegramFileInfo { export const forwardToStorage = async ( fileChunk: any, fileName: string, - forceDocument = false + forceDocument = false, ): Promise => { try { const caption = forceDocument ? `📁 ${fileName}` : fileName; - const input: any = forceDocument ? { document: fileChunk, caption } : { photo: [fileChunk], caption }; + const input: any = forceDocument + ? { document: fileChunk, caption } + : { photo: [fileChunk], caption }; const result = await bot.telegram.sendPhoto(config.storageChatId, input); @@ -33,7 +35,7 @@ export const forwardToStorage = async ( return { telegramFileId: result.photo?.slice(-1)[0]?.file_id || '', telegramFileUniqueId: result.photo?.slice(-1)[0]?.file_unique_id || '', - storageMessageId: result.message_id + storageMessageId: result.message_id, }; } catch (error: any) { logger.error('Failed to forward file to storage', { fileName, error: error.message }); @@ -43,7 +45,7 @@ export const forwardToStorage = async ( export const getFileInfo = async ( telegramFileId: string, - telegramFileUniqueId: string + telegramFileUniqueId: string, ): Promise => { try { const result = await fetch(`${TELEGRAM_API_URL}getFile`); @@ -57,7 +59,7 @@ export const getFileInfo = async ( const fileResult = await fetch(`${TELEGRAM_API_URL}getInfo`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ file_id: fileId }) + body: JSON.stringify({ file_id: fileId }), }); const fileInfo: any = await fileResult.json(); @@ -68,7 +70,7 @@ export const getFileInfo = async ( return { file_size: fileInfo.result.file_size, mime_type: fileInfo.result.mime_type, - file_path: fileInfo.result.file_path + file_path: fileInfo.result.file_path, }; } catch (error: any) { logger.error('Failed to get file info', { error: error.message }); diff --git a/test/bootstrap.test.ts b/test/bootstrap.test.ts index 49c99b7..c495124 100644 --- a/test/bootstrap.test.ts +++ b/test/bootstrap.test.ts @@ -1,43 +1,45 @@ // @ts-nocheck -import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; const mockServe = mock((options) => { return { port: options.port, routes: options.routes, - stop: mock() + stop: mock(), }; }); const originalServe = Bun.serve; Bun.serve = mockServe; -const mockStartBot = mock(() => Promise.resolve({ - stop: mock() +const mockStartBot = mock(() => + Promise.resolve({ + stop: mock(), + }), +); + +mock.module('../src/bot', () => ({ + startBot: mockStartBot, })); -mock.module("../src/bot", () => ({ - startBot: mockStartBot +mock.module('../src/routes/upload', () => ({ + handleUpload: mock(), })); -mock.module("../src/routes/upload", () => ({ - handleUpload: mock() -})); - -mock.module("../src/routes/files", () => ({ +mock.module('../src/routes/files', () => ({ handleFileRedirect: mock(), - handleFileInfo: mock() + handleFileInfo: mock(), })); -mock.module("../src/routes/health", () => ({ - handleHealth: mock() +mock.module('../src/routes/health', () => ({ + handleHealth: mock(), })); -mock.module("../src/utils/rateLimit", () => ({ - cleanupRateLimitCache: mock() +mock.module('../src/utils/rateLimit', () => ({ + cleanupRateLimitCache: mock(), })); -describe("Bootstrap Server", () => { +describe('Bootstrap Server', () => { beforeEach(() => { mockServe.mockClear(); mockStartBot.mockClear(); @@ -47,18 +49,18 @@ describe("Bootstrap Server", () => { Bun.serve = originalServe; }); - it("should bootstrap the application successfully", async () => { - await import("../src/index"); + it('should bootstrap the application successfully', async () => { + await import('../src/index'); expect(mockServe).toHaveBeenCalled(); expect(mockStartBot).toHaveBeenCalled(); const serveCallArgs = mockServe.mock.calls[0][0]; - expect(serveCallArgs).toHaveProperty("port"); - expect(serveCallArgs).toHaveProperty("routes"); - expect(serveCallArgs.routes).toHaveProperty("/api/upload"); - expect(serveCallArgs.routes).toHaveProperty("/f/:public_id"); - expect(serveCallArgs.routes).toHaveProperty("/file/:public_id/info"); - expect(serveCallArgs.routes).toHaveProperty("/health"); + expect(serveCallArgs).toHaveProperty('port'); + expect(serveCallArgs).toHaveProperty('routes'); + expect(serveCallArgs.routes).toHaveProperty('/api/upload'); + expect(serveCallArgs.routes).toHaveProperty('/f/:public_id'); + expect(serveCallArgs.routes).toHaveProperty('/file/:public_id/info'); + expect(serveCallArgs.routes).toHaveProperty('/health'); }); }); diff --git a/test/bot.test.ts b/test/bot.test.ts index 95c051c..e5c4233 100644 --- a/test/bot.test.ts +++ b/test/bot.test.ts @@ -1,11 +1,11 @@ // @ts-nocheck -import { describe, it, expect, mock, spyOn, beforeEach, afterAll } from "bun:test"; -import logger from "../src/utils/logger"; +import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import logger from '../src/utils/logger'; // Mock environment -process.env.BOT_TOKEN = "8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY"; -process.env.STORAGE_CHANNEL_ID = "-1003996572954"; -process.env.BASE_URL = "https://tele.asepharyana.tech"; +process.env.BOT_TOKEN = '8605908810:AAFpUzlIBktfd_7wpEj7zMJob2CFxvG-ZGY'; +process.env.STORAGE_CHANNEL_ID = '-1003996572954'; +process.env.BASE_URL = 'https://tele.asepharyana.tech'; // Mock Telegraf const mockLaunch = mock(() => Promise.resolve()); @@ -13,7 +13,7 @@ const mockCommand = mock(); const mockOn = mock(); const mockUse = mock(); -mock.module("telegraf", () => { +mock.module('telegraf', () => { return { Telegraf: class { constructor(token) { @@ -23,35 +23,37 @@ mock.module("telegraf", () => { this.on = mockOn; this.use = mockUse; } - } + }, }; }); // Mock database const mockInsert = mock(() => ({ - values: mock(() => Promise.resolve()) + values: mock(() => Promise.resolve()), })); -mock.module("../src/db/index", () => ({ +mock.module('../src/db/index', () => ({ db: { - insert: mockInsert + insert: mockInsert, }, - files: {} + files: {}, })); // Mock forwardToStorage -const mockForwardToStorage = mock(() => Promise.resolve({ - telegramFileId: "stored_file_id", - telegramFileUniqueId: "stored_unique_id", - storageMessageId: 9999 -})); -mock.module("../src/utils/telegram", () => ({ - forwardToStorage: mockForwardToStorage +const mockForwardToStorage = mock(() => + Promise.resolve({ + telegramFileId: 'stored_file_id', + telegramFileUniqueId: 'stored_unique_id', + storageMessageId: 9999, + }), +); +mock.module('../src/utils/telegram', () => ({ + forwardToStorage: mockForwardToStorage, })); -const infoSpy = spyOn(logger, "info"); -const errorSpy = spyOn(logger, "error"); +const infoSpy = spyOn(logger, 'info'); +const errorSpy = spyOn(logger, 'error'); -describe("Telegram Bot Handler", () => { +describe('Telegram Bot Handler', () => { beforeEach(() => { mockLaunch.mockClear(); mockCommand.mockClear(); @@ -63,36 +65,36 @@ describe("Telegram Bot Handler", () => { errorSpy.mockClear(); }); - it("should initialize and launch the bot", async () => { - const { startBot } = await import("../src/bot"); + it('should initialize and launch the bot', async () => { + const { startBot } = await import('../src/bot'); const bot = await startBot(); expect(bot).toBeDefined(); - expect(mockCommand).toHaveBeenCalledWith("start", expect.any(Function)); + expect(mockCommand).toHaveBeenCalledWith('start', expect.any(Function)); expect(mockOn).toHaveBeenCalledWith( - ["document", "photo", "video", "audio", "voice", "animation"], - expect.any(Function) + ['document', 'photo', 'video', 'audio', 'voice', 'animation'], + expect.any(Function), ); expect(mockUse).toHaveBeenCalled(); expect(mockLaunch).toHaveBeenCalled(); }); - it("should handle /start command", async () => { - const { startBot } = await import("../src/bot"); + it('should handle /start command', async () => { + const { startBot } = await import('../src/bot'); await startBot(); - const startHandler = mockCommand.mock.calls.find(call => call[0] === "start")[1]; + const startHandler = mockCommand.mock.calls.find((call) => call[0] === 'start')[1]; const replyMock = mock(() => Promise.resolve()); const ctx = { - reply: replyMock + reply: replyMock, }; await startHandler(ctx); - expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Halo")); + expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('Halo')); }); - it("should process document uploads and save to db", async () => { - const { startBot } = await import("../src/bot"); + it('should process document uploads and save to db', async () => { + const { startBot } = await import('../src/bot'); await startBot(); const fileHandler = mockOn.mock.calls[0][1]; @@ -101,27 +103,30 @@ describe("Telegram Bot Handler", () => { message: { message_id: 42, document: { - file_id: "doc_123", - file_unique_id: "doc_uniq_123", + file_id: 'doc_123', + file_unique_id: 'doc_uniq_123', file_size: 1024, - mime_type: "application/pdf", - file_name: "cv.pdf" - } + mime_type: 'application/pdf', + file_name: 'cv.pdf', + }, }, from: { - id: 999 + id: 999, }, - reply: replyMock + reply: replyMock, }; await fileHandler(ctx); - expect(mockForwardToStorage).toHaveBeenCalledWith("doc_123", "cv.pdf"); + expect(mockForwardToStorage).toHaveBeenCalledWith('doc_123', 'cv.pdf'); expect(mockInsert).toHaveBeenCalled(); - expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("File berhasil diupload"), expect.any(Object)); + expect(replyMock).toHaveBeenCalledWith( + expect.stringContaining('File berhasil diupload'), + expect.any(Object), + ); }); - it("should reject uploads exceeding max size limit", async () => { - const { startBot } = await import("../src/bot"); + it('should reject uploads exceeding max size limit', async () => { + const { startBot } = await import('../src/bot'); await startBot(); const fileHandler = mockOn.mock.calls[0][1]; @@ -129,22 +134,24 @@ describe("Telegram Bot Handler", () => { const ctx = { message: { message_id: 42, - photo: [{ - file_id: "photo_123", - file_unique_id: "photo_uniq_123", - file_size: 20 * 1024 * 1024, // 20MB exceeds 10MB limit - mime_type: "image/jpeg" - }] + photo: [ + { + file_id: 'photo_123', + file_unique_id: 'photo_uniq_123', + file_size: 20 * 1024 * 1024, // 20MB exceeds 10MB limit + mime_type: 'image/jpeg', + }, + ], }, from: { - id: 999 + id: 999, }, - reply: replyMock + reply: replyMock, }; await fileHandler(ctx); expect(mockForwardToStorage).not.toHaveBeenCalled(); - expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("exceeds")); + expect(replyMock).toHaveBeenCalledWith(expect.stringContaining('exceeds')); }); afterAll(() => { diff --git a/test/db.test.ts b/test/db.test.ts index 27edf1f..b4ba804 100644 --- a/test/db.test.ts +++ b/test/db.test.ts @@ -1,20 +1,20 @@ // @ts-nocheck -import { describe, it, expect } from "bun:test"; -import { db, files } from "../src/db/index"; -import { files as schemaFiles } from "../src/db/schema"; +import { describe, expect, it } from 'bun:test'; +import { db, files } from '../src/db/index'; +import { files as schemaFiles } from '../src/db/schema'; -describe("Database Layer", () => { - it("should export db instance", () => { +describe('Database Layer', () => { + it('should export db instance', () => { expect(db).toBeDefined(); }); - it("should export files schema from both index and schema", () => { + it('should export files schema from both index and schema', () => { expect(files).toBeDefined(); expect(schemaFiles).toBeDefined(); expect(files).toBe(schemaFiles); }); - it("should have correct schema properties", () => { + it('should have correct schema properties', () => { expect(files.id).toBeDefined(); expect(files.publicId).toBeDefined(); expect(files.telegramFileId).toBeDefined(); diff --git a/test/env.test.ts b/test/env.test.ts index 9d241d8..5707a75 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -1,46 +1,46 @@ // @ts-nocheck -import { describe, it, expect, beforeAll } from "bun:test"; -import { config } from "../src/env"; +import { describe, expect, it } from 'bun:test'; +import { config } from '../src/env'; -describe("Environment Variables Validation", () => { - it("config should have all required fields", () => { - expect(config).toHaveProperty("botToken"); - expect(config).toHaveProperty("storageChatId"); - expect(config).toHaveProperty("baseUrl"); - expect(config).toHaveProperty("databaseUrl"); - expect(config).toHaveProperty("port"); - expect(config).toHaveProperty("nodeEnv"); - expect(config).toHaveProperty("logLevel"); - expect(config).toHaveProperty("rateLimitWindowMs"); - expect(config).toHaveProperty("rateLimitMaxRequests"); +describe('Environment Variables Validation', () => { + it('config should have all required fields', () => { + expect(config).toHaveProperty('botToken'); + expect(config).toHaveProperty('storageChatId'); + expect(config).toHaveProperty('baseUrl'); + expect(config).toHaveProperty('databaseUrl'); + expect(config).toHaveProperty('port'); + expect(config).toHaveProperty('nodeEnv'); + expect(config).toHaveProperty('logLevel'); + expect(config).toHaveProperty('rateLimitWindowMs'); + expect(config).toHaveProperty('rateLimitMaxRequests'); }); - it("config.botToken should return BOT_TOKEN from process.env", () => { + it('config.botToken should return BOT_TOKEN from process.env', () => { 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"); + 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)); }); - it("config.port should default to 3000 when not specified", () => { - expect(typeof config.port).toBe("number"); + it('config.port should default to 3000 when not specified', () => { + expect(typeof config.port).toBe('number'); }); it("nodeEnv should be 'test' or 'development'", () => { - expect(["test", "development"]).toContain(config.nodeEnv); + expect(['test', 'development']).toContain(config.nodeEnv); }); it("logLevel should default to 'info'", () => { - expect(config.logLevel).toBe("info"); + expect(config.logLevel).toBe('info'); }); - it("rateLimitWindowMs should default to 60000 when not specified", () => { + it('rateLimitWindowMs should default to 60000 when not specified', () => { expect(config.rateLimitWindowMs).toBe(60000); }); - it("rateLimitMaxRequests should default to 30 when not specified", () => { + it('rateLimitMaxRequests should default to 30 when not specified', () => { expect(config.rateLimitMaxRequests).toBe(30); }); }); diff --git a/test/file.test.ts b/test/file.test.ts index 87271be..22377d9 100644 --- a/test/file.test.ts +++ b/test/file.test.ts @@ -1,89 +1,95 @@ // @ts-nocheck -import { describe, it, expect } from "bun:test"; -import { getFileType, checkFileSize, extractFileName, extractMimeType } from "../src/utils/file"; +import { describe, expect, it } from 'bun:test'; +import { checkFileSize, extractFileName, extractMimeType, getFileType } from '../src/utils/file'; -describe("File Utilities", () => { - describe("getFileType", () => { - it("should classify video mime types as video", () => { - expect(getFileType("video/mp4", "")).toBe("video"); - expect(getFileType("video/quicktime", "")).toBe("video"); +describe('File Utilities', () => { + describe('getFileType', () => { + it('should classify video mime types as video', () => { + expect(getFileType('video/mp4', '')).toBe('video'); + expect(getFileType('video/quicktime', '')).toBe('video'); }); - it("should classify audio mime types as audio", () => { - expect(getFileType("audio/mpeg", "")).toBe("audio"); - expect(getFileType("audio/ogg", "")).toBe("audio"); + it('should classify audio mime types as audio', () => { + expect(getFileType('audio/mpeg', '')).toBe('audio'); + expect(getFileType('audio/ogg', '')).toBe('audio'); }); - it("should classify image mime types based on caption", () => { - expect(getFileType("image/jpeg", "my photo")).toBe("photo"); - expect(getFileType("image/png", "cool image.png")).toBe("photo"); - expect(getFileType("image/gif", "funny.gif")).toBe("animation"); - expect(getFileType("image/png", "funny gif")).toBe("animation"); + it('should classify image mime types based on caption', () => { + expect(getFileType('image/jpeg', 'my photo')).toBe('photo'); + expect(getFileType('image/png', 'cool image.png')).toBe('photo'); + expect(getFileType('image/gif', 'funny.gif')).toBe('animation'); + expect(getFileType('image/png', 'funny gif')).toBe('animation'); }); - it("should classify voice and animation based on caption", () => { - expect(getFileType("application/octet-stream", "this is a voice note")).toBe("voice"); - expect(getFileType("application/octet-stream", "cool animation")).toBe("animation"); + it('should classify voice and animation based on caption', () => { + expect(getFileType('application/octet-stream', 'this is a voice note')).toBe('voice'); + expect(getFileType('application/octet-stream', 'cool animation')).toBe('animation'); }); - it("should default to mime first segment or document", () => { - expect(getFileType("application/pdf", "")).toBe("application"); - expect(getFileType(null, "")).toBe("document"); + it('should default to mime first segment or document', () => { + expect(getFileType('application/pdf', '')).toBe('application'); + expect(getFileType(null, '')).toBe('document'); }); }); - describe("checkFileSize", () => { - it("should allow files under the size limit", () => { - expect(checkFileSize(5 * 1024 * 1024, "photo")).toBe(true); // Photo limit is 10MB - expect(checkFileSize(1 * 1024 * 1024 * 1024, "video")).toBe(true); // Video limit is 2GB + describe('checkFileSize', () => { + it('should allow files under the size limit', () => { + expect(checkFileSize(5 * 1024 * 1024, 'photo')).toBe(true); // Photo limit is 10MB + expect(checkFileSize(1 * 1024 * 1024 * 1024, 'video')).toBe(true); // Video limit is 2GB }); - it("should block files exceeding the size limit", () => { - expect(checkFileSize(15 * 1024 * 1024, "photo")).toBe(false); // Photo limit is 10MB - expect(checkFileSize(3 * 1024 * 1024 * 1024, "video")).toBe(false); // Video limit is 2GB + it('should block files exceeding the size limit', () => { + expect(checkFileSize(15 * 1024 * 1024, 'photo')).toBe(false); // Photo limit is 10MB + expect(checkFileSize(3 * 1024 * 1024 * 1024, 'video')).toBe(false); // Video limit is 2GB }); - it("should fall back to document limit if fileType is unknown", () => { - expect(checkFileSize(1 * 1024 * 1024 * 1024, "unknown")).toBe(true); // Document limit is 2GB - expect(checkFileSize(3 * 1024 * 1024 * 1024, "unknown")).toBe(false); + it('should fall back to document limit if fileType is unknown', () => { + expect(checkFileSize(1 * 1024 * 1024 * 1024, 'unknown')).toBe(true); // Document limit is 2GB + expect(checkFileSize(3 * 1024 * 1024 * 1024, 'unknown')).toBe(false); }); }); - describe("extractFileName", () => { - it("should extract file name from headers if present", () => { - const req = { headers: { "x-file-name": "custom.txt" } }; - expect(extractFileName({}, req)).toBe("custom.txt"); + describe('extractFileName', () => { + it('should extract file name from headers if present', () => { + const req = { headers: { 'x-file-name': 'custom.txt' } }; + expect(extractFileName({}, req)).toBe('custom.txt'); }); - it("should extract file name from various message attachment types", () => { - expect(extractFileName({ document: { fileName: "doc.pdf" } }, null)).toBe("doc.pdf"); - expect(extractFileName({ photo: [{ fileName: "low.jpg" }, { fileName: "high.jpg" }] }, null)).toBe("high.jpg"); - expect(extractFileName({ audio: { fileName: "song.mp3" } }, null)).toBe("song.mp3"); - expect(extractFileName({ voice: { fileName: "voice.ogg" } }, null)).toBe("voice.ogg"); - expect(extractFileName({ animation: { fileName: "anim.gif" } }, null)).toBe("anim.gif"); + it('should extract file name from various message attachment types', () => { + expect(extractFileName({ document: { fileName: 'doc.pdf' } }, null)).toBe('doc.pdf'); + expect( + extractFileName({ photo: [{ fileName: 'low.jpg' }, { fileName: 'high.jpg' }] }, null), + ).toBe('high.jpg'); + expect(extractFileName({ audio: { fileName: 'song.mp3' } }, null)).toBe('song.mp3'); + expect(extractFileName({ voice: { fileName: 'voice.ogg' } }, null)).toBe('voice.ogg'); + expect(extractFileName({ animation: { fileName: 'anim.gif' } }, null)).toBe('anim.gif'); }); - it("should return default filename if not found", () => { - expect(extractFileName({}, null)).toBe("file"); + it('should return default filename if not found', () => { + expect(extractFileName({}, null)).toBe('file'); }); }); - describe("extractMimeType", () => { - it("should extract mime type from headers if present", () => { - const req = { headers: { "x-mime-type": "text/plain" } }; - expect(extractMimeType({}, req)).toBe("text/plain"); + describe('extractMimeType', () => { + it('should extract mime type from headers if present', () => { + const req = { headers: { 'x-mime-type': 'text/plain' } }; + expect(extractMimeType({}, req)).toBe('text/plain'); }); - it("should extract mime type from various message attachment types", () => { - expect(extractMimeType({ document: { mimeType: "application/pdf" } }, null)).toBe("application/pdf"); - expect(extractMimeType({ photo: [{ mimeType: "image/jpeg" }, { mimeType: "image/png" }] }, null)).toBe("image/png"); - expect(extractMimeType({ audio: { mimeType: "audio/mpeg" } }, null)).toBe("audio/mpeg"); - expect(extractMimeType({ voice: { mimeType: "audio/ogg" } }, null)).toBe("audio/ogg"); - expect(extractMimeType({ animation: { mimeType: "video/mp4" } }, null)).toBe("video/mp4"); + it('should extract mime type from various message attachment types', () => { + expect(extractMimeType({ document: { mimeType: 'application/pdf' } }, null)).toBe( + 'application/pdf', + ); + expect( + extractMimeType({ photo: [{ mimeType: 'image/jpeg' }, { mimeType: 'image/png' }] }, null), + ).toBe('image/png'); + expect(extractMimeType({ audio: { mimeType: 'audio/mpeg' } }, null)).toBe('audio/mpeg'); + expect(extractMimeType({ voice: { mimeType: 'audio/ogg' } }, null)).toBe('audio/ogg'); + expect(extractMimeType({ animation: { mimeType: 'video/mp4' } }, null)).toBe('video/mp4'); }); - it("should return default mime type if not found", () => { - expect(extractMimeType({}, null)).toBe("application/octet-stream"); + it('should return default mime type if not found', () => { + expect(extractMimeType({}, null)).toBe('application/octet-stream'); }); }); }); diff --git a/test/files.test.ts b/test/files.test.ts index 5c0659e..66f3147 100644 --- a/test/files.test.ts +++ b/test/files.test.ts @@ -1,44 +1,44 @@ // @ts-nocheck -import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock database layer const mockSelect = mock(() => ({ from: mock(() => ({ where: mock(() => ({ - limit: mock(() => Promise.resolve([])) - })) - })) + limit: mock(() => Promise.resolve([])), + })), + })), })); -mock.module("../src/db/index", () => ({ +mock.module('../src/db/index', () => ({ db: { - select: mockSelect + select: mockSelect, }, files: { publicId: { - equals: (val) => ({ type: "equals", value: val }) - } - } + equals: (val) => ({ type: 'equals', value: val }), + }, + }, })); // Mock telegram utils -const mockGetFile = mock(() => Promise.resolve({ file_path: "photos/file_0.jpg" })); -mock.module("../src/utils/telegram", () => ({ +const mockGetFile = mock(() => Promise.resolve({ file_path: 'photos/file_0.jpg' })); +mock.module('../src/utils/telegram', () => ({ getBot: () => ({ telegram: { - getFile: mockGetFile - } - }) + getFile: mockGetFile, + }, + }), })); // Mock rateLimit const mockCheckRateLimit = mock(() => true); -mock.module("../src/utils/rateLimit", () => ({ - checkRateLimit: mockCheckRateLimit +mock.module('../src/utils/rateLimit', () => ({ + checkRateLimit: mockCheckRateLimit, })); -describe("File Route Handlers", () => { - let handleFileRedirect, handleFileInfo; +describe('File Route Handlers', () => { + let handleFileRedirect: any, handleFileInfo: any; beforeEach(async () => { mockSelect.mockClear(); @@ -46,141 +46,146 @@ describe("File Route Handlers", () => { mockCheckRateLimit.mockClear(); // Set up mock token - process.env.BOT_TOKEN = "123456:ABC-DEF"; + process.env.BOT_TOKEN = '123456:ABC-DEF'; - const filesRoute = await import("../src/routes/files"); + const filesRoute = await import('../src/routes/files'); handleFileRedirect = filesRoute.handleFileRedirect; handleFileInfo = filesRoute.handleFileInfo; }); - describe("handleFileRedirect", () => { - it("should return 429 if rate limit is exceeded", async () => { + 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 = new Request('http://localhost:3000/f/test-id'); + req.params = { public_id: 'test-id' }; const res = await handleFileRedirect(req); expect(res.status).toBe(429); const body = await res.json(); - expect(body.error).toBe("Rate limit exceeded"); + expect(body.error).toBe('Rate limit exceeded'); }); - it("should return 404 if file is not found in database", async () => { + it('should return 404 if file is not found in database', async () => { mockSelect.mockImplementationOnce(() => ({ from: () => ({ where: () => ({ - limit: () => Promise.resolve([]) - }) - }) + limit: () => Promise.resolve([]), + }), + }), })); - const req = new Request("http://localhost:3000/f/missing-id"); - req.params = { public_id: "missing-id" }; + const req = new Request('http://localhost:3000/f/missing-id'); + req.params = { public_id: 'missing-id' }; const res = await handleFileRedirect(req); expect(res.status).toBe(404); const body = await res.json(); - expect(body.error).toBe("File not found"); + expect(body.error).toBe('File not found'); }); - it("should redirect to telegram file url if file is found", async () => { + it('should redirect to telegram file url if file is found', async () => { mockSelect.mockImplementationOnce(() => ({ from: () => ({ where: () => ({ - limit: () => Promise.resolve([{ - id: "uuid-123", - publicId: "test-id", - telegramFileId: "tg-file-id", - fileName: "test.jpg" - }]) - }) - }) + limit: () => + Promise.resolve([ + { + id: 'uuid-123', + publicId: 'test-id', + telegramFileId: 'tg-file-id', + fileName: 'test.jpg', + }, + ]), + }), + }), })); - const req = new Request("http://localhost:3000/f/test-id"); - req.params = { public_id: "test-id" }; + const req = new Request('http://localhost:3000/f/test-id'); + req.params = { public_id: 'test-id' }; const res = await handleFileRedirect(req); expect(res.status).toBe(302); - expect(res.headers.get("Location")).toBe("https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg"); + expect(res.headers.get('Location')).toBe( + 'https://api.telegram.org/file/bot123456:ABC-DEF/photos/file_0.jpg', + ); }); - it("should return 500 on database or external errors", async () => { + it('should return 500 on database or external errors', async () => { mockSelect.mockImplementationOnce(() => { - throw new Error("DB Connection Error"); + throw new Error('DB Connection Error'); }); - const req = new Request("http://localhost:3000/f/test-id"); - req.params = { public_id: "test-id" }; + const req = new Request('http://localhost:3000/f/test-id'); + req.params = { public_id: 'test-id' }; const res = await handleFileRedirect(req); expect(res.status).toBe(500); const body = await res.json(); - expect(body.error).toBe("Server error"); + expect(body.error).toBe('Server error'); }); }); - describe("handleFileInfo", () => { - it("should return 404 if file is not found in database", async () => { + describe('handleFileInfo', () => { + it('should return 404 if file is not found in database', async () => { mockSelect.mockImplementationOnce(() => ({ from: () => ({ where: () => ({ - limit: () => Promise.resolve([]) - }) - }) + limit: () => Promise.resolve([]), + }), + }), })); - const req = new Request("http://localhost:3000/file/missing-id/info"); - req.params = { public_id: "missing-id" }; + const req = new Request('http://localhost:3000/file/missing-id/info'); + req.params = { public_id: 'missing-id' }; const res = await handleFileInfo(req); expect(res.status).toBe(404); const body = await res.json(); - expect(body.error).toBe("File not found"); + expect(body.error).toBe('File not found'); }); - it("should return file info JSON if file is found", async () => { + it('should return file info JSON if file is found', async () => { const dbFile = { - publicId: "test-id", - fileName: "image.png", - mimeType: "image/png", + publicId: 'test-id', + fileName: 'image.png', + mimeType: 'image/png', sizeBytes: 2048, - fileType: "photo", + fileType: 'photo', uploaderId: 99999, - createdAt: new Date("2026-05-18T00:00:00.000Z") + createdAt: new Date('2026-05-18T00:00:00.000Z'), }; mockSelect.mockImplementationOnce(() => ({ from: () => ({ where: () => ({ - limit: () => Promise.resolve([dbFile]) - }) - }) + limit: () => Promise.resolve([dbFile]), + }), + }), })); - const req = new Request("http://localhost:3000/file/test-id/info"); - req.params = { public_id: "test-id" }; + const req = new Request('http://localhost:3000/file/test-id/info'); + req.params = { public_id: 'test-id' }; const res = await handleFileInfo(req); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ - public_id: "test-id", - file_name: "image.png", - mime_type: "image/png", + public_id: 'test-id', + file_name: 'image.png', + mime_type: 'image/png', size_bytes: 2048, - file_type: "photo", + file_type: 'photo', uploader_id: 99999, - created_at: "2026-05-18T00:00:00.000Z" + created_at: '2026-05-18T00:00:00.000Z', }); }); - it("should return 500 on database or external errors", async () => { + it('should return 500 on database or external errors', async () => { mockSelect.mockImplementationOnce(() => { - throw new Error("DB Connection Error"); + 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 = new Request('http://localhost:3000/file/test-id/info'); + req.params = { public_id: 'test-id' }; const res = await handleFileInfo(req); expect(res.status).toBe(500); const body = await res.json(); - expect(body.error).toBe("Server error"); + expect(body.error).toBe('Server error'); }); }); diff --git a/test/health.test.ts b/test/health.test.ts index 3207d18..b0b8215 100644 --- a/test/health.test.ts +++ b/test/health.test.ts @@ -1,42 +1,42 @@ // @ts-nocheck -import { describe, it, expect, mock, beforeEach } from "bun:test"; +import { beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock database layer const mockExecute = mock(() => Promise.resolve()); -mock.module("../src/db/index", () => ({ +mock.module('../src/db/index', () => ({ db: { - execute: mockExecute - } + execute: mockExecute, + }, })); -describe("Health Route Handler", () => { - let handleHealth; +describe('Health Route Handler', () => { + let handleHealth: any; beforeEach(async () => { mockExecute.mockClear(); - const healthRoute = await import("../src/routes/health"); + const healthRoute = await import('../src/routes/health'); handleHealth = healthRoute.handleHealth; }); - it("should return status 200 and ok when DB is healthy", async () => { - const req = new Request("http://localhost:3000/health"); + it('should return status 200 and ok when DB is healthy', async () => { + const req = new Request('http://localhost:3000/health'); const res = await handleHealth(req); expect(res.status).toBe(200); const body = await res.json(); - expect(body).toEqual({ status: "ok" }); + expect(body).toEqual({ status: 'ok' }); expect(mockExecute).toHaveBeenCalled(); }); - it("should return status 500 and error details when DB health check fails", async () => { - mockExecute.mockImplementationOnce(() => Promise.reject(new Error("DB Connection Failed"))); - const req = new Request("http://localhost:3000/health"); + it('should return status 500 and error details when DB health check fails', async () => { + mockExecute.mockImplementationOnce(() => Promise.reject(new Error('DB Connection Failed'))); + const req = new Request('http://localhost:3000/health'); const res = await handleHealth(req); expect(res.status).toBe(500); const body = await res.json(); - expect(body.status).toBe("error"); - expect(body.error).toBe("DB Connection Failed"); + expect(body.status).toBe('error'); + expect(body.error).toBe('DB Connection Failed'); }); }); diff --git a/test/rateLimit.test.ts b/test/rateLimit.test.ts index 0c57f89..6450452 100644 --- a/test/rateLimit.test.ts +++ b/test/rateLimit.test.ts @@ -1,17 +1,17 @@ // @ts-nocheck -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { checkRateLimit, cleanupRateLimitCache } from "../src/utils/rateLimit"; -import logger from "../src/utils/logger"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import logger from '../src/utils/logger'; +import { checkRateLimit, cleanupRateLimitCache } from '../src/utils/rateLimit'; // Spy on logger.warn -const warnSpy = spyOn(logger, "warn"); +const warnSpy = spyOn(logger, 'warn'); -describe("Rate Limiter", () => { +describe('Rate Limiter', () => { beforeEach(() => { warnSpy.mockClear(); // Set custom env variables for predictable tests - process.env.RATE_LIMIT_WINDOW_MS = "100"; // 100ms window - process.env.RATE_LIMIT_MAX_REQUESTS = "3"; // max 3 requests + process.env.RATE_LIMIT_WINDOW_MS = '100'; // 100ms window + process.env.RATE_LIMIT_MAX_REQUESTS = '3'; // max 3 requests }); afterEach(() => { @@ -19,16 +19,16 @@ describe("Rate Limiter", () => { delete process.env.RATE_LIMIT_MAX_REQUESTS; }); - it("should allow requests under the limit", () => { - const key = "user-1"; + it('should allow requests under the limit', () => { + const key = 'user-1'; expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); expect(warnSpy).not.toHaveBeenCalled(); }); - it("should block requests exceeding the limit and log a warning", () => { - const key = "user-2"; + it('should block requests exceeding the limit and log a warning', () => { + const key = 'user-2'; expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); @@ -37,12 +37,12 @@ describe("Rate Limiter", () => { expect(checkRateLimit(key)).toBe(false); expect(warnSpy).toHaveBeenCalled(); const callArgs = warnSpy.mock.calls[0]; - expect(callArgs[0]).toBe("Rate limit exceeded"); + expect(callArgs[0]).toBe('Rate limit exceeded'); expect(callArgs[1].key).toBe(key); }); - it("should reset request count after the window passes", async () => { - const key = "user-3"; + it('should reset request count after the window passes', async () => { + const key = 'user-3'; expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); expect(checkRateLimit(key)).toBe(true); @@ -55,9 +55,9 @@ describe("Rate Limiter", () => { expect(checkRateLimit(key)).toBe(true); }); - it("should cleanup rate limit cache of expired keys", async () => { - const key1 = "cleanup-1"; - const key2 = "cleanup-2"; + it('should cleanup rate limit cache of expired keys', async () => { + const key1 = 'cleanup-1'; + const key2 = 'cleanup-2'; // Populate keys expect(checkRateLimit(key1)).toBe(true); diff --git a/test/telegram.test.ts b/test/telegram.test.ts index 2a86d7f..f6d3e8f 100644 --- a/test/telegram.test.ts +++ b/test/telegram.test.ts @@ -1,33 +1,34 @@ // @ts-nocheck -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import logger from "../src/utils/logger"; -import { config } from "../src/env"; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import logger from '../src/utils/logger'; // Mock Telegraf and fetch -mock.module("telegraf", () => { +mock.module('telegraf', () => { return { Telegraf: class { constructor(token) { this.token = token; this.telegram = { - sendPhoto: mock(() => Promise.resolve({ - message_id: 12345, - photo: [ - { file_id: "photo_id_low", file_unique_id: "unique_id_low" }, - { file_id: "photo_id_high", file_unique_id: "unique_id_high" } - ] - })) + sendPhoto: mock(() => + Promise.resolve({ + message_id: 12345, + photo: [ + { file_id: 'photo_id_low', file_unique_id: 'unique_id_low' }, + { file_id: 'photo_id_high', file_unique_id: 'unique_id_high' }, + ], + }), + ), }; } - } + }, }; }); -const infoSpy = spyOn(logger, "info"); -const errorSpy = spyOn(logger, "error"); +const infoSpy = spyOn(logger, 'info'); +const errorSpy = spyOn(logger, 'error'); -describe("Telegram API Utilities", () => { - let forwardToStorage, getFileInfo, getBot; +describe('Telegram API Utilities', () => { + let forwardToStorage: any, getFileInfo: any, getBot: any; beforeEach(async () => { infoSpy.mockClear(); @@ -35,7 +36,7 @@ describe("Telegram API Utilities", () => { global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ ok: true })))); // Import dynamically so mocking is applied first - const telegramUtils = await import("../src/utils/telegram"); + const telegramUtils = await import('../src/utils/telegram'); forwardToStorage = telegramUtils.forwardToStorage; getFileInfo = telegramUtils.getFileInfo; getBot = telegramUtils.getBot; @@ -45,83 +46,101 @@ describe("Telegram API Utilities", () => { delete global.fetch; }); - describe("getBot", () => { - it("should return the telegraf bot instance", () => { + describe('getBot', () => { + it('should return the telegraf bot instance', () => { const bot = getBot(); expect(bot).toBeDefined(); expect(bot.telegram).toBeDefined(); }); }); - describe("forwardToStorage", () => { - it("should forward photo to storage chat and return file details", async () => { - const chunk = Buffer.from("fake photo data"); - const fileName = "test_photo.jpg"; + describe('forwardToStorage', () => { + it('should forward photo to storage chat and return file details', async () => { + const chunk = Buffer.from('fake photo data'); + const fileName = 'test_photo.jpg'; const result = await forwardToStorage(chunk, fileName, false); expect(result).toEqual({ - telegramFileId: "photo_id_high", - telegramFileUniqueId: "unique_id_high", - storageMessageId: 12345 + telegramFileId: 'photo_id_high', + telegramFileUniqueId: 'unique_id_high', + storageMessageId: 12345, }); - expect(infoSpy).toHaveBeenCalledWith("File forwarded to storage", { + expect(infoSpy).toHaveBeenCalledWith('File forwarded to storage', { fileName, - message: 12345 + message: 12345, }); }); - it("should handle error when forwarding fails", async () => { + it('should handle error when forwarding fails', async () => { const bot = getBot(); - bot.telegram.sendPhoto = mock(() => Promise.reject(new Error("Telegram send failed"))); + bot.telegram.sendPhoto = mock(() => Promise.reject(new Error('Telegram send failed'))); - const chunk = Buffer.from("fake photo data"); - const fileName = "test_photo.jpg"; + const chunk = Buffer.from('fake photo data'); + const fileName = 'test_photo.jpg'; - await expect(forwardToStorage(chunk, fileName, false)).rejects.toThrow("Telegram send failed"); - expect(errorSpy).toHaveBeenCalledWith("Failed to forward file to storage", { + await expect(forwardToStorage(chunk, fileName, false)).rejects.toThrow( + 'Telegram send failed', + ); + expect(errorSpy).toHaveBeenCalledWith('Failed to forward file to storage', { fileName, - error: "Telegram send failed" + error: 'Telegram send failed', }); }); }); - describe("getFileInfo", () => { - it("should fetch file details successfully", async () => { - global.fetch = mock((url, init) => { - if (url.endsWith("getFile")) { - return Promise.resolve(new Response(JSON.stringify({ - ok: true, - result: { file_id: "some_file_id" } - }))); - } else if (url.endsWith("getInfo")) { - return Promise.resolve(new Response(JSON.stringify({ - ok: true, - result: { - file_size: 98765, - mime_type: "image/jpeg", - file_path: "photos/file_0.jpg" - } - }))); + describe('getFileInfo', () => { + it('should fetch file details successfully', async () => { + global.fetch = mock((url, _init) => { + if (url.endsWith('getFile')) { + return Promise.resolve( + new Response( + JSON.stringify({ + ok: true, + result: { file_id: 'some_file_id' }, + }), + ), + ); + } else if (url.endsWith('getInfo')) { + return Promise.resolve( + new Response( + JSON.stringify({ + ok: true, + result: { + file_size: 98765, + mime_type: 'image/jpeg', + file_path: 'photos/file_0.jpg', + }, + }), + ), + ); } - return Promise.reject(new Error("Unknown URL")); + return Promise.reject(new Error('Unknown URL')); }); - const result = await getFileInfo("some_file_id", "some_unique_id"); + const result = await getFileInfo('some_file_id', 'some_unique_id'); expect(result).toEqual({ file_size: 98765, - mime_type: "image/jpeg", - file_path: "photos/file_0.jpg" + mime_type: 'image/jpeg', + file_path: 'photos/file_0.jpg', }); }); - it("should handle error when getFile fails", async () => { - global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({ - ok: false, - description: "Bad Request: file_id invalid" - })))); + it('should handle error when getFile fails', async () => { + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + ok: false, + description: 'Bad Request: file_id invalid', + }), + ), + ), + ); - await expect(getFileInfo("invalid_file_id", "invalid_unique_id")).rejects.toThrow("Bad Request: file_id invalid"); + await expect(getFileInfo('invalid_file_id', 'invalid_unique_id')).rejects.toThrow( + 'Bad Request: file_id invalid', + ); expect(errorSpy).toHaveBeenCalled(); }); }); diff --git a/test/upload.test.ts b/test/upload.test.ts index 0cb09bd..597a0c9 100644 --- a/test/upload.test.ts +++ b/test/upload.test.ts @@ -1,120 +1,124 @@ // @ts-nocheck -import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test'; // Mock db const mockInsert = mock(() => ({ - values: mock(() => Promise.resolve()) + values: mock(() => Promise.resolve()), })); -mock.module("../src/db/index", () => ({ +mock.module('../src/db/index', () => ({ db: { - insert: mockInsert + insert: mockInsert, }, - files: {} + files: {}, })); // Mock nanoid -mock.module("nanoid", () => ({ - nanoid: () => "mocked-nanoid-id" +mock.module('nanoid', () => ({ + nanoid: () => 'mocked-nanoid-id', })); // Mock telegram utils -mock.module("../src/utils/telegram", () => ({ - forwardToStorage: mock(() => Promise.resolve({ - telegramFileId: "tg-file-id-123", - telegramFileUniqueId: "tg-unique-id-abc", - storageMessageId: 98765 - })), +mock.module('../src/utils/telegram', () => ({ + forwardToStorage: mock(() => + Promise.resolve({ + telegramFileId: 'tg-file-id-123', + telegramFileUniqueId: 'tg-unique-id-abc', + storageMessageId: 98765, + }), + ), getBot: () => ({ telegram: { - getFile: mock(() => Promise.resolve({ - file_id: "tg-file-id-123", - file_size: 1000, - mime_type: "image/jpeg" - })) - } - }) + getFile: mock(() => + Promise.resolve({ + file_id: 'tg-file-id-123', + file_size: 1000, + mime_type: 'image/jpeg', + }), + ), + }, + }), })); -describe("Upload Route Handler", () => { - let handleUpload; +describe('Upload Route Handler', () => { + let handleUpload: any; beforeEach(async () => { mockInsert.mockClear(); - const uploadRoute = await import("../src/routes/upload"); + const uploadRoute = await import('../src/routes/upload'); handleUpload = uploadRoute.handleUpload; }); - it("should reject unsupported content types with 400 status", async () => { - const req = new Request("http://localhost:3000/api/upload", { - method: "POST", + it('should reject unsupported content types with 400 status', async () => { + const req = new Request('http://localhost:3000/api/upload', { + method: 'POST', headers: { - "content-type": "text/plain" + 'content-type': 'text/plain', }, - body: "plain text data" + body: 'plain text data', }); const res = await handleUpload(req); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toContain("Unsupported content type"); + expect(body.error).toContain('Unsupported content type'); }); - it("should process JSON upload (base64) successfully", async () => { - const req = new Request("http://localhost:3000/api/upload", { - method: "POST", + it('should process JSON upload (base64) successfully', async () => { + const req = new Request('http://localhost:3000/api/upload', { + method: 'POST', headers: { - "content-type": "application/json" + 'content-type': 'application/json', }, body: JSON.stringify({ - file: Buffer.from("hello world").toString("base64"), - fileName: "test.txt" - }) + file: Buffer.from('hello world').toString('base64'), + fileName: 'test.txt', + }), }); const res = await handleUpload(req); expect(res.status).toBe(200); const body = await res.json(); - expect(body.public_id).toBe("mocked-nanoid-id"); - expect(body.telegram_file_id).toBe("tg-file-id-123"); - expect(body.telegram_file_unique_id).toBe("tg-unique-id-abc"); - expect(body.file_name).toBe("test.txt"); - expect(body.file_type).toBe("document"); + expect(body.public_id).toBe('mocked-nanoid-id'); + expect(body.telegram_file_id).toBe('tg-file-id-123'); + expect(body.telegram_file_unique_id).toBe('tg-unique-id-abc'); + expect(body.file_name).toBe('test.txt'); + expect(body.file_type).toBe('document'); }); - it("should reject JSON upload without file key", async () => { - const req = new Request("http://localhost:3000/api/upload", { - method: "POST", + it('should reject JSON upload without file key', async () => { + const req = new Request('http://localhost:3000/api/upload', { + method: 'POST', headers: { - "content-type": "application/json" + 'content-type': 'application/json', }, body: JSON.stringify({ - fileName: "test.txt" - }) + fileName: 'test.txt', + }), }); const res = await handleUpload(req); expect(res.status).toBe(400); const body = await res.json(); - expect(body.error).toContain("Invalid JSON"); + expect(body.error).toContain('Invalid JSON'); }); - it("should process multipart upload successfully", async () => { + it('should process multipart upload successfully', async () => { const formData = new FormData(); - const fileBlob = new Blob([Buffer.from("multipart hello")], { type: "text/plain" }); - formData.append("file", fileBlob, "test_multi.txt"); + 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 req = new Request('http://localhost:3000/api/upload', { + method: 'POST', + body: formData, }); const res = await handleUpload(req); expect(res.status).toBe(200); const body = await res.json(); - expect(body.public_id).toBe("mocked-nanoid-id"); - expect(body.file_name).toBe("test_multi.txt"); + expect(body.public_id).toBe('mocked-nanoid-id'); + expect(body.file_name).toBe('test_multi.txt'); }); afterAll(() => {