/** * @mandujs/core/storage/s3 — unit tests * * These tests never touch the network. They substitute a controllable * in-memory fake for `Bun.S3Client` via the internal `_createS3ClientWith` * entry point, then assert that our wrapper forwards calls correctly and * applies the content-type / URL / error-mapping heuristics we promise. * * A separate integration suite (`packages/core/tests/storage/s3-minio.test.ts`) * exercises the real Bun.s3 code path against MinIO. */ import { describe, it, expect } from "bun:test"; import { _createS3ClientWith, getContentType, type BunS3ClientCtor, type BunS3ClientInstance, } from "../index"; // ─── Fake Bun.S3Client ────────────────────────────────────────────────────── /** Args captured on each fake method call — shape matches what we forward. */ interface CapturedWrite { key: string; body: Blob | ArrayBuffer | Uint8Array; options: { type?: string; acl?: string } | undefined; } interface CapturedPresign { key: string; options: { method?: string; expiresIn?: number; type?: string } | undefined; } interface FakeState { ctorCalls: Array<{ bucket: string; endpoint?: string; region?: string; virtualHostedStyle?: boolean; }>; writes: CapturedWrite[]; presigns: CapturedPresign[]; deletes: string[]; existsCalls: string[]; streamCalls: string[]; /** `true` = resolves true, `false` = resolves false, Error = throws. */ existsBehavior: Map; deleteBehavior: Map; } function createFakeCtor(): { Ctor: BunS3ClientCtor; state: FakeState } { const state: FakeState = { ctorCalls: [], writes: [], presigns: [], deletes: [], existsCalls: [], streamCalls: [], existsBehavior: new Map(), deleteBehavior: new Map(), }; class FakeS3Client implements BunS3ClientInstance { constructor(config: { bucket: string; endpoint?: string; region?: string; virtualHostedStyle?: boolean; }) { state.ctorCalls.push(config); } file(key: string) { return { async write( body: Blob | ArrayBuffer | Uint8Array, options?: { type?: string; acl?: string }, ) { state.writes.push({ key, body, options }); // Bun's write returns the byte count; fake a plausible number. return 0; }, presign(options?: { method?: string; expiresIn?: number; type?: string; }): string { state.presigns.push({ key, options }); const method = options?.method ?? "PUT"; const expires = options?.expiresIn ?? 900; return `https://fake.s3.local/${key}?method=${method}&expires=${expires}`; }, async delete() { const override = state.deleteBehavior.get(key); if (override) throw override; state.deletes.push(key); }, async exists() { state.existsCalls.push(key); const b = state.existsBehavior.get(key); if (b instanceof Error) throw b; if (b === undefined) return true; return b; }, stream(): ReadableStream { state.streamCalls.push(key); return new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])); controller.close(); }, }); }, }; } } // The ctor signature we export is `new (config) => instance` — cast through // unknown once to satisfy strict TS without widening the public type. return { Ctor: FakeS3Client as unknown as BunS3ClientCtor, state, }; } // ─── Tests ────────────────────────────────────────────────────────────────── describe("@mandujs/core/storage/s3 — upload", () => { it("calls bunClient.file(key).write(body) with the correct key and body", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "my-bucket", endpoint: "https://s3.example.com", forcePathStyle: true, }); const body = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); await client.upload(body, { key: "a/b.txt" }); expect(state.writes).toHaveLength(1); expect(state.writes[0]!.key).toBe("a/b.txt"); expect(state.writes[0]!.body).toBe(body); }); it("infers Content-Type from key extension when not provided", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.upload(new Uint8Array(), { key: "photos/hero.jpg" }); await client.upload(new Uint8Array(), { key: "data/report.json" }); expect(state.writes[0]!.options?.type).toBe("image/jpeg"); expect(state.writes[1]!.options?.type).toBe("application/json"); }); it("passes through explicit contentType when provided", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.upload(new Uint8Array(), { key: "binary-with-jpg-extension.jpg", contentType: "application/octet-stream", }); // Explicit contentType wins over extension-based inference. expect(state.writes[0]!.options?.type).toBe("application/octet-stream"); }); it("passes through acl when provided and omits when not", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.upload(new Uint8Array(), { key: "public.png", acl: "public-read" }); await client.upload(new Uint8Array(), { key: "private.png" }); expect(state.writes[0]!.options?.acl).toBe("public-read"); // When acl is omitted from our call, it must NOT appear in the forwarded options. expect(state.writes[1]!.options?.acl).toBeUndefined(); }); it("accepts Blob, ArrayBuffer, and Uint8Array bodies uniformly", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const blob = new Blob(["hi"], { type: "text/plain" }); const ab = new ArrayBuffer(4); const u8 = new Uint8Array([1, 2, 3]); await client.upload(blob, { key: "a.txt" }); await client.upload(ab, { key: "b.bin" }); await client.upload(u8, { key: "c.bin" }); expect(state.writes[0]!.body).toBe(blob); expect(state.writes[1]!.body).toBe(ab); expect(state.writes[2]!.body).toBe(u8); }); it("throws when metadata is passed (not yet supported by Bun.S3Client)", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await expect( client.upload(new Uint8Array(), { key: "x.txt", metadata: { owner: "alice" }, }), ).rejects.toThrow(/metadata.*not yet supported/); }); it("returns a path-style URL when forcePathStyle=true", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "uploads", endpoint: "https://minio.local:9000", forcePathStyle: true, }); const url = await client.upload(new Uint8Array(), { key: "u/1.png" }); expect(url).toBe("https://minio.local:9000/uploads/u/1.png"); }); it("returns a virtual-hosted URL by default", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "uploads", endpoint: "https://s3.example.com", }); const url = await client.upload(new Uint8Array(), { key: "u/1.png" }); expect(url).toBe("https://uploads.s3.example.com/u/1.png"); }); it("returns an s3:// URI when no endpoint is configured (AWS default)", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "my-bucket" }); const url = await client.upload(new Uint8Array(), { key: "a/b.txt" }); expect(url).toBe("s3://my-bucket/a/b.txt"); }); it("URL-encodes path segments but preserves '/' separators", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b", endpoint: "https://s3.example.com", forcePathStyle: true, }); const url = await client.upload(new Uint8Array(), { key: "user uploads/hello world.txt", }); expect(url).toBe("https://s3.example.com/b/user%20uploads/hello%20world.txt"); }); it("throws a TypeError when key is missing", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await expect( client.upload(new Uint8Array(), { key: "" }), ).rejects.toThrow(TypeError); }); }); describe("@mandujs/core/storage/s3 — presign", () => { it("returns a string URL for method=PUT", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const url = await client.presign({ key: "upload.png", method: "PUT" }); expect(typeof url).toBe("string"); expect(url.length).toBeGreaterThan(0); expect(state.presigns[0]!.options?.method).toBe("PUT"); }); it("honors expiresIn=60 for method=GET", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.presign({ key: "download.txt", method: "GET", expiresIn: 60 }); expect(state.presigns[0]!.options?.method).toBe("GET"); expect(state.presigns[0]!.options?.expiresIn).toBe(60); }); it("defaults method to PUT and expiresIn to 900 seconds", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.presign({ key: "thing" }); expect(state.presigns[0]!.options?.method).toBe("PUT"); expect(state.presigns[0]!.options?.expiresIn).toBe(900); }); it("forwards contentType as Bun's `type` option", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.presign({ key: "x.png", method: "PUT", contentType: "image/png" }); expect(state.presigns[0]!.options?.type).toBe("image/png"); }); it("throws a TypeError when key is missing", async () => { const { Ctor } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await expect(client.presign({ key: "" })).rejects.toThrow(TypeError); }); }); describe("@mandujs/core/storage/s3 — delete", () => { it("calls bunClient.file(key).delete() and resolves", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); await client.delete("to-remove.txt"); expect(state.deletes).toContain("to-remove.txt"); }); it("resolves silently on NoSuchKey errors (idempotent delete)", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const notFound = Object.assign(new Error("Not found"), { name: "S3Error", code: "NoSuchKey", }); state.deleteBehavior.set("ghost.txt", notFound); await expect(client.delete("ghost.txt")).resolves.toBeUndefined(); }); it("re-throws non-404 delete errors", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const accessDenied = Object.assign(new Error("Access denied"), { name: "S3Error", code: "AccessDenied", status: 403, }); state.deleteBehavior.set("forbidden.txt", accessDenied); await expect(client.delete("forbidden.txt")).rejects.toThrow(/Access denied/); }); }); describe("@mandujs/core/storage/s3 — exists", () => { it("returns true when the object exists", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); state.existsBehavior.set("here.txt", true); expect(await client.exists("here.txt")).toBe(true); }); it("returns false when Bun.s3 reports the object as absent", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); state.existsBehavior.set("gone.txt", false); expect(await client.exists("gone.txt")).toBe(false); }); it("returns false when Bun.s3 throws a NoSuchKey-shaped error", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const nf = Object.assign(new Error("no key"), { name: "S3Error", code: "NoSuchKey", }); state.existsBehavior.set("missing.txt", nf); expect(await client.exists("missing.txt")).toBe(false); }); it("re-throws non-404 errors (e.g., AccessDenied/5xx)", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const denied = Object.assign(new Error("403 denied"), { name: "S3Error", code: "AccessDenied", status: 403, }); state.existsBehavior.set("protected.txt", denied); await expect(client.exists("protected.txt")).rejects.toThrow(/403 denied/); }); }); describe("@mandujs/core/storage/s3 — getReadable", () => { it("returns a ReadableStream", async () => { const { Ctor, state } = createFakeCtor(); const client = _createS3ClientWith(Ctor, { bucket: "b" }); const s = await client.getReadable("file.bin"); expect(s).toBeInstanceOf(ReadableStream); expect(state.streamCalls).toContain("file.bin"); }); }); describe("@mandujs/core/storage/s3 — createS3Client", () => { it("forwards bucket, endpoint, and region to the underlying ctor", () => { const { Ctor, state } = createFakeCtor(); _createS3ClientWith(Ctor, { bucket: "prod-uploads", endpoint: "https://abc.r2.cloudflarestorage.com", region: "auto", }); expect(state.ctorCalls).toHaveLength(1); expect(state.ctorCalls[0]!.bucket).toBe("prod-uploads"); expect(state.ctorCalls[0]!.endpoint).toBe("https://abc.r2.cloudflarestorage.com"); expect(state.ctorCalls[0]!.region).toBe("auto"); }); it("maps forcePathStyle=true to virtualHostedStyle=false (Bun's flag name)", () => { const { Ctor, state } = createFakeCtor(); _createS3ClientWith(Ctor, { bucket: "b", endpoint: "http://localhost:9000", forcePathStyle: true, }); expect(state.ctorCalls[0]!.virtualHostedStyle).toBe(false); }); it("leaves virtualHostedStyle untouched when forcePathStyle is unset", () => { const { Ctor, state } = createFakeCtor(); _createS3ClientWith(Ctor, { bucket: "b" }); expect(state.ctorCalls[0]!.virtualHostedStyle).toBeUndefined(); }); it("throws a TypeError when bucket is missing", () => { const { Ctor } = createFakeCtor(); expect(() => // eslint-disable-next-line @typescript-eslint/no-explicit-any _createS3ClientWith(Ctor, { bucket: "" } as S3ConfigLike), ).toThrow(TypeError); }); }); // Helper alias for the "missing bucket" negative test — keeps us from having // to stringify `any` in the test body. type S3ConfigLike = Parameters[1]; describe("@mandujs/core/storage/s3 — getContentType", () => { it.each([ ["photo.jpg", "image/jpeg"], ["PHOTO.JPEG", "image/jpeg"], // case-insensitive ["a/b/c.png", "image/png"], ["logo.webp", "image/webp"], ["anim.gif", "image/gif"], ["doc.pdf", "application/pdf"], ["notes.txt", "text/plain"], ["data.json", "application/json"], ["report.csv", "text/csv"], ["archive.zip", "application/zip"], ["clip.mp4", "video/mp4"], ["clip.webm", "video/webm"], ])("maps %s to %s", (key, expected) => { expect(getContentType(key)).toBe(expected); }); it.each([ ["README", "application/octet-stream"], // no extension ["archive.", "application/octet-stream"], // trailing dot ["file.unknown", "application/octet-stream"], // unrecognized extension [".hidden", "application/octet-stream"], // dotfile without real extension ])("falls back to octet-stream for %s", (key, expected) => { expect(getContentType(key)).toBe(expected); }); });