// @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; type AnyFn = (...args: any[]) => any; const { executeFn, readdirFn, readFileFn, statFn, unlinkFn, writeFileFn, mkdirFn, existsFn, parseNitroBundle, getTargetsFn, listSourcesFn, downloadFileFn, } = vi.hoisted(() => ({ executeFn: vi.fn(async () => [ [ { item_name: "chair", public_name: "Chair" }, { item_name: "broken_thing", public_name: "Broken Thing" }, { item_name: "xmas_*1", public_name: "Xmas" }, ], [], ]), readdirFn: vi.fn(), readFileFn: vi.fn(), statFn: vi.fn(), unlinkFn: vi.fn(async () => {}), writeFileFn: vi.fn(async () => {}), mkdirFn: vi.fn(async () => {}), existsFn: vi.fn(() => false), parseNitroBundle: vi.fn(), getTargetsFn: vi.fn(async () => ({ nitroDir: "/assets/nitro", swfDir: "/assets/swf", iconDir: "/assets/icons", mirrorDirs: [], })), listSourcesFn: vi.fn(async () => [ { id: "test", name: "Test Source", furnidataUrl: "https://src.test/gamedata/FurnitureData.json", nitroBaseUrl: "https://src.test/nitro", iconBaseUrl: "https://src.test/icons", hotel: "nl", }, ]), downloadFileFn: vi.fn(async () => ({ ok: true, size: 100 })), })); vi.mock("@/lib/db", () => ({ db: { execute: executeFn } })); vi.mock("@/lib/services/furni-asset-dirs", () => ({ getFurniAssetWriteTargets: getTargetsFn, })); vi.mock("@/lib/services/swf/nitro-builder", () => ({ parseNitroBundle })); vi.mock("@/lib/services/clone-sources", () => ({ listSources: listSourcesFn, DEFAULT_NITRO_SOURCES: [], })); vi.mock("@/lib/services/import/core/download", () => ({ downloadFile: downloadFileFn, })); vi.mock("node:fs", async (orig) => { const real = (await orig()) as typeof import("node:fs"); return { ...real, promises: { ...real.promises, readdir: readdirFn as AnyFn, readFile: readFileFn as AnyFn, stat: statFn as AnyFn, unlink: unlinkFn as AnyFn, writeFile: writeFileFn as AnyFn, mkdir: mkdirFn as AnyFn, }, existsSync: existsFn, }; }); import { autoCleanFakeNitros, deleteNitroCleanupFiles, repairBrokenNitros, scanFakeBrokenNitros, } from "@/lib/services/nitro-cleanup"; beforeEach(() => { vi.clearAllMocks(); readdirFn.mockReset(); readFileFn.mockReset(); parseNitroBundle.mockReset(); existsFn.mockReset(); getTargetsFn.mockReset(); listSourcesFn.mockReset(); downloadFileFn.mockReset(); unlinkFn.mockReset(); unlinkFn.mockImplementation(async () => {}); writeFileFn.mockReset(); writeFileFn.mockImplementation(async () => {}); mkdirFn.mockReset(); mkdirFn.mockImplementation(async () => {}); getTargetsFn.mockResolvedValue({ nitroDir: "/assets/nitro", swfDir: "/assets/swf", iconDir: "/assets/icons", mirrorDirs: [], }); listSourcesFn.mockResolvedValue([ { id: "test", name: "Test Source", furnidataUrl: "https://src.test/gamedata/FurnitureData.json", nitroBaseUrl: "https://src.test/nitro", iconBaseUrl: "https://src.test/icons", hotel: "nl", }, ]); downloadFileFn.mockResolvedValue({ ok: true, size: 100 }); statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 })); }); function mockReaddir(byDir: Record): void { readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []); } describe("scanFakeBrokenNitros", () => { it("classifies fake (no matching item) and broken (unparseable) bundles", async () => { mockReaddir({ "/assets/nitro": [ "chair.nitro", "fake_stuff.nitro", "broken_thing.nitro", "xmas_.nitro", ], }); readFileFn.mockImplementation(async (filePath: string) => Buffer.from(filePath.includes("broken") ? "broken-data" : "nitro-data"), ); parseNitroBundle.mockImplementation((buffer: Buffer) => { if (buffer.toString("utf-8").startsWith("broken")) { throw new Error("Not a valid .nitro bundle"); } return { json: {}, jsonFileName: "visualization.json", png: Buffer.alloc(0), pngFileName: "texture.png", }; }); const result = await scanFakeBrokenNitros(); expect(result.fake.map((e) => e.fileName)).toEqual(["fake_stuff.nitro"]); expect(result.broken.map((e) => e.fileName)).toEqual([ "broken_thing.nitro", ]); expect(result.broken[0].error).toBe("Not a valid .nitro bundle"); }); it("resolves star classnames to their base nitro stem", async () => { mockReaddir({ "/assets/nitro": ["xmas_.nitro"] }); readFileFn.mockResolvedValue(Buffer.from("nitro-data")); parseNitroBundle.mockReturnValue({ json: {}, jsonFileName: "visualization.json", png: Buffer.alloc(0), pngFileName: "texture.png", }); const result = await scanFakeBrokenNitros(); expect(result.fake).toEqual([]); expect(result.broken).toEqual([]); expect(result.total).toBe(1); }); it("marks an unparseable bundle whose item is also missing as fake", async () => { mockReaddir({ "/assets/nitro": ["ghost.nitro"] }); const result = await scanFakeBrokenNitros(); expect(result.fake[0].fileName).toBe("ghost.nitro"); expect(result.broken).toEqual([]); }); it("reports orphaned swf and icon files alongside nitro leftovers", async () => { mockReaddir({ "/assets/nitro": ["chair.nitro", "ghost.nitro"], "/assets/swf": ["chair.swf", "phantom.swf"], "/assets/icons": ["chair.gif", "phantom.gif", "phantom.png"], }); const result = await scanFakeBrokenNitros(); expect(result.fake.map((e) => e.fileName)).toEqual(["ghost.nitro"]); expect(result.orphanedSwf.map((e) => e.fileName)).toEqual(["phantom.swf"]); expect(result.orphanedIcon.map((e) => e.fileName)).toEqual([ "phantom.gif", "phantom.png", ]); expect(result.total).toBe(7); }); it("records the largest size and latest mtime across mirrored copies", async () => { getTargetsFn.mockResolvedValue({ nitroDir: "/a/nitro", swfDir: "/a/swf", iconDir: "/a/icons", mirrorDirs: [ { nitroDir: "/b/nitro", swfDir: "/b/swf", iconDir: "/b/icons" }, ], }); mockReaddir({ "/a/nitro": ["ghost.nitro"], "/b/nitro": ["ghost.nitro"] }); statFn.mockImplementation(async (filePath: string) => filePath.startsWith("/a/") ? { size: 100, mtimeMs: 1000 } : { size: 900, mtimeMs: 2000 }, ); readFileFn.mockResolvedValue(Buffer.from("nitro-data")); parseNitroBundle.mockReturnValue({}); const result = await scanFakeBrokenNitros(); expect(result.total).toBe(1); expect(result.fake[0].dirs).toEqual(["/a/nitro", "/b/nitro"]); expect(result.fake[0].size).toBe(900); expect(result.fake[0].lastModified).toBe(2000); }); }); describe("deleteNitroCleanupFiles", () => { beforeEach(() => { getTargetsFn.mockResolvedValue({ nitroDir: "/a/nitro", swfDir: "/a/swf", iconDir: "/a/icons", mirrorDirs: [ { nitroDir: "/b/nitro", swfDir: "/b/swf", iconDir: "/b/icons" }, ], }); existsFn.mockImplementation((filePath: string) => filePath.includes("chair"), ); }); it("removes each file from every directory where it exists", async () => { const result = await deleteNitroCleanupFiles([ "chair.nitro", "missing.nitro", ]); expect(result.deleted).toBe(1); expect(result.copiesRemoved).toBe(2); expect(unlinkFn).toHaveBeenCalledTimes(2); expect(unlinkFn).toHaveBeenCalledWith("/a/nitro/chair.nitro"); expect(unlinkFn).toHaveBeenCalledWith("/b/nitro/chair.nitro"); expect(result.errors).toEqual([]); const chair = result.files.find((f) => f.fileName === "chair.nitro"); expect(chair?.deleted).toBe(true); expect(chair?.remaining).toEqual([]); }); it("keeps a file in remaining dirs when an unlink fails", async () => { unlinkFn.mockImplementation(async (filePath: string) => { if (filePath.startsWith("/b/")) throw new Error("locked"); }); const result = await deleteNitroCleanupFiles(["chair.nitro"]); expect(result.deleted).toBe(0); expect(result.copiesRemoved).toBe(1); expect(result.errors.some((e) => e.includes("locked"))).toBe(true); const chair = result.files.find((f) => f.fileName === "chair.nitro"); expect(chair?.deleted).toBe(false); expect(chair?.remaining).toEqual(["/b/nitro"]); }); it("rejects unsafe file names", async () => { const result = await deleteNitroCleanupFiles(["../evil.nitro"]); expect(result.deleted).toBe(0); expect(result.errors[0]).toContain("Invalid nitro file name"); expect(unlinkFn).not.toHaveBeenCalled(); }); it("deletes swf and icon files from their own directories", async () => { existsFn.mockImplementation((filePath: string) => filePath.includes("phantom"), ); const swfResult = await deleteNitroCleanupFiles(["phantom.swf"], "swf"); const iconResult = await deleteNitroCleanupFiles(["phantom.gif"], "icon"); expect(swfResult.copiesRemoved).toBe(2); expect(unlinkFn).toHaveBeenCalledWith("/a/swf/phantom.swf"); expect(unlinkFn).toHaveBeenCalledWith("/b/swf/phantom.swf"); expect(iconResult.copiesRemoved).toBe(2); expect(unlinkFn).toHaveBeenCalledWith("/a/icons/phantom.gif"); expect(unlinkFn).toHaveBeenCalledWith("/b/icons/phantom.gif"); }); it("rejects a swf name for the icon kind", async () => { const result = await deleteNitroCleanupFiles(["phantom.gif"], "swf"); expect(result.deleted).toBe(0); expect(result.errors[0]).toContain("Invalid swf file name"); expect(unlinkFn).not.toHaveBeenCalled(); }); }); describe("autoCleanFakeNitros", () => { it("removes only fake leftovers older than the age threshold", async () => { getTargetsFn.mockResolvedValue({ nitroDir: "/a/nitro", swfDir: "/a/swf", iconDir: "/a/icons", mirrorDirs: [], }); mockReaddir({ "/a/nitro": [ "old_fake.nitro", "chair.nitro", "recent_fake.nitro", "broken_thing.nitro", ], }); const now = Date.now(); statFn.mockImplementation(async (filePath: string) => ({ size: 10, mtimeMs: filePath.includes("old_fake") ? now - 60 * 24 * 60 * 60 * 1000 : now, })); existsFn.mockImplementation(() => true); readFileFn.mockResolvedValue(Buffer.from("broken-data")); parseNitroBundle.mockImplementation(() => { throw new Error("Not a valid .nitro bundle"); }); const result = await autoCleanFakeNitros(30); expect(result.deleted).toBe(1); expect(result.copiesRemoved).toBe(1); expect(unlinkFn).toHaveBeenCalledWith("/a/nitro/old_fake.nitro"); expect(result.skippedRecent).toBe(1); expect(result.skippedBroken).toBe(2); }); }); describe("repairBrokenNitros", () => { it("re-downloads valid bundles and writes them to every nitro dir", async () => { getTargetsFn.mockResolvedValue({ nitroDir: "/a/nitro", swfDir: "/a/swf", iconDir: "/a/icons", mirrorDirs: [], }); readFileFn.mockResolvedValue(Buffer.from("nitro-data")); parseNitroBundle.mockReturnValue({}); const result = await repairBrokenNitros(["broken_thing.nitro"]); expect(result.repaired).toBe(1); expect(result.failed).toBe(0); expect(downloadFileFn).toHaveBeenCalledWith( "https://src.test/nitro/broken_thing.nitro", expect.any(String), expect.objectContaining({ validate: "nitro" }), ); expect(writeFileFn).toHaveBeenCalledWith( expect.stringContaining("/a/nitro/broken_thing.nitro"), expect.any(Buffer), ); }); it("rejects unsafe file names without downloading", async () => { const result = await repairBrokenNitros(["../evil.nitro"]); expect(result.repaired).toBe(0); expect(result.failed).toBe(1); expect(downloadFileFn).not.toHaveBeenCalled(); }); it("fails when no source can provide the bundle", async () => { downloadFileFn.mockResolvedValue({ ok: false, size: 0, error: "HTTP 404" }); const result = await repairBrokenNitros(["broken_thing.nitro"]); expect(result.repaired).toBe(0); expect(result.failed).toBe(1); expect(result.errors[0]).toMatch(/could not re-obtain/); }); });