Support Gitea configuration and safe Catalog Studio furniture completion
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 21s

This commit is contained in:
Simo committed 2026-09-05 13:33:40 +02:00
1 parent 26f071117b
commit 9f791ba284
22 files changed
+1120 -166

No files matched your search

@@ -0,0 +1,120 @@
// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
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: async () => null,
}));
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
getHabboGamedataHotel: async () => "it",
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
tryDownloadCandidates: async () => false,
}));
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();
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();
});
});