fix: resolve Bun mock isolation contamination across test files
- storage.service.ts: use dynamic import() inside each function so the current @aws-sdk mock is always picked up regardless of module load order - images.test.ts + image.service.test.ts: replace module-level storage.service mock with @aws-sdk/client-s3 mock to avoid contaminating storage.service.test.ts - routes/auth.test.ts: remove unnecessary oauth.service mock (no test uses verifyAccessToken) which was contaminating oauth.service.test.ts - middleware/auth.test.ts: complete oauth.service mock shape with all exports All 464 tests now pass in a single bun test run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
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).
|
||||
// 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 s3 = new S3Client({
|
||||
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
|
||||
const presignExpiry = Number.parseInt(
|
||||
process.env.S3_PRESIGN_EXPIRY ?? "3600",
|
||||
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: {
|
||||
@@ -17,18 +19,15 @@ const s3 = new S3Client({
|
||||
},
|
||||
forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services
|
||||
});
|
||||
|
||||
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
|
||||
const presignExpiry = Number.parseInt(
|
||||
process.env.S3_PRESIGN_EXPIRY ?? "3600",
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadImage(
|
||||
buffer: Buffer | ArrayBuffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
export async function getImageUrl(filename: string): Promise<string> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
DeleteObjectCommand: class {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
DeleteObjectCommand: class {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
},
|
||||
GetObjectCommand: class {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
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<string, unknown>;
|
||||
};
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user