From f5d56f52d4427b8ef304106bde9764c3952f2ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 07:30:31 +0700 Subject: [PATCH] =?UTF-8?q?chore:=20fix=20lint=20errors=20=E2=80=94=20noBa?= =?UTF-8?q?nnedTypes,=20import=20ordering,=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace unsafe 'Function' type in test with ITelegramService interface - Biome auto-fix formatting and import sorting across 8 files Co-Authored-By: Claude Opus 5 (1M context) --- src/infrastructure/telegram/bot-pool.ts | 13 +- .../http/controllers/file-controller.ts | 4 +- .../http/controllers/s3-controller.ts | 2 +- .../http/controllers/web-api-controller.ts | 2 +- src/utils/chunked-storage.ts | 2 +- src/utils/uploadBatcher.ts | 2 +- test/auth-routes.test.ts | 4 +- test/production-e2e.test.ts | 458 +++++++++--------- test/telegram.test.ts | 3 +- 9 files changed, 248 insertions(+), 242 deletions(-) diff --git a/src/infrastructure/telegram/bot-pool.ts b/src/infrastructure/telegram/bot-pool.ts index 544705d..aac67aa 100644 --- a/src/infrastructure/telegram/bot-pool.ts +++ b/src/infrastructure/telegram/bot-pool.ts @@ -205,11 +205,14 @@ export class BotPool implements ITelegramService { if (attempt <= MAX_TRANSIENT_RETRIES && isTransientError(error)) { const backoffMs = Math.min(1000 * 2 ** attempt, 10_000); - logger.warn(`Transient error forwarding file, retrying (${attempt}/${MAX_TRANSIENT_RETRIES})`, { - fileName, - error: errorStr, - backoffMs, - }); + logger.warn( + `Transient error forwarding file, retrying (${attempt}/${MAX_TRANSIENT_RETRIES})`, + { + fileName, + error: errorStr, + backoffMs, + }, + ); await sleep(backoffMs); continue; } diff --git a/src/interfaces/http/controllers/file-controller.ts b/src/interfaces/http/controllers/file-controller.ts index 3ccb9dc..f445808 100644 --- a/src/interfaces/http/controllers/file-controller.ts +++ b/src/interfaces/http/controllers/file-controller.ts @@ -1,11 +1,11 @@ import { createReadStream } from 'node:fs'; import { nanoid } from 'nanoid'; +import type { TelegramFileInfo } from '../../../domain/ports/telegram-service'; import { fileInfoCache } from '../../../infrastructure/cache/index'; +import { botPool } from '../../../infrastructure/telegram/bot-pool'; import logger from '../../../shared/logger/index'; import { cleanupTempFile, formatCreatedAt, getErrorMessage } from '../../../shared/utils/file'; import { createChunkedObjectResponse } from '../../../utils/chunked-storage'; -import { botPool } from '../../../infrastructure/telegram/bot-pool'; -import type { TelegramFileInfo } from '../../../domain/ports/telegram-service'; import { locateZipEntry } from '../../../utils/zip'; /** diff --git a/src/interfaces/http/controllers/s3-controller.ts b/src/interfaces/http/controllers/s3-controller.ts index 6c9d897..846004a 100644 --- a/src/interfaces/http/controllers/s3-controller.ts +++ b/src/interfaces/http/controllers/s3-controller.ts @@ -18,6 +18,7 @@ import { listMultipartUploadsByBucket, } from '../../../db/multipart'; import type { File } from '../../../db/schema'; +import { botPool } from '../../../infrastructure/telegram/bot-pool'; import logger from '../../../shared/logger/index'; import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file'; import { @@ -43,7 +44,6 @@ import { parseDeleteObjectsBody, s3ErrorResponse, } from '../../../utils/s3/xml'; -import { botPool } from '../../../infrastructure/telegram/bot-pool'; /** * The default S3 region returned when no region is explicitly configured. diff --git a/src/interfaces/http/controllers/web-api-controller.ts b/src/interfaces/http/controllers/web-api-controller.ts index 1cecc94..b701180 100644 --- a/src/interfaces/http/controllers/web-api-controller.ts +++ b/src/interfaces/http/controllers/web-api-controller.ts @@ -8,13 +8,13 @@ import { listObjectsByPrefix, softDeleteFile, } from '../../../db/files-ext'; +import { botPool } from '../../../infrastructure/telegram/bot-pool'; import logger from '../../../shared/logger/index'; import { cleanupTempFile, ensureExtension, getErrorMessage } from '../../../shared/utils/file'; import { createChunkedObjectResponse, storeFileInTelegramChunks, } from '../../../utils/chunked-storage'; -import { botPool } from '../../../infrastructure/telegram/bot-pool'; /** * Route parameters extracted from the URL path. diff --git a/src/utils/chunked-storage.ts b/src/utils/chunked-storage.ts index 7bdca47..b1dd62b 100644 --- a/src/utils/chunked-storage.ts +++ b/src/utils/chunked-storage.ts @@ -6,10 +6,10 @@ import { db, files as fileSchema } from '../db'; import { insertFileParts, listFileParts, type NewFilePartInput } from '../db/file-parts'; import type { File } from '../db/schema'; import { config } from '../env'; +import { botPool } from '../infrastructure/telegram/bot-pool'; import { computeHash } from './file'; import { createGetObjectResponse, type ObjectPartSource } from './s3/object-stream'; import type { RangeParseResult } from './s3/range'; -import { botPool } from '../infrastructure/telegram/bot-pool'; export type ChunkCompressionAlgorithm = 'gzip' | null; diff --git a/src/utils/uploadBatcher.ts b/src/utils/uploadBatcher.ts index b2520fc..1643e35 100644 --- a/src/utils/uploadBatcher.ts +++ b/src/utils/uploadBatcher.ts @@ -3,8 +3,8 @@ import { nanoid } from 'nanoid'; import { db, files as fileSchema } from '../db'; import type { NewFile } from '../db/schema'; import { config } from '../env'; -import { cleanupTempFile } from './file'; import { botPool } from '../infrastructure/telegram/bot-pool'; +import { cleanupTempFile } from './file'; import { createZip, type ZipEntry } from './zip'; export type PreparedUpload = { diff --git a/test/auth-routes.test.ts b/test/auth-routes.test.ts index 1ff2764..cb61b68 100644 --- a/test/auth-routes.test.ts +++ b/test/auth-routes.test.ts @@ -18,7 +18,9 @@ setEnv('SESSION_COOKIE_NAME', 'route_session'); setEnv('SESSION_COOKIE_MAX_AGE_SECONDS', '3600'); const { createSessionCookie } = await import('../src/utils/auth'); -const { handleLogin, handleLogout, handleMe } = await import('../src/interfaces/http/controllers/auth-controller'); +const { handleLogin, handleLogout, handleMe } = await import( + '../src/interfaces/http/controllers/auth-controller' +); const jsonBody = async (res: Response): Promise => (await res.json()) as T; diff --git a/test/production-e2e.test.ts b/test/production-e2e.test.ts index 5991208..0a742a8 100644 --- a/test/production-e2e.test.ts +++ b/test/production-e2e.test.ts @@ -230,254 +230,254 @@ describe('S3 API (production, SigV4)', () => { console.info('ℹ️ S3_SKIP: S3_SECRET_KEY not set — skipping S3 tests'); }); } else { + const bucketName = `e2e-s3-${TS}`; - const bucketName = `e2e-s3-${TS}`; - - it('ListBuckets (GET /)', async () => { - const r = await s3Request('GET', '/'); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('ListAllMyBucketsResult'); - }); - - it('CreateBucket (PUT /{bucket})', async () => { - const r = await s3Request('PUT', `/${bucketName}`); - expect(r.status).toBe(200); - createdBuckets.push(bucketName); - }); - - it('HeadBucket (HEAD /{bucket})', async () => { - const r = await s3Request('HEAD', `/${bucketName}`); - expect(r.status).toBe(200); - }); - - it('PutObject (PUT /{bucket}/{key})', async () => { - const r = await s3Request('PUT', `/${bucketName}/test-file.txt`, { - body: new TextEncoder().encode('hello s3'), - }); - expect(r.status).toBe(200); - expect(r.headers.get('etag')).toBeTruthy(); - }); - - it('PutObject — nested folder key', async () => { - const r = await s3Request('PUT', `/${bucketName}/folder/nested.txt`, { - body: new TextEncoder().encode('nested'), - }); - expect(r.status).toBe(200); - }); - - it('HeadObject (HEAD /{bucket}/{key})', async () => { - const r = await s3Request('HEAD', `/${bucketName}/test-file.txt`); - expect(r.status).toBe(200); - expect(r.headers.get('etag')).toBeTruthy(); - expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0); - }); - - it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => { - const r = await s3Request('GET', `/${bucketName}/test-file.txt`); - expect(r.status).toBe(200); - const text = await r.text(); - expect(text).toContain('hello s3'); - expect(r.headers.get('content-type')).toMatch(/text|octet/); - }); - - it('ListObjectsV1 (GET /{bucket})', async () => { - const r = await s3Request('GET', `/${bucketName}`); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('ListBucketResult'); - expect(xml).toContain('test-file.txt'); - expect(xml).toContain('folder/nested.txt'); - }); - - it('ListObjectsV1 — prefix filter', async () => { - const r = await s3Request('GET', `/${bucketName}`, { query: { prefix: 'folder/' } }); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('folder/nested.txt'); - expect(xml).not.toContain('test-file.txt'); - }); - - it('ListObjectsV2 (GET /{bucket}?list-type=2)', async () => { - const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2' } }); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('ListBucketResultV2'); - expect(xml).toContain('KeyCount'); - }); - - it('ListObjectsV2 — continuation', async () => { - const r = await s3Request('GET', `/${bucketName}`, { - query: { 'list-type': '2', 'max-keys': '1' }, - }); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('IsTruncated'); - }); - - it('DeleteObject (DELETE /{bucket}/{key})', async () => { - const r = await s3Request('DELETE', `/${bucketName}/folder/nested.txt`); - expect(r.status).toBe(204); - }); - - it('DeleteObjects (POST /{bucket}?delete) — batch', async () => { - const content = new TextEncoder().encode('del'); - await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content }); - await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content }); - const deleteBody = - 'batch-1.txtbatch-2.txt'; - const r = await s3Request('POST', `/${bucketName}`, { - query: { delete: '' }, - body: new TextEncoder().encode(deleteBody), - }); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('DeleteResult'); - }); - - it('CopyObject (PUT /{dest} with x-amz-copy-source)', async () => { - const r = await s3Request('PUT', `/${bucketName}/copy-dest.txt`, { - headers: { 'x-amz-copy-source': `/${bucketName}/test-file.txt` }, - }); - expect(r.status).toBe(200); - const xml = await r.text(); - expect(xml).toContain('CopyObjectResult'); - }); - - it('Presigned URL — GET with X-Amz-Signature', async () => { - // Use s3Request to compute a presigned URL signature — verify the object - await s3Request('PUT', `/${bucketName}/presigned-test.txt`, { - body: new TextEncoder().encode('presigned content'), + it('ListBuckets (GET /)', async () => { + const r = await s3Request('GET', '/'); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('ListAllMyBucketsResult'); }); - const host = new URL(BASE_URL).host; - const now = new Date(); - const pad2 = (n: number) => String(n).padStart(2, '0'); - const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`; - const dateStamp = amzDate.slice(0, 8); - - // Build canonical query string (must match server's buildCanonicalQueryString) - const sp = new URLSearchParams({ - 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', - 'X-Amz-Credential': `${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request`, - 'X-Amz-Date': amzDate, - 'X-Amz-Expires': '3600', - 'X-Amz-SignedHeaders': 'host', + it('CreateBucket (PUT /{bucket})', async () => { + const r = await s3Request('PUT', `/${bucketName}`); + expect(r.status).toBe(200); + createdBuckets.push(bucketName); }); - // Sort keys to match server's alphabetical sort - const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b)); - const canonicalQs = sorted - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join('&'); - const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`; - const hcr = sha256hex(canonical); - const cs = `${dateStamp}/us-east-1/s3/aws4_request`; - const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`; - const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1'); - const sig = hex(hmacSha256(sk, sts)); - - sp.set('X-Amz-Signature', sig); - const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`; - - const r = await fetch(presignedUrl); - expect(r.status).toBe(200); - const text = await r.text(); - expect(text).toContain('presigned content'); - }); - - it('GetObject Range — returns partial single-part content', async () => { - const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { - headers: { range: 'bytes=0-4' }, + it('HeadBucket (HEAD /{bucket})', async () => { + const r = await s3Request('HEAD', `/${bucketName}`); + expect(r.status).toBe(200); }); - expect(r.status).toBe(206); - expect(r.headers.get('content-range')).toBe('bytes 0-4/8'); - expect(await r.text()).toBe('hello'); - }); - it('GetObject Range — invalid range returns 416 XML', async () => { - const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { - headers: { range: 'bytes=999-1000' }, + it('PutObject (PUT /{bucket}/{key})', async () => { + const r = await s3Request('PUT', `/${bucketName}/test-file.txt`, { + body: new TextEncoder().encode('hello s3'), + }); + expect(r.status).toBe(200); + expect(r.headers.get('etag')).toBeTruthy(); }); - expect(r.status).toBe(416); - expect(r.headers.get('content-range')).toBe('bytes */8'); - const xml = await r.text(); - expect(xml).toContain('InvalidRange'); - }); - it('Multipart GetObject — returns complete concatenated body', async () => { - const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { - query: { uploads: '' }, + it('PutObject — nested folder key', async () => { + const r = await s3Request('PUT', `/${bucketName}/folder/nested.txt`, { + body: new TextEncoder().encode('nested'), + }); + expect(r.status).toBe(200); }); - expect(create.status).toBe(200); - const createXml = await create.text(); - const uploadId = createXml.match(/([^<]+)<\/UploadId>/)?.[1]; - expect(uploadId).toBeTruthy(); - const part1 = new TextEncoder().encode('hello '); - const part2 = new TextEncoder().encode('multipart'); - const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { - query: { partNumber: '1', uploadId: uploadId! }, - body: part1, + it('HeadObject (HEAD /{bucket}/{key})', async () => { + const r = await s3Request('HEAD', `/${bucketName}/test-file.txt`); + expect(r.status).toBe(200); + expect(r.headers.get('etag')).toBeTruthy(); + expect(Number(r.headers.get('content-length'))).toBeGreaterThan(0); }); - const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { - query: { partNumber: '2', uploadId: uploadId! }, - body: part2, + + it('GetObject (GET /{bucket}/{key}) — proxies content from Telegram', async () => { + const r = await s3Request('GET', `/${bucketName}/test-file.txt`); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('hello s3'); + expect(r.headers.get('content-type')).toMatch(/text|octet/); }); - expect(p1.status).toBe(200); - expect(p2.status).toBe(200); - const completeBody = `1${p1.headers.get('etag')}2${p2.headers.get('etag')}`; - const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { - query: { uploadId: uploadId! }, - body: new TextEncoder().encode(completeBody), + it('ListObjectsV1 (GET /{bucket})', async () => { + const r = await s3Request('GET', `/${bucketName}`); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('ListBucketResult'); + expect(xml).toContain('test-file.txt'); + expect(xml).toContain('folder/nested.txt'); }); - expect(complete.status).toBe(200); - const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`); - expect(full.status).toBe(200); - expect(await full.text()).toBe('hello multipart'); - - const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, { - headers: { range: 'bytes=3-9' }, + it('ListObjectsV1 — prefix filter', async () => { + const r = await s3Request('GET', `/${bucketName}`, { query: { prefix: 'folder/' } }); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('folder/nested.txt'); + expect(xml).not.toContain('test-file.txt'); }); - expect(partial.status).toBe(206); - expect(partial.headers.get('content-range')).toBe('bytes 3-9/15'); - expect(await partial.text()).toBe('lo mult'); - }); - it('Delete bucket — must be empty first', async () => { - // Clean up remaining objects - await s3Request('DELETE', `/${bucketName}/test-file.txt`); - await s3Request('DELETE', `/${bucketName}/copy-dest.txt`); - await s3Request('DELETE', `/${bucketName}/presigned-test.txt`); - await s3Request('DELETE', `/${bucketName}/multipart-full.txt`); - - const r = await s3Request('DELETE', `/${bucketName}`); - expect(r.status).toBe(204); - createdBuckets = createdBuckets.filter((b) => b !== bucketName); - }); - - it('S3 error — NoSuchBucket returns 404 XML', async () => { - const r = await s3Request('GET', '/bucket-nonexistent-xyz'); - expect(r.status).toBe(404); - const xml = await r.text(); - expect(xml).toContain('NoSuchBucket'); - }); - - it('S3 error — bad signature returns 403', async () => { - const r = await fetch(`${BASE_URL}/`, { - headers: { - Authorization: - 'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00', - 'x-amz-date': '20260701T000000Z', - 'x-amz-content-sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', - }, + it('ListObjectsV2 (GET /{bucket}?list-type=2)', async () => { + const r = await s3Request('GET', `/${bucketName}`, { query: { 'list-type': '2' } }); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('ListBucketResultV2'); + expect(xml).toContain('KeyCount'); + }); + + it('ListObjectsV2 — continuation', async () => { + const r = await s3Request('GET', `/${bucketName}`, { + query: { 'list-type': '2', 'max-keys': '1' }, + }); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('IsTruncated'); + }); + + it('DeleteObject (DELETE /{bucket}/{key})', async () => { + const r = await s3Request('DELETE', `/${bucketName}/folder/nested.txt`); + expect(r.status).toBe(204); + }); + + it('DeleteObjects (POST /{bucket}?delete) — batch', async () => { + const content = new TextEncoder().encode('del'); + await s3Request('PUT', `/${bucketName}/batch-1.txt`, { body: content }); + await s3Request('PUT', `/${bucketName}/batch-2.txt`, { body: content }); + const deleteBody = + 'batch-1.txtbatch-2.txt'; + const r = await s3Request('POST', `/${bucketName}`, { + query: { delete: '' }, + body: new TextEncoder().encode(deleteBody), + }); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('DeleteResult'); + }); + + it('CopyObject (PUT /{dest} with x-amz-copy-source)', async () => { + const r = await s3Request('PUT', `/${bucketName}/copy-dest.txt`, { + headers: { 'x-amz-copy-source': `/${bucketName}/test-file.txt` }, + }); + expect(r.status).toBe(200); + const xml = await r.text(); + expect(xml).toContain('CopyObjectResult'); + }); + + it('Presigned URL — GET with X-Amz-Signature', async () => { + // Use s3Request to compute a presigned URL signature — verify the object + await s3Request('PUT', `/${bucketName}/presigned-test.txt`, { + body: new TextEncoder().encode('presigned content'), + }); + + const host = new URL(BASE_URL).host; + const now = new Date(); + const pad2 = (n: number) => String(n).padStart(2, '0'); + const amzDate = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}T${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}Z`; + const dateStamp = amzDate.slice(0, 8); + + // Build canonical query string (must match server's buildCanonicalQueryString) + const sp = new URLSearchParams({ + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${S3_KEY}/${dateStamp}/us-east-1/s3/aws4_request`, + 'X-Amz-Date': amzDate, + 'X-Amz-Expires': '3600', + 'X-Amz-SignedHeaders': 'host', + }); + // Sort keys to match server's alphabetical sort + const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b)); + const canonicalQs = sorted + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const canonical = `GET\n/${bucketName}/presigned-test.txt\n${canonicalQs}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`; + const hcr = sha256hex(canonical); + const cs = `${dateStamp}/us-east-1/s3/aws4_request`; + const sts = `AWS4-HMAC-SHA256\n${amzDate}\n${cs}\n${hcr}`; + const sk = getSigningKey(S3_SECRET, dateStamp, 'us-east-1'); + const sig = hex(hmacSha256(sk, sts)); + + sp.set('X-Amz-Signature', sig); + const presignedUrl = `${BASE_URL}/${bucketName}/presigned-test.txt?${sp.toString()}`; + + const r = await fetch(presignedUrl); + expect(r.status).toBe(200); + const text = await r.text(); + expect(text).toContain('presigned content'); + }); + + it('GetObject Range — returns partial single-part content', async () => { + const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { + headers: { range: 'bytes=0-4' }, + }); + expect(r.status).toBe(206); + expect(r.headers.get('content-range')).toBe('bytes 0-4/8'); + expect(await r.text()).toBe('hello'); + }); + + it('GetObject Range — invalid range returns 416 XML', async () => { + const r = await s3Request('GET', `/${bucketName}/test-file.txt`, { + headers: { range: 'bytes=999-1000' }, + }); + expect(r.status).toBe(416); + expect(r.headers.get('content-range')).toBe('bytes */8'); + const xml = await r.text(); + expect(xml).toContain('InvalidRange'); + }); + + it('Multipart GetObject — returns complete concatenated body', async () => { + const create = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { + query: { uploads: '' }, + }); + expect(create.status).toBe(200); + const createXml = await create.text(); + const uploadId = createXml.match(/([^<]+)<\/UploadId>/)?.[1]; + expect(uploadId).toBeTruthy(); + + const part1 = new TextEncoder().encode('hello '); + const part2 = new TextEncoder().encode('multipart'); + const p1 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { + query: { partNumber: '1', uploadId: uploadId! }, + body: part1, + }); + const p2 = await s3Request('PUT', `/${bucketName}/multipart-full.txt`, { + query: { partNumber: '2', uploadId: uploadId! }, + body: part2, + }); + expect(p1.status).toBe(200); + expect(p2.status).toBe(200); + + const completeBody = `1${p1.headers.get('etag')}2${p2.headers.get('etag')}`; + const complete = await s3Request('POST', `/${bucketName}/multipart-full.txt`, { + query: { uploadId: uploadId! }, + body: new TextEncoder().encode(completeBody), + }); + expect(complete.status).toBe(200); + + const full = await s3Request('GET', `/${bucketName}/multipart-full.txt`); + expect(full.status).toBe(200); + expect(await full.text()).toBe('hello multipart'); + + const partial = await s3Request('GET', `/${bucketName}/multipart-full.txt`, { + headers: { range: 'bytes=3-9' }, + }); + expect(partial.status).toBe(206); + expect(partial.headers.get('content-range')).toBe('bytes 3-9/15'); + expect(await partial.text()).toBe('lo mult'); + }); + + it('Delete bucket — must be empty first', async () => { + // Clean up remaining objects + await s3Request('DELETE', `/${bucketName}/test-file.txt`); + await s3Request('DELETE', `/${bucketName}/copy-dest.txt`); + await s3Request('DELETE', `/${bucketName}/presigned-test.txt`); + await s3Request('DELETE', `/${bucketName}/multipart-full.txt`); + + const r = await s3Request('DELETE', `/${bucketName}`); + expect(r.status).toBe(204); + createdBuckets = createdBuckets.filter((b) => b !== bucketName); + }); + + it('S3 error — NoSuchBucket returns 404 XML', async () => { + const r = await s3Request('GET', '/bucket-nonexistent-xyz'); + expect(r.status).toBe(404); + const xml = await r.text(); + expect(xml).toContain('NoSuchBucket'); + }); + + it('S3 error — bad signature returns 403', async () => { + const r = await fetch(`${BASE_URL}/`, { + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=fake/20260701/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=00', + 'x-amz-date': '20260701T000000Z', + 'x-amz-content-sha256': + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + }, + }); + expect(r.status).toBe(403); + const xml = await r.text(); + expect(xml).toContain('Error'); }); - expect(r.status).toBe(403); - const xml = await r.text(); - expect(xml).toContain('Error'); - }); } }); diff --git a/test/telegram.test.ts b/test/telegram.test.ts index 68a27cf..fa57d23 100644 --- a/test/telegram.test.ts +++ b/test/telegram.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { ITelegramService } from '../src/domain/ports/telegram-service'; import { config } from '../src/env'; import logger from '../src/utils/logger'; @@ -65,7 +66,7 @@ const infoSpy = spyOn(logger, 'info'); const errorSpy = spyOn(logger, 'error'); describe('Telegram API Utilities', () => { - let botPool: { forwardToStorage: Function; getFileInfo: Function; enqueueUpload: Function }; + let botPool: ITelegramService; beforeEach(async () => { infoSpy.mockClear();