diff --git a/src/server/services/storage.service.ts b/src/server/services/storage.service.ts index d53a7fe..64ac235 100644 --- a/src/server/services/storage.service.ts +++ b/src/server/services/storage.service.ts @@ -1,22 +1,6 @@ -import { - DeleteObjectCommand, - GetObjectCommand, - PutObjectCommand, - S3Client, -} from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; - // S3 API abstraction — provider-agnostic (Garage, Cloudflare R2, AWS S3). - -const s3 = new S3Client({ - endpoint: process.env.S3_ENDPOINT, - region: process.env.S3_REGION ?? "us-east-1", - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY!, - secretAccessKey: process.env.S3_SECRET_KEY!, - }, - forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services -}); +// Dynamic imports are used intentionally so that test mocks for @aws-sdk/* +// take effect even when this module is already cached from a prior import. const bucket = process.env.S3_BUCKET ?? "gearbox-images"; const presignExpiry = Number.parseInt( @@ -24,11 +8,26 @@ const presignExpiry = Number.parseInt( 10, ); +async function createS3Client() { + const { S3Client } = await import("@aws-sdk/client-s3"); + return new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION ?? "us-east-1", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services + }); +} + export async function uploadImage( buffer: Buffer | ArrayBuffer, filename: string, contentType: string, ): Promise { + const { PutObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = await createS3Client(); await s3.send( new PutObjectCommand({ Bucket: bucket, @@ -40,6 +39,8 @@ export async function uploadImage( } export async function deleteImage(filename: string): Promise { + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + const s3 = await createS3Client(); await s3.send( new DeleteObjectCommand({ Bucket: bucket, @@ -49,6 +50,9 @@ export async function deleteImage(filename: string): Promise { } export async function getImageUrl(filename: string): Promise { + const { GetObjectCommand } = await import("@aws-sdk/client-s3"); + const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner"); + const s3 = await createS3Client(); const command = new GetObjectCommand({ Bucket: bucket, Key: filename, diff --git a/tests/middleware/auth.test.ts b/tests/middleware/auth.test.ts index 01df328..bcb80b1 100644 --- a/tests/middleware/auth.test.ts +++ b/tests/middleware/auth.test.ts @@ -12,10 +12,17 @@ mock.module("@hono/oidc-auth", () => ({ revokeSession: async () => {}, })); -// Mock verifyAccessToken from oauth.service +// Mock oauth.service — all exports included so later test files don't see +// a partial module shape when Bun's module registry is shared across files. const mockVerifyAccessToken = mock(() => Promise.resolve(false)); mock.module("../../src/server/services/oauth.service", () => ({ verifyAccessToken: mockVerifyAccessToken, + registerClient: mock(() => Promise.resolve({ clientId: "id" })), + getClient: mock(() => Promise.resolve(null)), + createAuthorizationCode: mock(() => Promise.resolve({ code: "code" })), + exchangeCode: mock(() => Promise.resolve(null)), + refreshAccessToken: mock(() => Promise.resolve(null)), + cleanExpiredOAuthData: mock(() => Promise.resolve()), })); // Import middleware AFTER mocks are set up diff --git a/tests/routes/auth.test.ts b/tests/routes/auth.test.ts index 55f28a0..82681aa 100644 --- a/tests/routes/auth.test.ts +++ b/tests/routes/auth.test.ts @@ -12,11 +12,6 @@ mock.module("@hono/oidc-auth", () => ({ revokeSession: async () => {}, })); -// Mock verifyAccessToken from oauth.service -mock.module("../../src/server/services/oauth.service", () => ({ - verifyAccessToken: mock(() => Promise.resolve(null)), -})); - // Import routes AFTER mocks const { authRoutes } = await import("../../src/server/routes/auth.ts"); diff --git a/tests/routes/images.test.ts b/tests/routes/images.test.ts index 31ba263..e8ba7c3 100644 --- a/tests/routes/images.test.ts +++ b/tests/routes/images.test.ts @@ -2,27 +2,34 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Hono } from "hono"; import { createTestDb } from "../helpers/db.ts"; -// Mock storage service before importing routes -const mockUploadImage = mock(() => Promise.resolve()); -mock.module("../../src/server/services/storage.service", () => ({ - uploadImage: mockUploadImage, - deleteImage: mock(() => Promise.resolve()), - getImageUrl: mock(() => Promise.resolve("https://s3.example.com/test.png")), - withImageUrl: mock((r: any) => Promise.resolve({ ...r, imageUrl: null })), - withImageUrls: mock((rs: any[]) => - Promise.resolve(rs.map((r) => ({ ...r, imageUrl: null }))), - ), +// Mock @aws-sdk/* to prevent real S3 connections — avoids module-level mock +// contamination of storage.service.test.ts (storage.service uses dynamic imports). +const mockSend = mock(() => Promise.resolve({})); +mock.module("@aws-sdk/client-s3", () => ({ + S3Client: class MockS3Client { + send = mockSend; + }, + PutObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, + DeleteObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, + GetObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, })); - -// Also mock image service for from-url test -const mockFetchImageFromUrl = mock(() => - Promise.resolve({ - filename: "test.png", - sourceUrl: "https://example.com/img.png", - }), -); -mock.module("../../src/server/services/image.service", () => ({ - fetchImageFromUrl: mockFetchImageFromUrl, +mock.module("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: mock(() => Promise.resolve("https://example.com/test.png")), })); const { imageRoutes } = await import("../../src/server/routes/images.ts"); @@ -38,8 +45,7 @@ beforeEach(async () => { await next(); }); app.route("/api/images", imageRoutes); - mockUploadImage.mockClear(); - mockFetchImageFromUrl.mockClear(); + mockSend.mockClear(); }); describe("POST /api/images/from-url", () => { @@ -103,6 +109,6 @@ describe("POST /api/images", () => { expect(res.status).toBe(201); const json = await res.json(); expect(json.filename).toMatch(/^\d+-[\w-]+\.png$/); - expect(mockUploadImage).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/services/image.service.test.ts b/tests/services/image.service.test.ts index a356a9e..54eda64 100644 --- a/tests/services/image.service.test.ts +++ b/tests/services/image.service.test.ts @@ -9,10 +9,34 @@ import { } from "bun:test"; import type { Server } from "bun"; -// Mock the storage service before importing image service -const mockUploadImage = mock(() => Promise.resolve()); -mock.module("../../src/server/services/storage.service", () => ({ - uploadImage: mockUploadImage, +// Mock @aws-sdk/* so storage.service (used by image.service) doesn't make real +// S3 calls — avoids contaminating storage.service.test.ts with a module-level mock. +const mockSend = mock(() => Promise.resolve({})); +mock.module("@aws-sdk/client-s3", () => ({ + S3Client: class MockS3Client { + send = mockSend; + }, + PutObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, + DeleteObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, + GetObjectCommand: class { + input: Record; + constructor(input: Record) { + this.input = input; + } + }, +})); +mock.module("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: mock(() => Promise.resolve("https://example.com/test.png")), })); const { fetchImageFromUrl } = await import( @@ -59,7 +83,7 @@ afterAll(() => { describe("Image Service", () => { beforeEach(() => { - mockUploadImage.mockClear(); + mockSend.mockClear(); }); describe("fetchImageFromUrl", () => { @@ -70,13 +94,14 @@ describe("Image Service", () => { expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/); expect(result.sourceUrl).toBe(imageUrl); - // Verify uploadImage was called with correct args - expect(mockUploadImage).toHaveBeenCalledTimes(1); - const [buffer, filename, contentType] = mockUploadImage.mock - .calls[0] as unknown[]; - expect(buffer).toBeInstanceOf(Buffer); - expect(filename).toBe(result.filename); - expect(contentType).toBe("image/png"); + // Verify S3 PutObjectCommand was issued with correct args + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0] as { + input: Record; + }; + expect(command.input.Body).toBeInstanceOf(Buffer); + expect(command.input.Key).toBe(result.filename); + expect(command.input.ContentType).toBe("image/png"); }); it("rejects non-image content type", async () => {