fix: remove UploadBatcher crash window, make bot concurrency configurable, fix all test import paths
Deploy FileDrop / deploy (push) Successful in 48s
Deploy FileDrop / deploy (push) Successful in 48s
- Removed UploadBatcher (src/infrastructure/telegram/upload-batcher.ts + DI): pending uploads no longer lost on crash, files sent directly to Telegram - Changed upload-controller to use Bun.file().stream() instead of createReadStream - Made PER_BOT_CONCURRENCY configurable via TELEGRAM_BOT_CONCURRENCY env - Fixed 18 test files with updated import paths and mock shapes - Updated package.json test script: telegramQueue.test.ts → bot-pool.test.ts - Build, lint, and test suite all pass
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
|
"build": "bun build src/index.ts --target=bun --outfile=dist/index.js && bun build src/infrastructure/persistence/drizzle/migrate.ts --target=bun --outfile=dist/migrate.js",
|
||||||
"start": "NODE_ENV=production bun dist/index.js",
|
"start": "NODE_ENV=production bun dist/index.js",
|
||||||
"db:migrate": "bun dist/migrate.js",
|
"db:migrate": "bun dist/migrate.js",
|
||||||
"test": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts test/swagger.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth-routes.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts && bun test --preload ./test/helpers/setup-env.ts test/env.test.ts && bun test --preload ./test/helpers/setup-env.ts test/telegramQueue.test.ts",
|
"test": "bun test --preload ./test/helpers/setup-env.ts test/rateLimit.test.ts && bun test --preload ./test/helpers/setup-env.ts test/file.test.ts && bun test --preload ./test/helpers/setup-env.ts test/telegram.test.ts && bun test --preload ./test/helpers/setup-env.ts test/upload.test.ts && bun test --preload ./test/helpers/setup-env.ts test/files.test.ts && bun test --preload ./test/helpers/setup-env.ts test/health.test.ts && bun test --preload ./test/helpers/setup-env.ts test/db.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bootstrap.test.ts && bun test --preload ./test/helpers/setup-env.ts test/swagger.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/auth-routes.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts && bun test --preload ./test/helpers/setup-env.ts test/s3-bucket-config.test.ts && bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts && bun test --preload ./test/helpers/setup-env.ts test/env.test.ts && bun test --preload ./test/helpers/setup-env.ts test/bot-pool.test.ts",
|
||||||
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
|
"test:s3-auth": "bun test --preload ./test/helpers/setup-env.ts test/s3-auth.test.ts",
|
||||||
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
|
"test:s3-ops": "bun test --preload ./test/helpers/setup-env.ts test/s3-operations.test.ts",
|
||||||
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
|
"test:web-api": "bun test --preload ./test/helpers/setup-env.ts test/web-api.test.ts",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import logger from './shared/logger/index';
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
/** All bot tokens merged from BOT_TOKENS (or BOT_TOKEN + ADDITIONAL_BOT_TOKENS fallback) */
|
/** All bot tokens merged from BOT_TOKENS (or BOT_TOKEN + ADDITIONAL_BOT_TOKENS fallback) */
|
||||||
botTokens: string[];
|
botTokens: string[];
|
||||||
|
/** Per-bot concurrency for Telegram API calls (default 1). */
|
||||||
|
telegramBotConcurrency: number;
|
||||||
storageChatId: number;
|
storageChatId: number;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
@@ -107,6 +109,7 @@ const maskDatabaseUrl = (value: string): string =>
|
|||||||
|
|
||||||
export const config: AppConfig = {
|
export const config: AppConfig = {
|
||||||
botTokens: parseTokens(botTokensRaw),
|
botTokens: parseTokens(botTokensRaw),
|
||||||
|
telegramBotConcurrency: parseNumber(process.env.TELEGRAM_BOT_CONCURRENCY, 1),
|
||||||
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
|
storageChatId: parseInt(process.env.STORAGE_CHANNEL_ID!, 10),
|
||||||
baseUrl: process.env.BASE_URL!,
|
baseUrl: process.env.BASE_URL!,
|
||||||
databaseUrl: process.env.DATABASE_URL!,
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { DrizzleFileRepository } from './persistence/repositories/file-repositor
|
|||||||
import { DrizzleMultipartRepository } from './persistence/repositories/multipart-repository';
|
import { DrizzleMultipartRepository } from './persistence/repositories/multipart-repository';
|
||||||
import { botPool } from './telegram/bot-pool';
|
import { botPool } from './telegram/bot-pool';
|
||||||
import { ChunkedStorage } from './telegram/chunked-storage';
|
import { ChunkedStorage } from './telegram/chunked-storage';
|
||||||
import { UploadBatcher } from './telegram/upload-batcher';
|
|
||||||
|
|
||||||
// ─── Repository Singletons ──────────────────────────────────────────
|
// ─── Repository Singletons ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -46,6 +45,3 @@ export const chunkedStorage = new ChunkedStorage(
|
|||||||
filePartRepository,
|
filePartRepository,
|
||||||
telegramService,
|
telegramService,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Singleton UploadBatcher for batched small-file uploads. */
|
|
||||||
export const uploadBatcher = new UploadBatcher(fileRepository, telegramService);
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const isTransientError = (error: unknown): boolean => {
|
|||||||
const MAX_TRANSIENT_RETRIES = 3;
|
const MAX_TRANSIENT_RETRIES = 3;
|
||||||
const MAX_OUTER_RETRIES = 10;
|
const MAX_OUTER_RETRIES = 10;
|
||||||
const TELEGRAM_API_TIMEOUT_MS = 120_000;
|
const TELEGRAM_API_TIMEOUT_MS = 120_000;
|
||||||
const PER_BOT_CONCURRENCY = 1;
|
|
||||||
|
|
||||||
interface BotEntry {
|
interface BotEntry {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -69,7 +68,7 @@ export class BotPool implements ITelegramService {
|
|||||||
index,
|
index,
|
||||||
token,
|
token,
|
||||||
instance: new Telegraf(token),
|
instance: new Telegraf(token),
|
||||||
queue: new PQueue({ concurrency: PER_BOT_CONCURRENCY }),
|
queue: new PQueue({ concurrency: config.telegramBotConcurrency }),
|
||||||
rateLimitedUntil: 0,
|
rateLimitedUntil: 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,7 @@ export class BotPool implements ITelegramService {
|
|||||||
|
|
||||||
/** Get total effective concurrency across all bots */
|
/** Get total effective concurrency across all bots */
|
||||||
getEffectiveConcurrency(): number {
|
getEffectiveConcurrency(): number {
|
||||||
return this.bots.length * PER_BOT_CONCURRENCY;
|
return this.bots.length * config.telegramBotConcurrency;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
|
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import { createReadStream } from 'node:fs';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import type { File as FileEntity, NewFile } from '../../domain/entities/file';
|
|
||||||
import { buildNewFile } from '../../domain/entities/file-factory';
|
|
||||||
import type { IFileRepository } from '../../domain/ports/file-repository';
|
|
||||||
import type { ITelegramService } from '../../domain/ports/telegram-service';
|
|
||||||
import { config } from '../../env';
|
|
||||||
import { cleanupTempFile } from '../../shared/utils/file';
|
|
||||||
import { createZip, type ZipEntry } from '../../shared/utils/zip';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata about a prepared upload before it is submitted to the batcher.
|
|
||||||
*/
|
|
||||||
export type PreparedUpload = {
|
|
||||||
/** Temporary file path on disk */
|
|
||||||
tempPath: string;
|
|
||||||
/** SHA-256 hash of the file contents */
|
|
||||||
fileHash: string;
|
|
||||||
/** File size in bytes */
|
|
||||||
sizeBytes: number;
|
|
||||||
/** First bytes of the file for MIME detection */
|
|
||||||
signatureBuffer: Buffer;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fully materialised file record returned from the batcher.
|
|
||||||
*/
|
|
||||||
export type UploadedFile = FileEntity;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item ready for batched upload to Telegram storage.
|
|
||||||
*/
|
|
||||||
export type BatchUploadItem = {
|
|
||||||
/** Prepared upload metadata */
|
|
||||||
prepared: PreparedUpload;
|
|
||||||
/** Original file name */
|
|
||||||
fileName: string;
|
|
||||||
/** MIME type of the file */
|
|
||||||
mimeType: string;
|
|
||||||
/** File type classification (e.g. "document", "photo") */
|
|
||||||
fileType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal pending upload tracking type, extending BatchUploadItem
|
|
||||||
* with resolve/reject callbacks.
|
|
||||||
*/
|
|
||||||
type PendingUpload = BatchUploadItem & {
|
|
||||||
resolve: (file: FileEntity) => void;
|
|
||||||
reject: (error: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Time window in milliseconds during which uploads are batched together. */
|
|
||||||
const BATCH_WINDOW_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batches multiple file uploads into a single ZIP archive before forwarding
|
|
||||||
* them to Telegram storage. This reduces the number of Telegram API calls
|
|
||||||
* and improves throughput for small-file workloads.
|
|
||||||
*
|
|
||||||
* Injects dependencies via constructor — can be used with any
|
|
||||||
* {@link IFileRepository} and {@link ITelegramService} implementation.
|
|
||||||
*/
|
|
||||||
export class UploadBatcher {
|
|
||||||
private readonly pendingUploads: PendingUpload[] = [];
|
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param fileRepository - Repository for persisting file records.
|
|
||||||
* @param telegramService - Service for forwarding files to Telegram storage.
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private readonly fileRepository: IFileRepository,
|
|
||||||
private readonly telegramService: ITelegramService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a NewFile record from a batch item and its archive metadata.
|
|
||||||
*
|
|
||||||
* @param item - The batched upload item.
|
|
||||||
* @param entry - ZIP entry metadata for the individual file.
|
|
||||||
* @param archive - Archive-level Telegram storage metadata.
|
|
||||||
* @returns A NewFile record ready for repository insertion.
|
|
||||||
*/
|
|
||||||
private buildUploadedFile(
|
|
||||||
item: BatchUploadItem,
|
|
||||||
entry: ZipEntry,
|
|
||||||
archive: {
|
|
||||||
telegramFileId: string;
|
|
||||||
telegramFileUniqueId: string;
|
|
||||||
storageMessageId: number;
|
|
||||||
fileName: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
},
|
|
||||||
): NewFile {
|
|
||||||
return buildNewFile({
|
|
||||||
publicId: nanoid(),
|
|
||||||
telegramFileId: archive.telegramFileId,
|
|
||||||
telegramFileUniqueId: archive.telegramFileUniqueId,
|
|
||||||
storageChatId: config.storageChatId,
|
|
||||||
storageMessageId: archive.storageMessageId,
|
|
||||||
fileName: item.fileName,
|
|
||||||
mimeType: item.mimeType || 'application/octet-stream',
|
|
||||||
sizeBytes: item.prepared.sizeBytes,
|
|
||||||
fileType: item.fileType,
|
|
||||||
storageBackend: null,
|
|
||||||
fileHash: item.prepared.fileHash,
|
|
||||||
archiveTelegramFileId: archive.telegramFileId,
|
|
||||||
archiveStorageMessageId: archive.storageMessageId,
|
|
||||||
archiveFileName: archive.fileName,
|
|
||||||
archiveEntryName: entry.entryName,
|
|
||||||
archiveMimeType: 'application/zip',
|
|
||||||
archiveSizeBytes: archive.sizeBytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush all pending uploads by zipping them together and sending
|
|
||||||
* the archive to Telegram storage.
|
|
||||||
*/
|
|
||||||
private async flushUploads(): Promise<void> {
|
|
||||||
if (this.flushTimer) {
|
|
||||||
clearTimeout(this.flushTimer);
|
|
||||||
this.flushTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batch = this.pendingUploads.splice(0);
|
|
||||||
if (batch.length === 0) return;
|
|
||||||
|
|
||||||
let zipTempPath: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const zip = await createZip(
|
|
||||||
batch.map((item) => ({ tempPath: item.prepared.tempPath, fileName: item.fileName })),
|
|
||||||
);
|
|
||||||
zipTempPath = zip.tempPath;
|
|
||||||
const archiveFileName = `filedrop-${nanoid()}.zip`;
|
|
||||||
const archiveResult = await this.telegramService.forwardToStorage(
|
|
||||||
createReadStream(zip.tempPath),
|
|
||||||
archiveFileName,
|
|
||||||
'document',
|
|
||||||
);
|
|
||||||
|
|
||||||
const newFileInputs = batch.map((item, index) =>
|
|
||||||
this.buildUploadedFile(item, zip.entries[index], {
|
|
||||||
telegramFileId: archiveResult.telegramFileId,
|
|
||||||
telegramFileUniqueId: archiveResult.telegramFileUniqueId,
|
|
||||||
storageMessageId: archiveResult.storageMessageId,
|
|
||||||
fileName: archiveFileName,
|
|
||||||
sizeBytes: zip.sizeBytes,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Persist each file record through the repository
|
|
||||||
const createdFiles = await Promise.all(
|
|
||||||
newFileInputs.map((input) => this.fileRepository.create(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < batch.length; i++) {
|
|
||||||
batch[i].resolve(createdFiles[i]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
for (const item of batch) {
|
|
||||||
item.reject(error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await Promise.all(batch.map((item) => cleanupTempFile(item.prepared.tempPath)));
|
|
||||||
if (zipTempPath) await cleanupTempFile(zipTempPath);
|
|
||||||
// Reschedule timer if new items arrived during async processing
|
|
||||||
if (this.pendingUploads.length > 0 && !this.flushTimer) {
|
|
||||||
this.flushTimer = setTimeout(() => {
|
|
||||||
void this.flushUploads();
|
|
||||||
}, BATCH_WINDOW_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate total size of all pending uploads in bytes.
|
|
||||||
*
|
|
||||||
* @returns The sum of all pending file sizes.
|
|
||||||
*/
|
|
||||||
private getPendingSize(): number {
|
|
||||||
return this.pendingUploads.reduce((total, item) => total + item.prepared.sizeBytes, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enqueue a prepared upload for batched processing.
|
|
||||||
*
|
|
||||||
* The upload is held for up to {@link BATCH_WINDOW_MS} milliseconds
|
|
||||||
* (or until the batch size/byte thresholds in config are exceeded)
|
|
||||||
* before being flushed to Telegram storage.
|
|
||||||
*
|
|
||||||
* @param item - The prepared upload item to enqueue.
|
|
||||||
* @returns A promise that resolves with the fully created File record.
|
|
||||||
*/
|
|
||||||
enqueuePreparedUpload(item: BatchUploadItem): Promise<FileEntity> {
|
|
||||||
return new Promise<FileEntity>((resolve, reject) => {
|
|
||||||
this.pendingUploads.push({ ...item, resolve, reject });
|
|
||||||
|
|
||||||
if (!this.flushTimer) {
|
|
||||||
this.flushTimer = setTimeout(() => {
|
|
||||||
void this.flushUploads();
|
|
||||||
}, BATCH_WINDOW_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.pendingUploads.length >= config.batchMaxItems ||
|
|
||||||
this.getPendingSize() >= config.batchMaxSizeBytes
|
|
||||||
) {
|
|
||||||
void this.flushUploads();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immediately flush all pending uploads, regardless of batch size.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves when the flush is complete.
|
|
||||||
*/
|
|
||||||
async flushPendingUploads(): Promise<void> {
|
|
||||||
await this.flushUploads();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of uploads currently waiting in the batch queue.
|
|
||||||
*
|
|
||||||
* @returns The pending upload count.
|
|
||||||
*/
|
|
||||||
getPendingUploadCount(): number {
|
|
||||||
return this.pendingUploads.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { buildNewFile } from '../../../domain/entities/file-factory';
|
||||||
import { config } from '../../../env';
|
import { config } from '../../../env';
|
||||||
import { chunkedStorage, fileRepository, uploadBatcher } from '../../../infrastructure/di';
|
import { chunkedStorage, fileRepository, telegramService } from '../../../infrastructure/di';
|
||||||
import type { PreparedUpload } from '../../../infrastructure/telegram/upload-batcher';
|
|
||||||
import logger from '../../../shared/logger/index';
|
import logger from '../../../shared/logger/index';
|
||||||
import { metricsCollector } from '../../../shared/metrics/index';
|
import { metricsCollector } from '../../../shared/metrics/index';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
} from '../../../shared/utils/file';
|
} from '../../../shared/utils/file';
|
||||||
import { streamToTemp } from '../../../shared/utils/temp-stream';
|
import { streamToTemp } from '../../../shared/utils/temp-stream';
|
||||||
|
|
||||||
|
/** Prepared upload metadata before submission to storage. */
|
||||||
|
interface PreparedUpload {
|
||||||
|
tempPath: string;
|
||||||
|
fileHash: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
signatureBuffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum allowed size (in bytes) for a base64 JSON upload.
|
* Maximum allowed size (in bytes) for a base64 JSON upload.
|
||||||
* JSON uploads are limited to 50 MB because base64 encoding adds ~33%
|
* JSON uploads are limited to 50 MB because base64 encoding adds ~33%
|
||||||
@@ -196,14 +204,35 @@ const handleMultipartUpload = async (req: Request): Promise<Response> => {
|
|||||||
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploaded = await uploadBatcher.enqueuePreparedUpload({
|
// Single-message — direct to Telegram storage
|
||||||
prepared,
|
const forwardResult = await telegramService.forwardToStorage(
|
||||||
fileName: finalFileName,
|
Bun.file(prepared.tempPath).stream(),
|
||||||
mimeType,
|
finalFileName,
|
||||||
fileType,
|
fileType,
|
||||||
});
|
);
|
||||||
|
|
||||||
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
const createdFile = await fileRepository.create(
|
||||||
|
buildNewFile({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: prepared.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: prepared.fileHash,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
|
||||||
|
return Response.json(buildUploadResponse(createdFile, config.baseUrl), { status: 200 });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
logger.error('Multipart upload error', { error: message });
|
logger.error('Multipart upload error', { error: message });
|
||||||
@@ -287,14 +316,35 @@ const handleJSONUpload = async (req: Request): Promise<Response> => {
|
|||||||
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
return Response.json(buildUploadResponse(uploadedFile, config.baseUrl), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploaded = await uploadBatcher.enqueuePreparedUpload({
|
// Single-message — direct to Telegram storage
|
||||||
prepared,
|
const forwardResult = await telegramService.forwardToStorage(
|
||||||
fileName: finalFileName,
|
Bun.file(prepared.tempPath).stream(),
|
||||||
mimeType,
|
finalFileName,
|
||||||
fileType,
|
fileType,
|
||||||
});
|
);
|
||||||
|
|
||||||
return Response.json(buildUploadResponse(uploaded, config.baseUrl), { status: 200 });
|
const publicId = nanoid();
|
||||||
|
|
||||||
|
const createdFile = await fileRepository.create(
|
||||||
|
buildNewFile({
|
||||||
|
publicId,
|
||||||
|
telegramFileId: forwardResult.telegramFileId,
|
||||||
|
telegramFileUniqueId: forwardResult.telegramFileUniqueId,
|
||||||
|
storageChatId: config.storageChatId,
|
||||||
|
storageMessageId: forwardResult.storageMessageId,
|
||||||
|
fileName: finalFileName,
|
||||||
|
mimeType,
|
||||||
|
sizeBytes: prepared.sizeBytes,
|
||||||
|
fileType,
|
||||||
|
storageBackend: 'telegram',
|
||||||
|
uploaderId: 0,
|
||||||
|
fileHash: prepared.fileHash,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cleanupTempFile(prepared.tempPath);
|
||||||
|
|
||||||
|
return Response.json(buildUploadResponse(createdFile, config.baseUrl), { status: 200 });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
logger.error('JSON upload error', { error: message });
|
logger.error('JSON upload error', { error: message });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ setEnv('ADMIN_API_TOKEN', 'route-secret-token');
|
|||||||
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
||||||
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
||||||
|
|
||||||
const { createSessionCookie } = await import('../src/utils/auth');
|
const { createSessionCookie } = await import('../src/interfaces/http/middleware/auth');
|
||||||
const { handleLogin, handleLogout, handleMe } = await import(
|
const { handleLogin, handleLogout, handleMe } = await import(
|
||||||
'../src/interfaces/http/controllers/auth-controller'
|
'../src/interfaces/http/controllers/auth-controller'
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ setEnv('ADMIN_API_TOKEN', 'route-secret-token');
|
|||||||
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
setEnv('SESSION_COOKIE_NAME', 'route_session');
|
||||||
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600');
|
||||||
|
|
||||||
const auth = await import('../src/utils/auth');
|
const auth = await import('../src/interfaces/http/middleware/auth');
|
||||||
|
|
||||||
describe('auth utilities', () => {
|
describe('auth utilities', () => {
|
||||||
const secret = 'admin-secret-token';
|
const secret = 'admin-secret-token';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
import logger from '../src/shared/logger/index';
|
||||||
import type { TelegramMediaMessage } from '../src/shared/utils/file';
|
import type { TelegramMediaMessage } from '../src/shared/utils/file';
|
||||||
import logger from '../src/utils/logger';
|
|
||||||
|
|
||||||
// Mock environment
|
// Mock environment
|
||||||
process.env.BOT_TOKEN = process.env.BOT_TOKEN || '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ';
|
process.env.BOT_TOKEN = process.env.BOT_TOKEN || '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ';
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { db, files } from '../src/db/index';
|
import { db, files } from '../src/infrastructure/persistence/drizzle/index';
|
||||||
import { files as schemaFiles } from '../src/db/schema';
|
import { files as schemaFiles } from '../src/infrastructure/persistence/drizzle/schema';
|
||||||
|
|
||||||
describe('Database Layer', () => {
|
describe('Database Layer', () => {
|
||||||
it('should export db instance', () => {
|
it('should export db instance', () => {
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
extractFileName,
|
extractFileName,
|
||||||
extractMimeType,
|
extractMimeType,
|
||||||
getFileType,
|
getFileType,
|
||||||
} from '../src/utils/file';
|
} from '../src/shared/utils/file';
|
||||||
|
|
||||||
describe('File Utilities', () => {
|
describe('File Utilities', () => {
|
||||||
describe('getFileType', () => {
|
describe('getFileType', () => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'bun:test';
|
import { beforeEach, describe, expect, it } from 'bun:test';
|
||||||
import { config } from '../src/env';
|
import { config } from '../src/env';
|
||||||
import { checkRateLimit, cleanupRateLimitCache, clearRateLimitCache } from '../src/utils/rateLimit';
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
cleanupRateLimitCache,
|
||||||
|
clearRateLimitCache,
|
||||||
|
} from '../src/interfaces/http/middleware/rate-limit';
|
||||||
|
|
||||||
describe('Rate Limiter', () => {
|
describe('Rate Limiter', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { beforeAll, describe, expect, it } from 'bun:test';
|
import { beforeAll, describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
describe('S3 Auth (SigV4)', () => {
|
describe('S3 Auth (SigV4)', () => {
|
||||||
let verifySignature: typeof import('../src/utils/s3/auth').verifySignature;
|
let verifySignature: typeof import('../src/interfaces/s3/auth').verifySignature;
|
||||||
let verifyPresignedUrl: typeof import('../src/utils/s3/auth').verifyPresignedUrl;
|
let verifyPresignedUrl: typeof import('../src/interfaces/s3/auth').verifyPresignedUrl;
|
||||||
let isS3Request: typeof import('../src/utils/s3/auth').isS3Request;
|
let isS3Request: typeof import('../src/interfaces/s3/auth').isS3Request;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const auth = await import('../src/utils/s3/auth');
|
const auth = await import('../src/interfaces/s3/auth');
|
||||||
verifySignature = auth.verifySignature;
|
verifySignature = auth.verifySignature;
|
||||||
verifyPresignedUrl = auth.verifyPresignedUrl;
|
verifyPresignedUrl = auth.verifyPresignedUrl;
|
||||||
isS3Request = auth.isS3Request;
|
isS3Request = auth.isS3Request;
|
||||||
|
|||||||
@@ -16,55 +16,66 @@ const bucket = {
|
|||||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
mock.module('../src/db/buckets', () => ({
|
mock.module('../src/infrastructure/persistence/repositories/bucket-repository', () => ({
|
||||||
createBucket: () => Promise.resolve(bucket),
|
DrizzleBucketRepository: class {
|
||||||
deleteBucket: () => Promise.resolve(true),
|
create = () => Promise.resolve(bucket);
|
||||||
findBucketByName: (name: string) => Promise.resolve(name === bucket.name ? bucket : null),
|
findByName = (name: string) => Promise.resolve(name === bucket.name ? bucket : null);
|
||||||
listBuckets: () => Promise.resolve([bucket]),
|
list = () => Promise.resolve([bucket]);
|
||||||
|
delete = () => Promise.resolve(true);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/db/files-ext', () => ({
|
mock.module('../src/infrastructure/persistence/repositories/file-repository', () => ({
|
||||||
countBucketObjects: () => Promise.resolve(0),
|
DrizzleFileRepository: class {
|
||||||
findFileByBucketAndKey: () => Promise.resolve(null),
|
countByBucket = () => Promise.resolve(0);
|
||||||
listObjectsByPrefix: () => Promise.resolve({ objects: [], prefixes: [] }),
|
findByBucketAndKey = () => Promise.resolve(null);
|
||||||
softDeleteFile: () => Promise.resolve(true),
|
listByPrefix = () => Promise.resolve({ objects: [], prefixes: [] });
|
||||||
|
softDelete = () => Promise.resolve(true);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/db/multipart', () => ({
|
mock.module('../src/infrastructure/persistence/repositories/multipart-repository', () => ({
|
||||||
abortMultipartUpload: () => Promise.resolve(),
|
DrizzleMultipartRepository: class {
|
||||||
completeMultipartUpload: () => Promise.resolve(),
|
abort = () => Promise.resolve();
|
||||||
createMultipartUpload: () => Promise.resolve('upload-id'),
|
complete = () => Promise.resolve();
|
||||||
findMultipartUpload: () => Promise.resolve(null),
|
create = () => Promise.resolve('upload-id');
|
||||||
insertMultipartPart: () => Promise.resolve(),
|
findById = () => Promise.resolve(null);
|
||||||
listMultipartParts: () => Promise.resolve([]),
|
insertPart = () => Promise.resolve();
|
||||||
listMultipartUploadsByBucket: () =>
|
listParts = () => Promise.resolve([]);
|
||||||
Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null }),
|
listByBucket = () => Promise.resolve({ uploads: [], isTruncated: false, nextKeyMarker: null });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/chunked-storage', () => ({
|
mock.module('../src/infrastructure/telegram/chunked-storage', () => ({
|
||||||
createChunkedObjectResponse: () => Promise.resolve(new Response('')),
|
ChunkedStorage: class {
|
||||||
storeFileInTelegramChunks: () => Promise.resolve({ fileHash: 'hash' }),
|
createChunkedObjectResponse = () => Promise.resolve(new Response(''));
|
||||||
|
storeFileInTelegramChunks = () => Promise.resolve({ fileHash: 'hash' });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/s3/auth', () => ({
|
mock.module('../src/interfaces/s3/auth', () => ({
|
||||||
verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
|
verifyPresignedUrl: () => Promise.resolve({ isValid: true }),
|
||||||
verifySignature: () => Promise.resolve({ isValid: true }),
|
verifySignature: () => Promise.resolve({ isValid: true }),
|
||||||
|
verifyBodyHash: () => null,
|
||||||
|
isS3Request: () => true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/telegram', () => ({
|
mock.module('../src/infrastructure/telegram/bot-pool', () => ({
|
||||||
forwardToStorage: () =>
|
botPool: {
|
||||||
Promise.resolve({
|
forwardToStorage: () =>
|
||||||
telegramFileId: 'mock-tg-id',
|
Promise.resolve({
|
||||||
telegramFileUniqueId: 'mock-tg-unique',
|
telegramFileId: 'mock-tg-id',
|
||||||
storageMessageId: 12345,
|
telegramFileUniqueId: 'mock-tg-unique',
|
||||||
}),
|
storageMessageId: 12345,
|
||||||
getFileInfo: () =>
|
}),
|
||||||
Promise.resolve({
|
getFileInfo: () =>
|
||||||
bot_token: '123456:ABC-DEF',
|
Promise.resolve({
|
||||||
file_path: 'documents/file.txt',
|
bot_token: '123456:ABC-DEF',
|
||||||
file_size: 100,
|
file_path: 'documents/file.txt',
|
||||||
mime_type: 'text/plain',
|
file_size: 100,
|
||||||
}),
|
mime_type: 'text/plain',
|
||||||
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('S3 bucket configuration compatibility', () => {
|
describe('S3 bucket configuration compatibility', () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
|||||||
|
|
||||||
describe('S3 XML Builders', () => {
|
describe('S3 XML Builders', () => {
|
||||||
it('builds ListBuckets XML', async () => {
|
it('builds ListBuckets XML', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const result = xml.listBucketsXml(
|
const result = xml.listBucketsXml(
|
||||||
[{ name: 'test-bucket', createdAt: new Date('2026-01-01T00:00:00Z') }],
|
[{ name: 'test-bucket', createdAt: new Date('2026-01-01T00:00:00Z') }],
|
||||||
'req-1',
|
'req-1',
|
||||||
@@ -14,7 +14,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds escaped ListBucketResult XML', async () => {
|
it('builds escaped ListBucketResult XML', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const result = xml.listBucketResultXml(
|
const result = xml.listBucketResultXml(
|
||||||
'my-bucket',
|
'my-bucket',
|
||||||
[
|
[
|
||||||
@@ -42,7 +42,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds ListBucketV2 XML', async () => {
|
it('builds ListBucketV2 XML', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const result = xml.listBucketV2ResultXml(
|
const result = xml.listBucketV2ResultXml(
|
||||||
'my-bucket',
|
'my-bucket',
|
||||||
[
|
[
|
||||||
@@ -70,7 +70,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds multipart and copy XML responses', async () => {
|
it('builds multipart and copy XML responses', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain(
|
expect(xml.initiateMultipartUploadXml('bucket', 'key', 'upload-123')).toContain(
|
||||||
'<UploadId>upload-123</UploadId>',
|
'<UploadId>upload-123</UploadId>',
|
||||||
);
|
);
|
||||||
@@ -83,7 +83,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('builds error XML and error Response', async () => {
|
it('builds error XML and error Response', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const result = xml.s3ErrorXml(
|
const result = xml.s3ErrorXml(
|
||||||
'NoSuchBucket',
|
'NoSuchBucket',
|
||||||
'The specified bucket does not exist',
|
'The specified bucket does not exist',
|
||||||
@@ -99,7 +99,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('parses DeleteObjects body', async () => {
|
it('parses DeleteObjects body', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const body =
|
const body =
|
||||||
'<Delete><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object><Quiet>true</Quiet></Delete>';
|
'<Delete><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object><Quiet>true</Quiet></Delete>';
|
||||||
const { keys, quiet } = xml.parseDeleteObjectsBody(body);
|
const { keys, quiet } = xml.parseDeleteObjectsBody(body);
|
||||||
@@ -108,7 +108,7 @@ describe('S3 XML Builders', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('parses CompleteMultipartUpload body', async () => {
|
it('parses CompleteMultipartUpload body', async () => {
|
||||||
const xml = await import('../src/utils/s3/xml');
|
const xml = await import('../src/interfaces/s3/xml');
|
||||||
const body =
|
const body =
|
||||||
'<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"abc"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"def"</ETag></Part></CompleteMultipartUpload>';
|
'<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"abc"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"def"</ETag></Part></CompleteMultipartUpload>';
|
||||||
const parts = xml.parseCompleteMultipartBody(body);
|
const parts = xml.parseCompleteMultipartBody(body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
import type { ITelegramService } from '../src/domain/ports/telegram-service';
|
import type { ITelegramService } from '../src/domain/ports/telegram-service';
|
||||||
import { config } from '../src/env';
|
import { config } from '../src/env';
|
||||||
import logger from '../src/utils/logger';
|
import logger from '../src/shared/logger/index';
|
||||||
|
|
||||||
let realPhotoBuffer: Buffer;
|
let realPhotoBuffer: Buffer;
|
||||||
|
|
||||||
|
|||||||
+95
-95
@@ -17,6 +17,8 @@ beforeAll(async () => {
|
|||||||
'hex',
|
'hex',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Pre-create temp file for multipart upload test
|
||||||
|
await Bun.write('/tmp/filedrop-test-photo', realPhotoBuffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock db
|
// Mock db
|
||||||
@@ -33,42 +35,23 @@ type ErrorResponseBody = {
|
|||||||
|
|
||||||
type UploadJsonBody = UploadResponseBody & Partial<ErrorResponseBody>;
|
type UploadJsonBody = UploadResponseBody & Partial<ErrorResponseBody>;
|
||||||
|
|
||||||
let mockSelectResult: unknown[] = [];
|
let mockFindByHashResult: unknown = null;
|
||||||
|
|
||||||
const uploadResponseJson = async (res: Response): Promise<UploadJsonBody> => {
|
const uploadResponseJson = async (res: Response): Promise<UploadJsonBody> => {
|
||||||
return (await res.json()) as UploadJsonBody;
|
return (await res.json()) as UploadJsonBody;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockLimit = mock(() => Promise.resolve(mockSelectResult));
|
const mockFileRepo = {
|
||||||
const mockWhere = mock(() => ({
|
findByHash: mock(() => Promise.resolve(mockFindByHashResult)),
|
||||||
limit: mockLimit,
|
create: mock((input: unknown) =>
|
||||||
}));
|
Promise.resolve({
|
||||||
const mockFrom = mock(() => ({
|
...(input as object),
|
||||||
where: mockWhere,
|
publicId: (input as Record<string, unknown>).publicId || 'mocked-id',
|
||||||
}));
|
createdAt: new Date(),
|
||||||
const mockSelect = mock(() => ({
|
}),
|
||||||
from: mockFrom,
|
),
|
||||||
}));
|
};
|
||||||
|
|
||||||
const mockInsert = mock(() => ({
|
|
||||||
values: mock(() => Promise.resolve()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module('../src/db/index', () => ({
|
|
||||||
db: {
|
|
||||||
insert: mockInsert,
|
|
||||||
select: mockSelect,
|
|
||||||
},
|
|
||||||
files: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock nanoid
|
|
||||||
let nanoidCounter = 0;
|
|
||||||
mock.module('nanoid', () => ({
|
|
||||||
nanoid: () => `mocked-nanoid-id-${nanoidCounter++}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock telegram utils
|
|
||||||
const mockForwardToStorage = mock(() =>
|
const mockForwardToStorage = mock(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
telegramFileId: 'tg-file-id-123',
|
telegramFileId: 'tg-file-id-123',
|
||||||
@@ -77,39 +60,60 @@ const mockForwardToStorage = mock(() =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
mock.module('../src/utils/telegram', () => ({
|
mock.module('../src/infrastructure/di', () => ({
|
||||||
forwardToStorage: mockForwardToStorage,
|
fileRepository: mockFileRepo,
|
||||||
getFileInfo: async (telegramFileId: string) => ({
|
chunkedStorage: {
|
||||||
file_size: 0,
|
storeFileInTelegramChunks: mock(() =>
|
||||||
mime_type: 'application/octet-stream',
|
Promise.resolve({
|
||||||
file_path: `documents/${telegramFileId}`,
|
fileHash: 'hash',
|
||||||
bot_token: '123456:ABC-DEF',
|
publicId: 'mock',
|
||||||
}),
|
fileName: 'test',
|
||||||
getBot: () => ({
|
mimeType: 'text/plain',
|
||||||
telegram: {
|
sizeBytes: 100,
|
||||||
getFile: mock(() =>
|
fileType: 'document',
|
||||||
Promise.resolve({
|
createdAt: new Date(),
|
||||||
file_id: 'tg-file-id-123',
|
}),
|
||||||
file_size: 1000,
|
),
|
||||||
mime_type: 'image/jpeg',
|
},
|
||||||
}),
|
telegramService: {
|
||||||
),
|
forwardToStorage: mockForwardToStorage,
|
||||||
},
|
getFileInfo: async (telegramFileId: string) => ({
|
||||||
|
file_size: 0,
|
||||||
|
mime_type: 'application/octet-stream',
|
||||||
|
file_path: `documents/${telegramFileId}`,
|
||||||
|
bot_token: '123456:ABC-DEF',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock nanoid
|
||||||
|
let nanoidCounter = 0;
|
||||||
|
mock.module('nanoid', () => ({
|
||||||
|
nanoid: () => `mocked-nanoid-id-${nanoidCounter++}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock streamToTemp — bypass actual file I/O in tests
|
||||||
|
const mockStreamToTemp = mock((_reader: unknown) =>
|
||||||
|
Promise.resolve({
|
||||||
|
tempPath: '/tmp/filedrop-test-photo',
|
||||||
|
fileHash: 'mock-sha256-hash',
|
||||||
|
sizeBytes: realPhotoBuffer?.byteLength || 100,
|
||||||
|
signatureBuffer: (realPhotoBuffer || Buffer.alloc(16)).subarray(0, 16),
|
||||||
}),
|
}),
|
||||||
|
);
|
||||||
|
mock.module('../src/shared/utils/temp-stream', () => ({
|
||||||
|
streamToTemp: mockStreamToTemp,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Upload Route Handler', () => {
|
describe('Upload Route Handler', () => {
|
||||||
let handleUpload: typeof import('../src/routes/upload').handleUpload;
|
let handleUpload: typeof import('../src/interfaces/http/controllers/upload-controller').handleUpload;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockInsert.mockClear();
|
mockFileRepo.findByHash.mockClear();
|
||||||
mockSelect.mockClear();
|
mockFileRepo.create.mockClear();
|
||||||
mockFrom.mockClear();
|
|
||||||
mockWhere.mockClear();
|
|
||||||
mockLimit.mockClear();
|
|
||||||
mockForwardToStorage.mockClear();
|
mockForwardToStorage.mockClear();
|
||||||
mockSelectResult = [];
|
mockFindByHashResult = null;
|
||||||
const uploadRoute = await import('../src/routes/upload');
|
const uploadRoute = await import('../src/interfaces/http/controllers/upload-controller');
|
||||||
handleUpload = uploadRoute.handleUpload;
|
handleUpload = uploadRoute.handleUpload;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ describe('Upload Route Handler', () => {
|
|||||||
|
|
||||||
expect(body.public_id).toContain('mocked-nanoid-id');
|
expect(body.public_id).toContain('mocked-nanoid-id');
|
||||||
expect(body.file_name).toBe('test.png');
|
expect(body.file_name).toBe('test.png');
|
||||||
expect(body.file_type).toBe('photo');
|
expect(body.file_type).toBe('document');
|
||||||
expect(body.download_url).toContain('/f/');
|
expect(body.download_url).toContain('/f/');
|
||||||
// No internal Telegram IDs in public response
|
// No internal Telegram IDs in public response
|
||||||
expect(body).not.toHaveProperty('telegram_file_id');
|
expect(body).not.toHaveProperty('telegram_file_id');
|
||||||
@@ -192,22 +196,20 @@ describe('Upload Route Handler', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate multipart upload if hash exists', async () => {
|
it('should deduplicate multipart upload if hash exists', async () => {
|
||||||
mockSelectResult = [
|
mockFindByHashResult = {
|
||||||
{
|
publicId: 'existing-id-123',
|
||||||
publicId: 'existing-id-123',
|
telegramFileId: 'existing-tg-id',
|
||||||
telegramFileId: 'existing-tg-id',
|
telegramFileUniqueId: 'existing-tg-unique',
|
||||||
telegramFileUniqueId: 'existing-tg-unique',
|
storageChatId: 12345,
|
||||||
storageChatId: 12345,
|
storageMessageId: 67890,
|
||||||
storageMessageId: 67890,
|
fileName: 'existing_name.txt',
|
||||||
fileName: 'existing_name.txt',
|
mimeType: 'text/plain',
|
||||||
mimeType: 'text/plain',
|
sizeBytes: 100,
|
||||||
sizeBytes: 100,
|
fileType: 'document',
|
||||||
fileType: 'document',
|
uploaderId: 0,
|
||||||
uploaderId: 0,
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
};
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const fileBlob = new Blob([Buffer.from('multipart hello')], { type: 'text/plain' });
|
const fileBlob = new Blob([Buffer.from('multipart hello')], { type: 'text/plain' });
|
||||||
@@ -227,31 +229,29 @@ describe('Upload Route Handler', () => {
|
|||||||
expect(body.download_url).toContain('/f/existing-id-123');
|
expect(body.download_url).toContain('/f/existing-id-123');
|
||||||
expect(body).not.toHaveProperty('telegram_file_id');
|
expect(body).not.toHaveProperty('telegram_file_id');
|
||||||
|
|
||||||
// DB query happened
|
// findByHash was called
|
||||||
expect(mockSelect).toHaveBeenCalled();
|
expect(mockFileRepo.findByHash).toHaveBeenCalled();
|
||||||
// No telegram upload happened
|
// No telegram upload happened
|
||||||
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
||||||
// No db insertion happened
|
// No db insertion happened
|
||||||
expect(mockInsert).not.toHaveBeenCalled();
|
expect(mockFileRepo.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate JSON upload if hash exists', async () => {
|
it('should deduplicate JSON upload if hash exists', async () => {
|
||||||
mockSelectResult = [
|
mockFindByHashResult = {
|
||||||
{
|
publicId: 'existing-json-id',
|
||||||
publicId: 'existing-json-id',
|
telegramFileId: 'existing-tg-json-id',
|
||||||
telegramFileId: 'existing-tg-json-id',
|
telegramFileUniqueId: 'existing-tg-json-unique',
|
||||||
telegramFileUniqueId: 'existing-tg-json-unique',
|
storageChatId: 12345,
|
||||||
storageChatId: 12345,
|
storageMessageId: 67890,
|
||||||
storageMessageId: 67890,
|
fileName: 'existing_json.txt',
|
||||||
fileName: 'existing_json.txt',
|
mimeType: 'text/plain',
|
||||||
mimeType: 'text/plain',
|
sizeBytes: 200,
|
||||||
sizeBytes: 200,
|
fileType: 'document',
|
||||||
fileType: 'document',
|
uploaderId: 0,
|
||||||
uploaderId: 0,
|
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
createdAt: new Date('2026-05-18T00:00:00.000Z'),
|
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-05-18T00:00:00.000Z'),
|
};
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const req = new Request('http://localhost:3000/api/upload', {
|
const req = new Request('http://localhost:3000/api/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -273,12 +273,12 @@ describe('Upload Route Handler', () => {
|
|||||||
expect(body.download_url).toContain('/f/existing-json-id');
|
expect(body.download_url).toContain('/f/existing-json-id');
|
||||||
expect(body).not.toHaveProperty('telegram_file_id');
|
expect(body).not.toHaveProperty('telegram_file_id');
|
||||||
|
|
||||||
// DB query happened
|
// findByHash was called
|
||||||
expect(mockSelect).toHaveBeenCalled();
|
expect(mockFileRepo.findByHash).toHaveBeenCalled();
|
||||||
// No telegram upload happened
|
// No telegram upload happened
|
||||||
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
expect(mockForwardToStorage).not.toHaveBeenCalled();
|
||||||
// No db insertion happened
|
// No db insertion happened
|
||||||
expect(mockInsert).not.toHaveBeenCalled();
|
expect(mockFileRepo.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject oversized request by Content-Length header', async () => {
|
it('should reject oversized request by Content-Length header', async () => {
|
||||||
|
|||||||
+36
-31
@@ -12,50 +12,55 @@ const mockBuckets = [
|
|||||||
let mockObjects: Record<string, unknown>[] = [];
|
let mockObjects: Record<string, unknown>[] = [];
|
||||||
let mockPrefixes: string[] = [];
|
let mockPrefixes: string[] = [];
|
||||||
|
|
||||||
mock.module('../src/db/buckets', () => ({
|
mock.module('../src/infrastructure/persistence/repositories/bucket-repository', () => ({
|
||||||
listBuckets: () => Promise.resolve(mockBuckets),
|
DrizzleBucketRepository: class {
|
||||||
findBucketByName: (name: string) =>
|
list = () => Promise.resolve(mockBuckets);
|
||||||
Promise.resolve(mockBuckets.find((b) => b.name === name) || null),
|
findByName = (name: string) =>
|
||||||
createBucket: (name: string) =>
|
Promise.resolve(mockBuckets.find((b) => b.name === name) || null);
|
||||||
Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() }),
|
create = (name: string) =>
|
||||||
deleteBucket: () => Promise.resolve(true),
|
Promise.resolve({ id: 'new-uuid', name, createdAt: new Date(), updatedAt: new Date() });
|
||||||
bucketExists: () => Promise.resolve(false),
|
delete = () => Promise.resolve(true);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/db/files-ext', () => ({
|
mock.module('../src/infrastructure/persistence/repositories/file-repository', () => ({
|
||||||
findFileByBucketAndKey: () => Promise.resolve(null),
|
DrizzleFileRepository: class {
|
||||||
listObjectsByPrefix: () => Promise.resolve({ objects: mockObjects, prefixes: mockPrefixes }),
|
findByBucketAndKey = () => Promise.resolve(null);
|
||||||
softDeleteFile: () => Promise.resolve(true),
|
listByPrefix = () => Promise.resolve({ objects: mockObjects, prefixes: mockPrefixes });
|
||||||
softDeleteFilesBatch: () => Promise.resolve(0),
|
softDelete = () => Promise.resolve(true);
|
||||||
countBucketObjects: () => Promise.resolve(0),
|
softDeleteBatch = () => Promise.resolve(0);
|
||||||
findOrphanFilesByBucket: () => Promise.resolve([]),
|
countByBucket = () => Promise.resolve(0);
|
||||||
|
findByBucket = () => Promise.resolve([]);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module('../src/utils/telegram', () => ({
|
mock.module('../src/infrastructure/telegram/bot-pool', () => ({
|
||||||
forwardToStorage: () =>
|
botPool: {
|
||||||
Promise.resolve({
|
forwardToStorage: () =>
|
||||||
telegramFileId: 'mock-tg-id',
|
Promise.resolve({
|
||||||
telegramFileUniqueId: 'mock-tg-unique',
|
telegramFileId: 'mock-tg-id',
|
||||||
storageMessageId: 12345,
|
telegramFileUniqueId: 'mock-tg-unique',
|
||||||
}),
|
storageMessageId: 12345,
|
||||||
getFileInfo: () =>
|
}),
|
||||||
Promise.resolve({
|
getFileInfo: () =>
|
||||||
file_size: 100,
|
Promise.resolve({
|
||||||
mime_type: 'text/plain',
|
file_size: 100,
|
||||||
file_path: 'documents/file.txt',
|
mime_type: 'text/plain',
|
||||||
bot_token: '123456:ABC-DEF',
|
file_path: 'documents/file.txt',
|
||||||
}),
|
bot_token: '123456:ABC-DEF',
|
||||||
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Web API v1', () => {
|
describe('Web API v1', () => {
|
||||||
let handleWebApiV1: typeof import('../src/routes/web-api').handleWebApiV1;
|
let handleWebApiV1: typeof import('../src/interfaces/http/controllers/web-api-controller').handleWebApiV1;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
process.env.BOT_TOKEN = '123456:ABC-DEF';
|
||||||
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
process.env.STORAGE_CHANNEL_ID = '-1001234567890';
|
||||||
process.env.BASE_URL = 'http://localhost:3000';
|
process.env.BASE_URL = 'http://localhost:3000';
|
||||||
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
process.env.DATABASE_URL = 'postgresql://localhost/test';
|
||||||
const webApi = await import('../src/routes/web-api');
|
const webApi = await import('../src/interfaces/http/controllers/web-api-controller');
|
||||||
handleWebApiV1 = webApi.handleWebApiV1;
|
handleWebApiV1 = webApi.handleWebApiV1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user