From edd0fc58ebe37f28059c91c67ce3e945a017e28d Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 7 May 2026 20:00:56 +0800 Subject: [PATCH] feat(data): avatar file store helper Co-Authored-By: Claude Sonnet 4.6 --- src/data/avatars.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++ src/data/avatars.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/data/avatars.test.ts create mode 100644 src/data/avatars.ts diff --git a/src/data/avatars.test.ts b/src/data/avatars.test.ts new file mode 100644 index 0000000..5c0856a --- /dev/null +++ b/src/data/avatars.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAvatarStore } from "./avatars.js"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "avatar-test-")); +}); + +describe("createAvatarStore", () => { + it("write returns a relative path under the store dir", () => { + const store = createAvatarStore(dir); + const buf = Buffer.from("fake-png"); + const rel = store.write("bot-1", "image/png", buf); + expect(rel).toBe("bot-1.png"); + expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true); + }); + + it("write picks correct extension for jpeg / webp", () => { + const store = createAvatarStore(dir); + expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg"); + expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp"); + }); + + it("write rejects unsupported MIME types", () => { + const store = createAvatarStore(dir); + expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i); + }); + + it("read returns the bytes for an existing file", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("hello")); + const buf = store.read("bot-1.png"); + expect(buf?.equals(Buffer.from("hello"))).toBe(true); + }); + + it("read returns null when path is missing", () => { + const store = createAvatarStore(dir); + expect(store.read("missing.png")).toBeNull(); + }); + + it("remove deletes the file (idempotent)", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("x")); + store.remove("bot-1.png"); + expect(existsSync(join(dir, "bot-1.png"))).toBe(false); + expect(() => store.remove("bot-1.png")).not.toThrow(); + }); + + it("write replaces any existing file for the same botId regardless of old extension", () => { + const store = createAvatarStore(dir); + store.write("bot-1", "image/png", Buffer.from("old")); + const rel = store.write("bot-1", "image/jpeg", Buffer.from("new")); + expect(rel).toBe("bot-1.jpg"); + expect(existsSync(join(dir, "bot-1.png"))).toBe(false); + expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true); + }); +}); diff --git a/src/data/avatars.ts b/src/data/avatars.ts new file mode 100644 index 0000000..6d7629b --- /dev/null +++ b/src/data/avatars.ts @@ -0,0 +1,43 @@ +import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +}; + +export interface AvatarStore { + /** Returns the relative path written (e.g. "bot-1.png"). */ + write(botId: string, mime: string, buffer: Buffer): string; + read(relPath: string): Buffer | null; + remove(relPath: string): void; + getDir(): string; +} + +export function createAvatarStore(dir: string): AvatarStore { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + return { + write(botId, mime, buffer) { + const ext = MIME_TO_EXT[mime]; + if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`); + for (const name of readdirSync(dir)) { + if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true }); + } + const rel = `${botId}.${ext}`; + writeFileSync(join(dir, rel), buffer); + return rel; + }, + read(relPath) { + const full = join(dir, relPath); + if (!existsSync(full)) return null; + return readFileSync(full); + }, + remove(relPath) { + rmSync(join(dir, relPath), { force: true }); + }, + getDir() { + return dir; + }, + }; +}