refactor(fase0): shared scaffold — crypto, file-url, schemas, test splits

- Add zod dep; new src/shared/validation/schemas.ts (BucketName,
  JsonUploadPayload, LoginBody, DeleteObjects, CompleteMultipart,
  clampMaxKeys, parseOrNull) + test/validation.test.ts
- Dedup timingSafeCompare -> src/shared/utils/crypto.ts (auth
  middleware, authenticate use-case, s3/auth now import it)
- Dedup AuthSession -> single type in dto/auth.ts
- Dedup telegram file URL builder + filename sanitizer to shared
  modules (file-controller now imports the canonical ones)
- Remove duplicate controllers/home.html (canonical: src/home.html)
- Fix stale bootstrap mocks to real module paths; bootstrap now
  asserts public GET vs guarded POST separately
- Fix health assertion to include version field
- package.json: test -> test:unit alias; new test:quarantine for
  network/live tests; register previously unlisted test files
This commit is contained in:
asepharyana
2026-09-14 17:09:52 +07:00
parent f4b30cef0c
commit 5d01a9405f
16 changed files with 511 additions and 473 deletions
+17 -4
View File
@@ -42,7 +42,7 @@ mock.module('../src/interfaces/bot/handler', () => ({
startBot: mockStartBot,
}));
mock.module('../src/db/migrate', () => ({
mock.module('../src/infrastructure/persistence/drizzle/migrate', () => ({
runMigration: mock(() => Promise.resolve()),
}));
@@ -75,7 +75,7 @@ mock.module('../src/interfaces/http/middleware/auth', () => ({
requireAuth: mockRequireAuth,
}));
mock.module('../src/utils/rateLimit', () => ({
mock.module('../src/interfaces/http/middleware/rate-limit', () => ({
cleanupRateLimitCache: mock(),
clearRateLimitCache: mock(),
checkRateLimit: mock(() => true),
@@ -128,8 +128,21 @@ describe('Bootstrap Server', () => {
expect(await res.json()).toEqual({ ok: true });
expect(mockHandleUpload).toHaveBeenCalledTimes(1);
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as { GET: RouteHandler };
const protectedRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
// GET /api/v1/* is intentionally public (read endpoints need no auth) —
// it passes through to the raw handler (stubbed here to 404).
const webApiRoute = serveCallArgs.routes?.['/api/v1/*'] as {
GET: RouteHandler;
POST: RouteHandler;
};
const publicRes = await webApiRoute.GET(new Request('http://localhost/api/v1/files'));
expect(publicRes.status).toBe(404);
expect(await publicRes.json()).toEqual({ error: 'Not Found' });
// Write endpoints are auth-guarded — POST goes through requireAuth (401 here).
const protectedRes = await webApiRoute.POST(
new Request('http://localhost/api/v1/files', { method: 'POST' }),
);
expect(protectedRes.status).toBe(401);
expect(await protectedRes.json()).toEqual({ error: 'Unauthorized' });
-3
View File
@@ -7,9 +7,6 @@ import { ChunkedStorage } from '../src/infrastructure/telegram/chunked-storage';
* 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 =>
({
+1 -1
View File
@@ -24,7 +24,7 @@ describe('Health Route Handler', () => {
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ status: 'ok' });
expect(body).toEqual({ status: 'ok', version: '1.2.2' });
expect(mockExecute).toHaveBeenCalled();
});
+170
View File
@@ -0,0 +1,170 @@
import { mock } from 'bun:test';
import type { ITelegramService } from '../../src/domain/ports/telegram-service';
/**
* Shared test doubles for the TeleUploader unit test suite.
*
* Consolidates the mock patterns that were previously copy-pasted across
* test files (`MockTelegraf` in bot-pool/bot/telegram tests, stub DI repos
* in upload/files tests, deterministic nanoid counters in upload tests).
*
* Rules:
* - Import from this module inside individual test files only — never as a
* global preload (avoids cross-file mock pollution; see CLAUDE.md).
* - Prefer `mock.module` with these factories over hand-rolled inline mocks
* so behavior stays consistent when the doubles evolve.
*
* @module test/helpers/test-doubles
*/
/** Minimal Telegram message result shape used by the MockTelegraf doubles. */
export interface MockTelegramMessage {
/** Telegram message identifier. */
message_id: number;
/** Document payload (present for document uploads). */
document?: { file_id: string; file_unique_id: string };
/** Photo payload (present for photo uploads). */
photo?: Array<{ file_id: string; file_unique_id: string }>;
}
/**
* Creates a Telegraf mock payload (`mock.module('telegraf', …)` factory).
*
* Mirrors the `MockTelegraf` pattern from `bot-pool.test.ts` / `bot.test.ts` /
* `telegram.test.ts` with controllable per-method implementations.
*
* @param overrides - Optional per-method implementations.
* @returns A `{ Telegraf }` module shape for `mock.module`.
*/
export const mockTelegrafModule = (overrides?: {
sendDocument?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
sendPhoto?: (chatId: unknown, file: unknown, extra?: unknown) => Promise<MockTelegramMessage>;
getFile?: (fileId: string) => Promise<{ file_path?: string }>;
}) => {
const sendDocument =
overrides?.sendDocument ??
(async () => ({
message_id: 54321,
document: { file_id: 'document_id', file_unique_id: 'document_unique_id' },
}));
const sendPhoto =
overrides?.sendPhoto ??
(async () => ({
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 getFile = overrides?.getFile ?? (async () => ({ file_path: 'documents/file.dat' }));
return {
Telegraf: class {
token: unknown;
telegram: {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
getFile: typeof getFile;
};
constructor(token: unknown) {
this.token = token;
this.telegram = { sendDocument, sendPhoto, getFile };
}
},
};
};
/**
* Creates a minimal {@link ITelegramService} stub backed by `bun:test` mocks.
*
* Mirrors the hand-rolled stub in `chunked-storage.test.ts` (real
* `ChunkedStorage` + stub telegram service + no-op repos).
*
* @param overrides - Optional per-method implementations.
* @returns An `ITelegramService` implementation whose methods are mocks.
*/
export const makeTelegramServiceStub = (
overrides?: Partial<ITelegramService>,
): ITelegramService & {
forwardToStorage: ReturnType<typeof mock>;
getFileInfo: ReturnType<typeof mock>;
} => {
const forwardToStorage = mock(
overrides?.forwardToStorage ??
(async (_bytes: unknown, fileName: string) => ({
telegramFileId: `tg-${fileName}`,
telegramFileUniqueId: `tg-unique-${fileName}`,
storageMessageId: 1,
})),
);
const getFileInfo = mock(
overrides?.getFileInfo ??
(async (telegramFileId: string) => ({
file_size: 100,
mime_type: 'application/octet-stream',
file_path: 'documents/file.dat',
bot_token: '123456:ABC-DEF',
telegramFileId,
})),
);
return {
forwardToStorage: forwardToStorage as unknown as ITelegramService['forwardToStorage'],
getFileInfo: getFileInfo as unknown as ITelegramService['getFileInfo'],
};
};
/**
* Creates a deterministic `nanoid` module mock — each call returns
* `prefix-1`, `prefix-2`, … instead of random IDs.
*
* Replaces the ad-hoc `mock.module('nanoid')` counters in `upload.test.ts`.
*
* @param prefix - Prefix for generated IDs (default `"test-id"`).
* @returns A `{ nanoid }` module shape for `mock.module`.
*/
export const mockNanoidModule = (prefix = 'test-id') => {
let counter = 0;
return {
nanoid: mock(() => `${prefix}-${++counter}`),
};
};
/**
* Builds a signed S3 test request with valid SigV4 headers.
*
* Request-signing is covered by `s3-auth.test.ts`; this helper exists for
* handler-level tests that mock `../src/interfaces/s3/auth` to accept any
* signature and only need a well-formed request object.
*
* @param url - Request URL (path-style `/bucket/key` or `/`).
* @param init - Optional `RequestInit` overrides (method defaults to GET).
* @returns A `Request` with stub SigV4 headers.
*/
export const s3TestRequest = (url: string, init?: RequestInit): Request =>
new Request(url, {
method: 'GET',
...init,
headers: {
authorization: 'AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/s3/aws4_request',
'x-amz-date': '20260101T000000Z',
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
...(init?.headers ?? {}),
},
});
/** 1x1px JPEG fallback binary (offline-safe fixture, no network fetch). */
export const TINY_JPEG_HEX =
'ffd8ffe000104a46494600010101006000600000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0b000080100010101011100ffc4001f0000010501010110000000000000000000000102030405060708ffda000c03010002110311003f00a0ffd9';
/**
* Returns the tiny-JPEG fixture as a `Buffer` without any network access.
*
* Replaces the Wikimedia-fetch-with-fallback pattern in `telegram.test.ts`
* and `upload.test.ts` for tests that only need *some* valid image bytes.
* Tests that genuinely need a real PNG must stay in quarantine (see
* `test:quarantine` in package.json).
*
* @returns A 1x1px JPEG buffer.
*/
export const tinyJpegBuffer = (): Buffer => Buffer.from(TINY_JPEG_HEX, 'hex');
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'bun:test';
import {
BucketNameSchema,
CompleteMultipartBodySchema,
clampMaxKeys,
DeleteObjectsBodySchema,
JsonUploadPayloadSchema,
LoginBodySchema,
parseOrNull,
} from '../src/shared/validation/schemas';
describe('BucketNameSchema (single canonical validator)', () => {
it('accepts valid bucket names', () => {
expect(BucketNameSchema.safeParse('gitea').success).toBe(true);
expect(BucketNameSchema.safeParse('my.bucket-01').success).toBe(true);
});
it('rejects invalid names (format, dots, IP, xn--)', () => {
expect(BucketNameSchema.safeParse('ab').success).toBe(false);
expect(BucketNameSchema.safeParse('UPPERCASE').success).toBe(false);
expect(BucketNameSchema.safeParse('a..b').success).toBe(false);
expect(BucketNameSchema.safeParse('192.168.1.1').success).toBe(false);
expect(BucketNameSchema.safeParse('xn--bcher-kva').success).toBe(false);
});
});
describe('JsonUploadPayloadSchema', () => {
it('accepts file + optional fileName', () => {
expect(
JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=', fileName: 'hi.txt' }).success,
).toBe(true);
});
it('defaults fileName and rejects missing/empty file', () => {
const parsed = JsonUploadPayloadSchema.safeParse({ file: 'aGVsbG8=' });
expect(parsed.success && parsed.data.fileName).toBe('file');
expect(JsonUploadPayloadSchema.safeParse({}).success).toBe(false);
expect(JsonUploadPayloadSchema.safeParse({ file: '' }).success).toBe(false);
});
});
describe('LoginBodySchema', () => {
it('accepts a non-empty token and rejects the rest', () => {
expect(LoginBodySchema.safeParse({ token: 'secret' }).success).toBe(true);
expect(LoginBodySchema.safeParse({}).success).toBe(false);
expect(LoginBodySchema.safeParse({ token: '' }).success).toBe(false);
expect(LoginBodySchema.safeParse({ token: 42 }).success).toBe(false);
});
});
describe('DeleteObjectsBodySchema', () => {
it('accepts parsed keys and enforces the 1000-key S3 limit', () => {
expect(DeleteObjectsBodySchema.safeParse({ keys: ['a', 'b'], quiet: false }).success).toBe(
true,
);
expect(
DeleteObjectsBodySchema.safeParse({ keys: new Array(1001).fill('k'), quiet: true }).success,
).toBe(false);
});
});
describe('CompleteMultipartBodySchema', () => {
it('accepts parsed parts and rejects bad part numbers', () => {
expect(
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 1, etag: 'abc' }] }).success,
).toBe(true);
expect(
CompleteMultipartBodySchema.safeParse({ parts: [{ partNumber: 0, etag: 'abc' }] }).success,
).toBe(false);
});
});
describe('clampMaxKeys (unified listing clamp)', () => {
it('clamps into [1, 1000] and falls back to 1000 on garbage', () => {
expect(clampMaxKeys(null)).toBe(1000);
expect(clampMaxKeys('5')).toBe(5);
expect(clampMaxKeys('99999')).toBe(1000);
expect(clampMaxKeys('0')).toBe(1000);
expect(clampMaxKeys('-3')).toBe(1000);
expect(clampMaxKeys('abc')).toBe(1000);
});
});
describe('parseOrNull', () => {
it('returns the value on success and null on failure', () => {
expect(parseOrNull(LoginBodySchema, { token: 'x' })).toEqual({ token: 'x' });
expect(parseOrNull(LoginBodySchema, {})).toBeNull();
});
});