2026-07-07 04:23:12 +07:00
|
|
|
import { afterEach, describe, expect, it } from 'bun:test';
|
2026-08-27 15:48:14 +07:00
|
|
|
import { createGetObjectResponse, type ObjectPartSource } from '../src/interfaces/s3/object-stream';
|
|
|
|
|
import type { RangeParseResult } from '../src/interfaces/s3/range';
|
2026-07-07 04:23:12 +07:00
|
|
|
|
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
|
|
2026-08-27 15:48:14 +07:00
|
|
|
interface PartStub {
|
|
|
|
|
url: string;
|
|
|
|
|
size: number;
|
|
|
|
|
content: string;
|
|
|
|
|
gzip?: boolean;
|
|
|
|
|
/** If set, fetch returns this error for this part. */
|
|
|
|
|
status?: number;
|
|
|
|
|
}
|
2026-07-07 04:23:12 +07:00
|
|
|
|
2026-08-27 15:48:14 +07:00
|
|
|
/**
|
|
|
|
|
* Installs a fetch stub that serves each part's bytes (optionally slicing by
|
|
|
|
|
* Range header), or returns the configured HTTP error status.
|
|
|
|
|
*/
|
|
|
|
|
const installFetch = (parts: PartStub[]) => {
|
2026-07-07 04:23:12 +07:00
|
|
|
globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
|
|
|
const url = String(_url);
|
2026-08-27 15:48:14 +07:00
|
|
|
const part = parts.find((p) => p.url === url);
|
|
|
|
|
if (!part) return new Response('not found', { status: 404 });
|
|
|
|
|
if (part.status) return new Response('err', { status: part.status });
|
|
|
|
|
|
|
|
|
|
const range = new Headers(init?.headers).get('range');
|
|
|
|
|
let body = part.content;
|
|
|
|
|
let status = 200;
|
|
|
|
|
if (range) {
|
|
|
|
|
const m = /bytes=(\d+)-(\d+)/.exec(range);
|
|
|
|
|
if (m) {
|
|
|
|
|
const start = Number(m[1]);
|
|
|
|
|
const end = Number(m[2]);
|
|
|
|
|
body = part.content.slice(start, end + 1);
|
|
|
|
|
status = 206;
|
|
|
|
|
}
|
2026-07-07 04:23:12 +07:00
|
|
|
}
|
2026-08-27 15:48:14 +07:00
|
|
|
return new Response(body, {
|
|
|
|
|
status,
|
|
|
|
|
headers: { 'content-length': String(new TextEncoder().encode(body).length) },
|
2026-07-07 04:23:12 +07:00
|
|
|
});
|
|
|
|
|
}) as typeof fetch;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
globalThis.fetch = originalFetch;
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-27 15:48:14 +07:00
|
|
|
const part = (
|
|
|
|
|
id: string,
|
|
|
|
|
size: number,
|
|
|
|
|
content: string,
|
|
|
|
|
overrides: Partial<PartStub> = {},
|
|
|
|
|
): PartStub & {
|
|
|
|
|
asSrc: ObjectPartSource;
|
|
|
|
|
} => ({
|
|
|
|
|
url: `https://tg.test/${id}`,
|
|
|
|
|
size,
|
|
|
|
|
content,
|
|
|
|
|
...overrides,
|
|
|
|
|
asSrc: {
|
|
|
|
|
telegramFileId: id,
|
|
|
|
|
telegramUrl: `https://tg.test/${id}`,
|
|
|
|
|
sizeBytes: size,
|
|
|
|
|
partNumber: 1,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const src = (p: PartStub, partNumber: number): ObjectPartSource => ({
|
|
|
|
|
telegramFileId: p.url,
|
|
|
|
|
telegramUrl: p.url,
|
|
|
|
|
sizeBytes: p.size,
|
|
|
|
|
partNumber,
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-07 04:23:12 +07:00
|
|
|
describe('S3 object stream response builder', () => {
|
2026-08-27 15:48:14 +07:00
|
|
|
it('concatenates multiple parts in order', async () => {
|
|
|
|
|
const p1 = part('part-1', 6, 'hello ');
|
|
|
|
|
const p2 = part('part-2', 5, 'world');
|
|
|
|
|
installFetch([p1, p2]);
|
|
|
|
|
|
2026-07-07 04:23:12 +07:00
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-1',
|
|
|
|
|
contentType: 'text/plain',
|
|
|
|
|
etag: 'etag123',
|
|
|
|
|
lastModified: new Date('2026-07-07T00:00:00Z'),
|
|
|
|
|
totalSize: 11,
|
2026-08-27 15:48:14 +07:00
|
|
|
parts: [src(p1, 1), src(p2, 2)],
|
2026-07-07 04:23:12 +07:00
|
|
|
range: { type: 'none' },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
|
expect(res.headers.get('content-length')).toBe('11');
|
|
|
|
|
expect(await res.text()).toBe('hello world');
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-27 15:48:14 +07:00
|
|
|
it('returns 206 with content-range for a range within a single part', async () => {
|
|
|
|
|
const p1 = part('part-1', 6, 'hello ');
|
|
|
|
|
installFetch([p1]);
|
|
|
|
|
const range: RangeParseResult = { type: 'valid', start: 1, end: 3 };
|
|
|
|
|
|
2026-07-07 04:23:12 +07:00
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-2',
|
|
|
|
|
contentType: 'text/plain',
|
|
|
|
|
etag: 'etag123',
|
|
|
|
|
lastModified: new Date('2026-07-07T00:00:00Z'),
|
|
|
|
|
totalSize: 6,
|
2026-08-27 15:48:14 +07:00
|
|
|
parts: [src(p1, 1)],
|
|
|
|
|
range,
|
2026-07-07 04:23:12 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.status).toBe(206);
|
|
|
|
|
expect(res.headers.get('content-range')).toBe('bytes 1-3/6');
|
|
|
|
|
expect(res.headers.get('content-length')).toBe('3');
|
|
|
|
|
expect(await res.text()).toBe('ell');
|
|
|
|
|
});
|
2026-08-27 15:48:14 +07:00
|
|
|
|
|
|
|
|
it('spans a byte range across two parts correctly', async () => {
|
|
|
|
|
// part-1 = "hello " (6 bytes), part-2 = "world" (5 bytes), total "hello world"
|
|
|
|
|
const p1 = part('p1', 6, 'hello ');
|
|
|
|
|
const p2 = part('p2', 5, 'world');
|
|
|
|
|
installFetch([p1, p2]);
|
|
|
|
|
// range 5..9 = " worl" (byte5 space + bytes6-9 "worl")
|
|
|
|
|
const range: RangeParseResult = { type: 'valid', start: 5, end: 9 };
|
|
|
|
|
|
|
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-3',
|
|
|
|
|
contentType: 'text/plain',
|
|
|
|
|
etag: 'e',
|
|
|
|
|
lastModified: new Date(),
|
|
|
|
|
totalSize: 11,
|
|
|
|
|
parts: [src(p1, 1), src(p2, 2)],
|
|
|
|
|
range,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(res.status).toBe(206);
|
|
|
|
|
expect(res.headers.get('content-range')).toBe('bytes 5-9/11');
|
|
|
|
|
expect(await res.text()).toBe(' worl');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serves full object when range is none', async () => {
|
|
|
|
|
const p1 = part('p1', 2, 'ab');
|
|
|
|
|
const p2 = part('p2', 2, 'cd');
|
|
|
|
|
installFetch([p1, p2]);
|
|
|
|
|
|
|
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-4',
|
|
|
|
|
contentType: 'application/octet-stream',
|
|
|
|
|
etag: 'e',
|
|
|
|
|
lastModified: new Date(),
|
|
|
|
|
totalSize: 4,
|
|
|
|
|
parts: [src(p1, 1), src(p2, 2)],
|
|
|
|
|
range: { type: 'none' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.status).toBe(200);
|
|
|
|
|
expect(await res.text()).toBe('abcd');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('includes S3 headers and CORS in the response', async () => {
|
|
|
|
|
const p1 = part('p1', 1, 'a');
|
|
|
|
|
installFetch([p1]);
|
|
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-x',
|
|
|
|
|
contentType: 'text/plain',
|
|
|
|
|
etag: 'mytag',
|
|
|
|
|
lastModified: new Date('2026-01-01T00:00:00Z'),
|
|
|
|
|
totalSize: 1,
|
|
|
|
|
parts: [src(p1, 1)],
|
|
|
|
|
range: { type: 'none' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.headers.get('x-amz-request-id')).toBe('req-x');
|
|
|
|
|
expect(res.headers.get('accept-ranges')).toBe('bytes');
|
|
|
|
|
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
|
|
|
|
expect(res.headers.get('etag')).toBe('"mytag"');
|
|
|
|
|
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('propagates a Telegram fetch failure into the response stream (errors on read)', async () => {
|
|
|
|
|
const p1 = part('p1', 2, 'ab', { status: 502 });
|
|
|
|
|
installFetch([p1]);
|
|
|
|
|
const res = await createGetObjectResponse({
|
|
|
|
|
reqId: 'req-err',
|
|
|
|
|
contentType: 'text/plain',
|
|
|
|
|
etag: 'e',
|
|
|
|
|
lastModified: new Date(),
|
|
|
|
|
totalSize: 2,
|
|
|
|
|
parts: [src(p1, 1)],
|
|
|
|
|
range: { type: 'none' },
|
|
|
|
|
});
|
|
|
|
|
// The stream errors when read, not on creation.
|
|
|
|
|
await expect(res.text()).rejects.toThrow();
|
|
|
|
|
});
|
2026-07-07 04:23:12 +07:00
|
|
|
});
|