From 86cd43def922e2bf31fa14f5f6188faddee7f5ef Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 2 Aug 2026 13:24:15 +0200 Subject: [PATCH] fix: mirror manual furni uploads to live assets --- src/lib/services/upload-import.test.ts | 139 +++++++++++++++++++++++++ src/lib/services/upload-import.ts | 56 +++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/lib/services/upload-import.test.ts diff --git a/src/lib/services/upload-import.test.ts b/src/lib/services/upload-import.test.ts new file mode 100644 index 00000000..5f12e342 --- /dev/null +++ b/src/lib/services/upload-import.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment node +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + appendFurniEntryMock, + assetTargets, + dbExecuteMock, + getOrCreateCategoryPageMock, +} = vi.hoisted(() => ({ + appendFurniEntryMock: vi.fn(), + assetTargets: { + swfDir: "", + iconDir: "", + nitroDir: "", + mirrorDirs: [] as Array<{ + swfDir: string; + iconDir: string; + nitroDir: string; + }>, + }, + dbExecuteMock: vi.fn(), + getOrCreateCategoryPageMock: vi.fn(), +})); + +vi.mock("drizzle-orm", () => ({ + eq: vi.fn(() => ({})), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }), +})); + +vi.mock("@/lib/db", () => ({ + ItemsBase: { id: "id", itemName: "item_name" }, + db: { + execute: dbExecuteMock, + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [], + }), + }), + }), + }, +})); + +vi.mock("@/lib/services/furni-asset-dirs", () => ({ + getFurniAssetDirs: vi.fn(async () => assetTargets), + getFurniAssetWriteTargets: vi.fn(async () => assetTargets), +})); + +vi.mock("@/lib/services/furni-data", () => ({ + appendFurniEntry: appendFurniEntryMock, + buildFurniEntry: vi.fn(() => ({ id: 501 })), +})); + +vi.mock("@/lib/services/furni-import", () => ({ + allocateCatalogItemId: vi.fn( + async (insertFn: (nextId: number) => Promise) => insertFn(701), + ), + autoPriceFurni: vi.fn(() => ({ credits: 3, points: 0, pointsType: 0 })), + ensureDirectories: vi.fn(async () => { + const dirs = [ + assetTargets.swfDir, + assetTargets.iconDir, + assetTargets.nitroDir, + ...assetTargets.mirrorDirs.flatMap((target) => [ + target.swfDir, + target.iconDir, + target.nitroDir, + ]), + ]; + await Promise.all(dirs.map((dir) => fs.mkdir(dir, { recursive: true }))); + }), + getOrCreateCategoryPage: getOrCreateCategoryPageMock, +})); + +vi.mock("@/lib/services/swf/nitro-builder", () => ({ + parseNitroBundle: vi.fn(() => ({ json: { xdim: 1, ydim: 1 } })), +})); + +import { uploadSingleFurni } from "./upload-import"; + +describe("uploadSingleFurni live asset mirrors", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "upload-furni-")); + assetTargets.swfDir = path.join(tempDir, "cms", "swf"); + assetTargets.iconDir = path.join(tempDir, "cms", "icons"); + assetTargets.nitroDir = path.join(tempDir, "cms", "furniture"); + assetTargets.mirrorDirs = [ + { + swfDir: assetTargets.swfDir, + iconDir: path.join(tempDir, "Gamedata", "icons"), + nitroDir: path.join(tempDir, "Gamedata", "bundled", "furniture"), + }, + ]; + dbExecuteMock.mockReset(); + dbExecuteMock + .mockResolvedValueOnce([[{ next: 501 }], undefined]) + .mockResolvedValue(undefined); + getOrCreateCategoryPageMock.mockResolvedValue("imp_other"); + appendFurniEntryMock.mockResolvedValue(undefined); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it("copies the Nitro bundle and icon into the directories served by production", async () => { + const nitroBuffer = Buffer.from("nitro-bundle"); + const iconBuffer = Buffer.from("png-icon"); + + const result = await uploadSingleFurni({ + classname: "chair", + name: "Chair", + itemType: "s", + nitroBuffer, + iconBuffer, + }); + + expect(result.ok).toBe(true); + await expect( + fs.readFile( + path.join(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"), + ), + ).resolves.toEqual(nitroBuffer); + await expect( + fs.readFile( + path.join(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"), + ), + ).resolves.toEqual(iconBuffer); + }); +}); diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 10ad2b9c..0f019874 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -2,7 +2,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, sql } from "drizzle-orm"; import { db, ItemsBase } from "@/lib/db"; -import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; +import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data"; import { allocateCatalogItemId, @@ -43,6 +43,36 @@ const MIGRATIONS_DIR = path.resolve( let itemsBaseIdAllocChain: Promise = Promise.resolve(); +function pathKey(value: string): string { + const normalized = path.normalize(value); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +async function mirrorUploadedAsset( + sourcePath: string, + fileName: string, + dirs: string[], + warnings: string[], + copiedPaths: string[], +): Promise { + const sourceKey = pathKey(sourcePath); + const seen = new Set(); + for (const dir of dirs) { + const targetPath = path.join(/*turbopackIgnore: true*/ dir, fileName); + const targetKey = pathKey(targetPath); + if (targetKey === sourceKey || seen.has(targetKey)) continue; + seen.add(targetKey); + try { + await fs.copyFile(sourcePath, targetPath); + copiedPaths.push(targetPath); + } catch (err) { + warnings.push( + `asset mirror failed (${targetPath}): ${(err as Error).message}`, + ); + } + } +} + async function allocateItemsBaseId( insertFn: (nextId: number) => Promise, ): Promise { @@ -200,7 +230,8 @@ export async function uploadSingleFurni(params: { } await ensureDirectories(); - const { iconDir, nitroDir } = await getFurniAssetDirs(); + const assetTargets = await getFurniAssetWriteTargets(); + const { iconDir, nitroDir } = assetTargets; const nitroFileName = `${classname}.nitro`; const nitroPath = path.join( @@ -225,6 +256,24 @@ export async function uploadSingleFurni(params: { await fs.writeFile(iconPath, iconBuffer); } + const mirroredPaths: string[] = []; + await mirrorUploadedAsset( + nitroPath, + nitroFileName, + assetTargets.mirrorDirs.map((target) => target.nitroDir), + warnings, + mirroredPaths, + ); + if (iconPath && iconFileName) { + await mirrorUploadedAsset( + iconPath, + iconFileName, + assetTargets.mirrorDirs.map((target) => target.iconDir), + warnings, + mirroredPaths, + ); + } + const xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1); const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1); @@ -248,6 +297,9 @@ export async function uploadSingleFurni(params: { } catch (err) { await fs.unlink(nitroPath).catch(() => {}); if (iconPath) await fs.unlink(iconPath).catch(() => {}); + await Promise.all( + mirroredPaths.map((file) => fs.unlink(file).catch(() => {})), + ); return { ok: false, classname,