test: expand unit test coverage to all S3 edge cases; fix stale tests
Add comprehensive unit tests and repair stale tests that referenced the old (pre-refactor) src/utils/* layout which no longer exists: - s3-range: expand to 25 cases (suffix, clamping, malformed, zero-size, invalid totals, content-range formatting) - s3-object-stream: rewrite against the real interfaces/s3 module; add multi-part ordering, ranges spanning parts, S3/CORS headers, fetch-error propagation - s3-helpers-edge (new): compress heuristics, virtual-host bucket parsing, S3 route detection, client-IP/trustProxy, S3 response headers - s3-auth-edge (new): verifyBodyHash, isS3Request, canonical-query-string encoding/sorting - chunked-storage: rewrite against the real ChunkedStorage class (was importing deleted src/utils/chunked-storage) — chunk split, hashing, compression, size-limit guards, forwarding - zip: fix stale import + add path-traversal/duplicate sanitization, locateZipEntry, empty-name fallback - s3-docker-registry: fix stale src/config import; correct the rate-limit test to assert S3 routes INTENTIONALLY bypass rate limiting - temp-stream (new): streamToTemp hashing, MD5, signature bytes, empty and oversized streams - package.json: add the S3/unit files to test and test:s3 scripts All new unit tests pass when run per-file (the project's documented mode to avoid cross-file mock pollution). s3-sdk.test.ts (live E2E against a running server) is deliberately excluded from test:s3.
This commit is contained in:
+100
-78
@@ -1,38 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ITelegramService } from '../src/domain/ports/telegram-service';
|
||||
import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
|
||||
|
||||
// Mock DB layer (chunked-storage imports db, insertFileParts, listFileParts)
|
||||
const mockInsert = mock(() => Promise.resolve());
|
||||
const mockPartsInsert = mock(() => Promise.resolve());
|
||||
const mockPartsSelect = mock(() => Promise.resolve([]));
|
||||
/**
|
||||
* Tests the real ChunkedStorage class (src/infrastructure/telegram/
|
||||
* chunked-storage.ts). uploadFileInTelegramChunks only depends on the injected
|
||||
* telegramService, so we stub that and pass no-op repos for the rest.
|
||||
*
|
||||
* Rewritten from a stale test that imported the old `src/utils/chunked-storage`
|
||||
* layout, which no longer exists after the refactor.
|
||||
*/
|
||||
const makeTelegramStub = (): ITelegramService =>
|
||||
({
|
||||
forwardToStorage: mock(async (_bytes: unknown, fileName: string) => ({
|
||||
telegramFileId: `tg-${fileName}`,
|
||||
telegramFileUniqueId: `tg-unique-${fileName}`,
|
||||
storageMessageId: Math.floor(Math.random() * 100000) + 1,
|
||||
})),
|
||||
getFileInfo: mock(async (telegramFileId: string) => ({
|
||||
file_size: 0,
|
||||
mime_type: 'application/octet-stream',
|
||||
file_path: `documents/${telegramFileId}`,
|
||||
bot_token: '123456:ABC-DEF',
|
||||
})),
|
||||
}) as unknown as ITelegramService;
|
||||
|
||||
mock.module('../src/db/index', () => ({
|
||||
db: {
|
||||
insert: mockInsert,
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: () => Promise.resolve([]) }) }) }),
|
||||
execute: mock(() => Promise.resolve([])),
|
||||
},
|
||||
files: {},
|
||||
fileParts: {},
|
||||
}));
|
||||
|
||||
mock.module('../src/db/file-parts', () => ({
|
||||
insertFileParts: mockPartsInsert,
|
||||
listFileParts: mockPartsSelect,
|
||||
}));
|
||||
|
||||
mock.module('../src/utils/telegram', () => ({
|
||||
forwardToStorage: async (_bytes: unknown, fileName: string) => ({
|
||||
telegramFileId: `tg-${fileName}`,
|
||||
telegramFileUniqueId: `tg-unique-${fileName}`,
|
||||
storageMessageId: Math.floor(Math.random() * 100000) + 1,
|
||||
}),
|
||||
getFileInfo: async (telegramFileId: string) => ({
|
||||
file_size: 0,
|
||||
mime_type: 'application/octet-stream',
|
||||
file_path: `documents/${telegramFileId}`,
|
||||
bot_token: '123456:ABC-DEF',
|
||||
}),
|
||||
}));
|
||||
const noopRepo = {} as never;
|
||||
|
||||
const writeTemp = async (path: string, data: Buffer): Promise<void> => {
|
||||
await Bun.write(path, data);
|
||||
@@ -46,85 +40,88 @@ const rmTemp = async (path: string): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
describe('chunked-storage utility', () => {
|
||||
describe('ChunkedStorage.uploadFileInTelegramChunks', () => {
|
||||
let storage: ChunkedStorage;
|
||||
let tg: ITelegramService;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInsert.mockClear();
|
||||
mockPartsInsert.mockClear();
|
||||
mockPartsSelect.mockClear();
|
||||
tg = makeTelegramStub();
|
||||
storage = new ChunkedStorage(noopRepo, noopRepo, tg);
|
||||
});
|
||||
|
||||
it('should split a file into correct number of chunks', async () => {
|
||||
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
|
||||
|
||||
const data = Buffer.from('1234567890ab');
|
||||
it('splits a file into the correct number of chunks', async () => {
|
||||
const data = Buffer.from('1234567890ab'); // 12 bytes
|
||||
const path = '/tmp/test-chunk-1';
|
||||
await writeTemp(path, data);
|
||||
|
||||
const result = await uploadFileInTelegramChunks({
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'test-1',
|
||||
chunkSizeBytes: 4,
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
});
|
||||
|
||||
await rmTemp(path);
|
||||
|
||||
// 12 bytes at 4 bytes/chunk = 3 chunks
|
||||
expect(result.parts.length).toBe(3);
|
||||
expect(result.totalSizeBytes).toBe(12);
|
||||
expect(result.parts[0].partNumber).toBe(1);
|
||||
expect(result.parts[1].partNumber).toBe(2);
|
||||
expect(result.parts[2].partNumber).toBe(3);
|
||||
expect(result.parts.map((p) => p.partNumber)).toEqual([1, 2, 3]);
|
||||
expect(result.parts[0].sizeBytes).toBe(4);
|
||||
expect(result.parts[0].storedSizeBytes).toBe(4);
|
||||
expect(result.parts[0].compressionAlgorithm).toBeNull();
|
||||
});
|
||||
|
||||
it('should compute correct full-file hash', async () => {
|
||||
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
|
||||
const { createHash } = await import('node:crypto');
|
||||
it('handles a file smaller than one chunk as a single part', async () => {
|
||||
const data = Buffer.from('abc');
|
||||
const path = '/tmp/test-chunk-small';
|
||||
await writeTemp(path, data);
|
||||
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'small',
|
||||
chunkSizeBytes: 1024,
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
});
|
||||
await rmTemp(path);
|
||||
|
||||
expect(result.parts.length).toBe(1);
|
||||
expect(result.totalSizeBytes).toBe(3);
|
||||
});
|
||||
|
||||
it('computes the correct full-file SHA-256 hash', async () => {
|
||||
const data = Buffer.from('Hello, chunked storage!');
|
||||
const path = '/tmp/test-chunk-hash';
|
||||
await writeTemp(path, data);
|
||||
|
||||
// Expected SHA-256
|
||||
const expectedHash = createHash('sha256').update(data).digest('hex');
|
||||
|
||||
const result = await uploadFileInTelegramChunks({
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'test-hash',
|
||||
partFileNamePrefix: 'hash',
|
||||
chunkSizeBytes: 10,
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
});
|
||||
|
||||
await rmTemp(path);
|
||||
|
||||
expect(result.fileHash).toBe(expectedHash);
|
||||
expect(result.fileHash).toBe(createHash('sha256').update(data).digest('hex'));
|
||||
expect(result.totalSizeBytes).toBe(data.byteLength);
|
||||
});
|
||||
|
||||
it('should gzip compressible chunks and skip incompressible ones', async () => {
|
||||
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
|
||||
|
||||
// Use data large enough to exceed compressionMinSizeBytes
|
||||
it('gzip-compresses compressible chunks and records the algorithm', async () => {
|
||||
const data = Buffer.from('AAAAAAAAAA'.repeat(100)); // 1000 bytes, very compressible
|
||||
const path = '/tmp/test-chunk-compress';
|
||||
await writeTemp(path, data);
|
||||
|
||||
const result = await uploadFileInTelegramChunks({
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'test-comp',
|
||||
partFileNamePrefix: 'comp',
|
||||
chunkSizeBytes: 512,
|
||||
compress: true,
|
||||
compressionMinSizeBytes: 10,
|
||||
});
|
||||
|
||||
await rmTemp(path);
|
||||
|
||||
expect(result.parts.length).toBe(2);
|
||||
// At least one chunk was compressed (gzip)
|
||||
for (const part of result.parts) {
|
||||
expect(part.storedSizeBytes).toBeLessThanOrEqual(part.sizeBytes);
|
||||
if (part.storedSizeBytes < part.sizeBytes) {
|
||||
@@ -134,41 +131,66 @@ describe('chunked-storage utility', () => {
|
||||
expect(result.totalSizeBytes).toBe(1000);
|
||||
});
|
||||
|
||||
it('should not attempt compression for incompressible data', async () => {
|
||||
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
|
||||
it('does not compress already-incompressible (gzipped) data', async () => {
|
||||
const { gzipSync } = await import('node:zlib');
|
||||
|
||||
const original = Buffer.from('AAAA'.repeat(100));
|
||||
const compressed = gzipSync(original);
|
||||
const compressed = gzipSync(original); // already gzipped → won't compress further
|
||||
const path = '/tmp/test-chunk-incompress';
|
||||
await writeTemp(path, compressed);
|
||||
|
||||
const result = await uploadFileInTelegramChunks({
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'test-inc',
|
||||
partFileNamePrefix: 'inc',
|
||||
chunkSizeBytes: 1024,
|
||||
compress: true,
|
||||
compressionMinSizeBytes: 10,
|
||||
});
|
||||
|
||||
await rmTemp(path);
|
||||
|
||||
// Incompressible data should stay uncompressed
|
||||
expect(result.parts[0].compressionAlgorithm).toBeNull();
|
||||
expect(result.parts[0].sizeBytes).toBe(result.parts[0].storedSizeBytes);
|
||||
});
|
||||
|
||||
it('should reject chunk size of zero', async () => {
|
||||
const { uploadFileInTelegramChunks } = await import('../src/utils/chunked-storage');
|
||||
|
||||
expect(
|
||||
uploadFileInTelegramChunks({
|
||||
it('rejects a zero chunk size', async () => {
|
||||
await expect(
|
||||
storage.uploadFileInTelegramChunks({
|
||||
tempPath: '/nonexistent',
|
||||
partFileNamePrefix: 'err',
|
||||
chunkSizeBytes: 0,
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow('Invalid Telegram chunk size');
|
||||
).rejects.toThrow(/chunk size/i);
|
||||
});
|
||||
|
||||
it('rejects a chunk size that exceeds the Telegram 20MB safety limit', async () => {
|
||||
await expect(
|
||||
storage.uploadFileInTelegramChunks({
|
||||
tempPath: '/nonexistent',
|
||||
partFileNamePrefix: 'err',
|
||||
chunkSizeBytes: 100 * 1024 * 1024, // 100 MB
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('forwards each chunk to the telegram service via document type', async () => {
|
||||
const data = Buffer.from('abcdef'); // 6 bytes, 3 chunks of 2
|
||||
const path = '/tmp/test-chunk-fwd';
|
||||
await writeTemp(path, data);
|
||||
|
||||
const result = await storage.uploadFileInTelegramChunks({
|
||||
tempPath: path,
|
||||
partFileNamePrefix: 'fwd',
|
||||
chunkSizeBytes: 2,
|
||||
compress: false,
|
||||
compressionMinSizeBytes: 4096,
|
||||
});
|
||||
await rmTemp(path);
|
||||
|
||||
const fwd = tg.forwardToStorage as unknown as ReturnType<typeof mock>;
|
||||
expect(fwd).toHaveBeenCalledTimes(3);
|
||||
expect(result.parts.every((p) => p.telegramFileId.startsWith('tg-'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user