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).
|
// 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,
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
region: process.env.S3_REGION ?? "us-east-1",
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -17,18 +19,15 @@ const s3 = new S3Client({
|
|||||||
},
|
},
|
||||||
forcePathStyle: true, // REQUIRED for Garage and most S3-compatible services
|
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(
|
export async function uploadImage(
|
||||||
buffer: Buffer | ArrayBuffer,
|
buffer: Buffer | ArrayBuffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
|
||||||
|
const s3 = await createS3Client();
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -40,6 +39,8 @@ export async function uploadImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteImage(filename: string): Promise<void> {
|
export async function deleteImage(filename: string): Promise<void> {
|
||||||
|
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
||||||
|
const s3 = await createS3Client();
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
@@ -49,6 +50,9 @@ export async function deleteImage(filename: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getImageUrl(filename: string): Promise<string> {
|
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({
|
const command = new GetObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: filename,
|
Key: filename,
|
||||||
|
|||||||
@@ -12,10 +12,17 @@ mock.module("@hono/oidc-auth", () => ({
|
|||||||
revokeSession: async () => {},
|
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));
|
const mockVerifyAccessToken = mock(() => Promise.resolve(false));
|
||||||
mock.module("../../src/server/services/oauth.service", () => ({
|
mock.module("../../src/server/services/oauth.service", () => ({
|
||||||
verifyAccessToken: mockVerifyAccessToken,
|
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
|
// Import middleware AFTER mocks are set up
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ mock.module("@hono/oidc-auth", () => ({
|
|||||||
revokeSession: async () => {},
|
revokeSession: async () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock verifyAccessToken from oauth.service
|
|
||||||
mock.module("../../src/server/services/oauth.service", () => ({
|
|
||||||
verifyAccessToken: mock(() => Promise.resolve(null)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Import routes AFTER mocks
|
// Import routes AFTER mocks
|
||||||
const { authRoutes } = await import("../../src/server/routes/auth.ts");
|
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 { Hono } from "hono";
|
||||||
import { createTestDb } from "../helpers/db.ts";
|
import { createTestDb } from "../helpers/db.ts";
|
||||||
|
|
||||||
// Mock storage service before importing routes
|
// Mock @aws-sdk/* to prevent real S3 connections — avoids module-level mock
|
||||||
const mockUploadImage = mock(() => Promise.resolve());
|
// contamination of storage.service.test.ts (storage.service uses dynamic imports).
|
||||||
mock.module("../../src/server/services/storage.service", () => ({
|
const mockSend = mock(() => Promise.resolve({}));
|
||||||
uploadImage: mockUploadImage,
|
mock.module("@aws-sdk/client-s3", () => ({
|
||||||
deleteImage: mock(() => Promise.resolve()),
|
S3Client: class MockS3Client {
|
||||||
getImageUrl: mock(() => Promise.resolve("https://s3.example.com/test.png")),
|
send = mockSend;
|
||||||
withImageUrl: mock((r: any) => Promise.resolve({ ...r, imageUrl: null })),
|
},
|
||||||
withImageUrls: mock((rs: any[]) =>
|
PutObjectCommand: class {
|
||||||
Promise.resolve(rs.map((r) => ({ ...r, imageUrl: null }))),
|
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", () => ({
|
||||||
// Also mock image service for from-url test
|
getSignedUrl: mock(() => Promise.resolve("https://example.com/test.png")),
|
||||||
const mockFetchImageFromUrl = mock(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
filename: "test.png",
|
|
||||||
sourceUrl: "https://example.com/img.png",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
mock.module("../../src/server/services/image.service", () => ({
|
|
||||||
fetchImageFromUrl: mockFetchImageFromUrl,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { imageRoutes } = await import("../../src/server/routes/images.ts");
|
const { imageRoutes } = await import("../../src/server/routes/images.ts");
|
||||||
@@ -38,8 +45,7 @@ beforeEach(async () => {
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
app.route("/api/images", imageRoutes);
|
app.route("/api/images", imageRoutes);
|
||||||
mockUploadImage.mockClear();
|
mockSend.mockClear();
|
||||||
mockFetchImageFromUrl.mockClear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/images/from-url", () => {
|
describe("POST /api/images/from-url", () => {
|
||||||
@@ -103,6 +109,6 @@ describe("POST /api/images", () => {
|
|||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
expect(json.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
expect(json.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
||||||
expect(mockUploadImage).toHaveBeenCalledTimes(1);
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,10 +9,34 @@ import {
|
|||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
import type { Server } from "bun";
|
import type { Server } from "bun";
|
||||||
|
|
||||||
// Mock the storage service before importing image service
|
// Mock @aws-sdk/* so storage.service (used by image.service) doesn't make real
|
||||||
const mockUploadImage = mock(() => Promise.resolve());
|
// S3 calls — avoids contaminating storage.service.test.ts with a module-level mock.
|
||||||
mock.module("../../src/server/services/storage.service", () => ({
|
const mockSend = mock(() => Promise.resolve({}));
|
||||||
uploadImage: mockUploadImage,
|
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(
|
const { fetchImageFromUrl } = await import(
|
||||||
@@ -59,7 +83,7 @@ afterAll(() => {
|
|||||||
|
|
||||||
describe("Image Service", () => {
|
describe("Image Service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUploadImage.mockClear();
|
mockSend.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetchImageFromUrl", () => {
|
describe("fetchImageFromUrl", () => {
|
||||||
@@ -70,13 +94,14 @@ describe("Image Service", () => {
|
|||||||
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/);
|
||||||
expect(result.sourceUrl).toBe(imageUrl);
|
expect(result.sourceUrl).toBe(imageUrl);
|
||||||
|
|
||||||
// Verify uploadImage was called with correct args
|
// Verify S3 PutObjectCommand was issued with correct args
|
||||||
expect(mockUploadImage).toHaveBeenCalledTimes(1);
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||||
const [buffer, filename, contentType] = mockUploadImage.mock
|
const command = mockSend.mock.calls[0][0] as {
|
||||||
.calls[0] as unknown[];
|
input: Record<string, unknown>;
|
||||||
expect(buffer).toBeInstanceOf(Buffer);
|
};
|
||||||
expect(filename).toBe(result.filename);
|
expect(command.input.Body).toBeInstanceOf(Buffer);
|
||||||
expect(contentType).toBe("image/png");
|
expect(command.input.Key).toBe(result.filename);
|
||||||
|
expect(command.input.ContentType).toBe("image/png");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-image content type", async () => {
|
it("rejects non-image content type", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user