From 5ce3f92a78350e03fbb7abd6f13a18685da2f0f3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 5 Apr 2026 12:20:31 +0200 Subject: [PATCH] feat(17-02): refactor image service and routes to use S3 storage service - Replace Bun.write/mkdir with uploadImage() from storage.service - Remove uploadsDir parameter from fetchImageFromUrl - Update tests to mock storage service instead of checking filesystem --- src/server/routes/images.ts | 10 ++-- src/server/services/image.service.ts | 9 ++-- tests/routes/images.test.ts | 72 +++++++++++++++++++++++++++- tests/services/image.service.test.ts | 65 +++++++++++++++---------- 4 files changed, 117 insertions(+), 39 deletions(-) diff --git a/src/server/routes/images.ts b/src/server/routes/images.ts index 6446184..2c03c9b 100644 --- a/src/server/routes/images.ts +++ b/src/server/routes/images.ts @@ -1,10 +1,9 @@ import { randomUUID } from "node:crypto"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; import { fetchImageFromUrl } from "../services/image.service"; +import { uploadImage } from "../services/storage.service"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_SIZE = 5 * 1024 * 1024; // 5MB @@ -56,12 +55,9 @@ app.post("/", async (c) => { file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1]; const filename = `${Date.now()}-${randomUUID()}.${ext}`; - // Ensure uploads directory exists - await mkdir("uploads", { recursive: true }); - - // Write file + // Upload to object storage const buffer = await file.arrayBuffer(); - await Bun.write(join("uploads", filename), buffer); + await uploadImage(Buffer.from(buffer), filename, file.type); return c.json({ filename }, 201); }); diff --git a/src/server/services/image.service.ts b/src/server/services/image.service.ts index c568518..249f2d9 100644 --- a/src/server/services/image.service.ts +++ b/src/server/services/image.service.ts @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import { uploadImage } from "./storage.service"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_SIZE = 5 * 1024 * 1024; // 5MB @@ -13,7 +12,6 @@ interface FetchImageResult { export async function fetchImageFromUrl( url: string, - uploadsDir = "uploads", ): Promise { // Validate URL format let parsedUrl: URL; @@ -74,9 +72,8 @@ export async function fetchImageFromUrl( const ext = contentType === "image/jpeg" ? "jpg" : contentType.split("/")[1]; const filename = `${Date.now()}-${randomUUID()}.${ext}`; - // Ensure directory exists and write - await mkdir(uploadsDir, { recursive: true }); - await Bun.write(join(uploadsDir, filename), buffer); + // Upload to object storage + await uploadImage(Buffer.from(buffer), filename, contentType); return { filename, sourceUrl: url }; } diff --git a/tests/routes/images.test.ts b/tests/routes/images.test.ts index 00a0fff..f39eaba 100644 --- a/tests/routes/images.test.ts +++ b/tests/routes/images.test.ts @@ -1,8 +1,29 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Hono } from "hono"; -import { imageRoutes } from "../../src/server/routes/images"; 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 }))), + ), +})); + +// 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, +})); + +const { imageRoutes } = await import("../../src/server/routes/images.ts"); + let app: Hono; beforeEach(async () => { @@ -14,6 +35,8 @@ beforeEach(async () => { await next(); }); app.route("/api/images", imageRoutes); + mockUploadImage.mockClear(); + mockFetchImageFromUrl.mockClear(); }); describe("POST /api/images/from-url", () => { @@ -35,3 +58,48 @@ describe("POST /api/images/from-url", () => { expect(res.status).toBe(400); }); }); + +describe("POST /api/images", () => { + test("returns 400 when no image file provided", async () => { + const formData = new FormData(); + const res = await app.request("/api/images", { + method: "POST", + body: formData, + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toBe("No image file provided"); + }); + + test("returns 400 for invalid file type", async () => { + const formData = new FormData(); + formData.append( + "image", + new Blob(["test"], { type: "text/plain" }), + "test.txt", + ); + const res = await app.request("/api/images", { + method: "POST", + body: formData, + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toContain("Invalid file type"); + }); + + test("uploads valid image and calls storage service", async () => { + const formData = new FormData(); + const pngBlob = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { + type: "image/png", + }); + formData.append("image", pngBlob, "test.png"); + const res = await app.request("/api/images", { + method: "POST", + body: formData, + }); + expect(res.status).toBe(201); + const json = await res.json(); + expect(json.filename).toMatch(/^\d+-[\w-]+\.png$/); + expect(mockUploadImage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/services/image.service.test.ts b/tests/services/image.service.test.ts index 2251a57..3922314 100644 --- a/tests/services/image.service.test.ts +++ b/tests/services/image.service.test.ts @@ -1,18 +1,32 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import { existsSync, rmSync } from "node:fs"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; import type { Server } from "bun"; -import { fetchImageFromUrl } from "../../src/server/services/image.service.ts"; -const TEST_UPLOADS_DIR = "test-uploads"; +// Mock the storage service before importing image service +const mockUploadImage = mock(() => Promise.resolve()); +mock.module("../../src/server/services/storage.service", () => ({ + uploadImage: mockUploadImage, +})); + +const { fetchImageFromUrl } = await import( + "../../src/server/services/image.service.ts" +); // 1x1 transparent PNG (smallest valid PNG) const TINY_PNG = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, - 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, - 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, - 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, - 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, - 0x82, + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); let server: Server; @@ -44,45 +58,48 @@ afterAll(() => { }); describe("Image Service", () => { - afterEach(() => { - if (existsSync(TEST_UPLOADS_DIR)) { - rmSync(TEST_UPLOADS_DIR, { recursive: true }); - } + beforeEach(() => { + mockUploadImage.mockClear(); }); describe("fetchImageFromUrl", () => { - it("fetches a valid image URL and saves to disk", async () => { + it("fetches a valid image URL and uploads to storage", async () => { const imageUrl = `${baseUrl}/image.png`; - const result = await fetchImageFromUrl(imageUrl, TEST_UPLOADS_DIR); + const result = await fetchImageFromUrl(imageUrl); expect(result.filename).toMatch(/^\d+-[\w-]+\.png$/); expect(result.sourceUrl).toBe(imageUrl); - const filePath = `${TEST_UPLOADS_DIR}/${result.filename}`; - expect(existsSync(filePath)).toBe(true); + // 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"); }); it("rejects non-image content type", async () => { await expect( - fetchImageFromUrl(`${baseUrl}/page.html`, TEST_UPLOADS_DIR), + fetchImageFromUrl(`${baseUrl}/page.html`), ).rejects.toThrow("Invalid content type"); }); it("rejects invalid URL format", async () => { - await expect( - fetchImageFromUrl("not-a-url", TEST_UPLOADS_DIR), - ).rejects.toThrow("Invalid URL format"); + await expect(fetchImageFromUrl("not-a-url")).rejects.toThrow( + "Invalid URL format", + ); }); it("rejects non-HTTP protocols", async () => { await expect( - fetchImageFromUrl("ftp://example.com/image.png", TEST_UPLOADS_DIR), + fetchImageFromUrl("ftp://example.com/image.png"), ).rejects.toThrow("URL must use HTTP or HTTPS"); }); it("rejects 404 responses", async () => { await expect( - fetchImageFromUrl(`${baseUrl}/missing.jpg`, TEST_UPLOADS_DIR), + fetchImageFromUrl(`${baseUrl}/missing.jpg`), ).rejects.toThrow("HTTP 404"); }); });