Files
EpicNext-Cms/src/lib/services/furni-import-repair.test.ts
T
Simo 8a919f85b2
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m21s
CI / e2e (push) Successful in 21s
Preserve furniture revisions when downloading source assets
2026-09-05 14:29:48 +02:00

180 lines
5.5 KiB
TypeScript

// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
official: vi.fn(),
download: vi.fn(),
execute: vi.fn(),
select: vi.fn(),
stat: vi.fn(),
conflict: vi.fn(),
build: vi.fn(),
append: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { select: mocks.select, execute: mocks.execute },
ItemsBase: {
id: "id",
itemName: "item_name",
spriteId: "sprite_id",
type: "type",
},
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: () => true,
promises: {
stat: mocks.stat,
readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)),
},
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
DEFAULT_FURNI_NITRO_DIR: "/nitro",
getFurniAssetWriteTargets: async () => ({
swfDir: "/swf",
nitroDir: "/nitro",
iconDir: "/icons",
mirrorDirs: [],
}),
}));
vi.mock("@/lib/services/furni-data", () => ({
findFurniDataIdConflict: mocks.conflict,
buildFurniEntry: mocks.build,
appendFurniEntry: mocks.append,
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: mocks.official,
}));
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
getHabboGamedataHotel: async () => "it",
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
tryDownloadCandidates: mocks.download,
}));
vi.mock("@/lib/services/swf-to-nitro", () => ({
parseNitroBundle: () => ({ json: {} }),
extractIconFromSwfAsync: async () => null,
}));
import { importSingleFurni } from "./furni-import";
const input = {
id: 42,
classname: "recycler_scrapbookA",
name: "Scrapbook",
description: "Book",
type: "flooritem",
revision: 1,
category: "unknown",
repairExisting: true,
};
const dialect = new MySqlDialect();
function queries() {
return mocks.execute.mock.calls.map(([query]) => dialect.sqlToQuery(query));
}
describe("completion of existing furniture", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.official.mockResolvedValue(null);
mocks.download.mockResolvedValue(false);
const rows = [
[{ id: 2000037257, spriteId: 9001, type: "s" }],
[{ itemName: input.classname }],
];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => rows.shift() }) }),
}));
mocks.stat.mockResolvedValue({ size: 256 });
mocks.conflict.mockResolvedValue(null);
mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]);
mocks.build.mockImplementation((value) => value);
});
it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => {
const result = await importSingleFurni(input);
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037257);
expect(mocks.conflict).toHaveBeenCalledWith(9001, input.classname);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({ id: 9001, spriteId: 9001 }),
);
expect(mocks.append).toHaveBeenCalled();
expect(
queries().every((query) => query.sql.trim().startsWith("SELECT")),
).toBe(true);
expect(queries()[0].params).toContain("2000037257");
});
it("never deletes existing SQL when the nitro cannot be recovered", async () => {
mocks.stat.mockRejectedValue(new Error("missing"));
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(result.error).toContain("preserved");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
it("rejects an occupied furnidata sprite before any mutation", async () => {
mocks.conflict.mockResolvedValue("other_furniture");
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
});
it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata", async () => {
vi.clearAllMocks();
const selections = [[], [{ itemName: "recycler_kintsugi" }]];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => selections.shift() }) }),
}));
mocks.conflict.mockResolvedValue(null);
mocks.stat.mockResolvedValue({ size: 256 });
mocks.build.mockImplementation((value) => value);
mocks.execute.mockImplementation(async (query) => {
const { sql, params } = dialect.sqlToQuery(query);
if (sql.includes("MAX(")) return [[{ maximum: 2000037257 }], []];
if (sql.includes("SELECT item_name"))
return [
params[0] === 16940 ? [{ item_name: "recycler_kintsugi" }] : [],
[],
];
if (sql.includes("SELECT id, page_id"))
return [[{ id: 123, page_id: "15" }], []];
return [[], []];
});
mocks.official.mockResolvedValue({ revision: 73706, xdim: 1, ydim: 1 });
const result = await importSingleFurni({
...input,
revision: 0,
id: 16940,
classname: "recycler_kintsugiB",
});
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037258);
expect(mocks.download).toHaveBeenCalledWith(
expect.arrayContaining([
"https://images.habbo.com/dcr/hof_furni/73706/recycler_kintsugiB.swf",
]),
expect.any(String),
"swf",
);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({
id: 2000037258,
spriteId: 2000037258,
classname: "recycler_kintsugiB",
}),
);
expect(
queries()
.find((q) => q.sql.includes("INSERT INTO items_base"))
?.params.slice(0, 2),
).toEqual([2000037258, 2000037258]);
expect(
queries().find((q) => q.sql.includes("SELECT id, page_id"))?.params,
).toContain("2000037258");
expect(result.warnings.join(" ")).toContain("16940");
});