test: add unit tests for pure logic modules
Add 22 test files covering imager, soundtracks, browser-headers, source-keys (figure/pet/effect), effect-source, plus catalog-translations, catalog-layouts, client-translation-files, translations-utils, and various admin/services/helpers modules. Total test count increases by 120+.
This commit is contained in:
1 parent
11e8b06bf8
commit
82e8448f26
25 files changed
+1400
-304
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calcPagination,
|
||||
PER_PAGE_OPTIONS,
|
||||
parseListParams,
|
||||
} from "./admin-helpers";
|
||||
|
||||
describe("parseListParams", () => {
|
||||
it("parses defaults", () => {
|
||||
const p = parseListParams(new URLSearchParams());
|
||||
expect(p).toEqual({
|
||||
search: "",
|
||||
perPage: 20,
|
||||
page: 1,
|
||||
sort: undefined,
|
||||
order: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit values", () => {
|
||||
const p = parseListParams(
|
||||
new URLSearchParams("search=sofa&perPage=50&page=3&sort=name&order=asc"),
|
||||
);
|
||||
expect(p.search).toBe("sofa");
|
||||
expect(p.perPage).toBe(50);
|
||||
expect(p.page).toBe(3);
|
||||
expect(p.sort).toBe("name");
|
||||
expect(p.order).toBe("asc");
|
||||
});
|
||||
|
||||
it("clamps perPage to [1, 100]", () => {
|
||||
expect(parseListParams(new URLSearchParams("perPage=0")).perPage).toBe(1);
|
||||
expect(parseListParams(new URLSearchParams("perPage=1000")).perPage).toBe(
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps page to >= 1", () => {
|
||||
expect(parseListParams(new URLSearchParams("page=0")).page).toBe(1);
|
||||
expect(parseListParams(new URLSearchParams("page=-5")).page).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults order to desc when not asc", () => {
|
||||
expect(parseListParams(new URLSearchParams("order=asc")).order).toBe("asc");
|
||||
expect(parseListParams(new URLSearchParams("order=desc")).order).toBe(
|
||||
"desc",
|
||||
);
|
||||
expect(parseListParams(new URLSearchParams("order=bogus")).order).toBe(
|
||||
"desc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calcPagination", () => {
|
||||
it("computes last page, offset and clamps the page", () => {
|
||||
const p = calcPagination(50, 3, 20);
|
||||
expect(p.lastPage).toBe(3);
|
||||
expect(p.page).toBe(3);
|
||||
expect(p.offset).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps page beyond last page", () => {
|
||||
const p = calcPagination(10, 99, 20);
|
||||
expect(p.lastPage).toBe(1);
|
||||
expect(p.page).toBe(1);
|
||||
expect(p.offset).toBe(0);
|
||||
});
|
||||
|
||||
it("handles zero total with lastPage 1", () => {
|
||||
const p = calcPagination(0, 1, 20);
|
||||
expect(p.lastPage).toBe(1);
|
||||
expect(p.offset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PER_PAGE_OPTIONS", () => {
|
||||
it("exposes the selectable page sizes", () => {
|
||||
expect(PER_PAGE_OPTIONS).toEqual([10, 20, 50]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
collectNavPermissionSlugs,
|
||||
findAdminHub,
|
||||
navItemIsAllowed,
|
||||
} from "./admin-nav";
|
||||
|
||||
describe("findAdminHub", () => {
|
||||
it("matches by prefix", () => {
|
||||
expect(findAdminHub("/admin/events")?.id).toBe("events");
|
||||
expect(findAdminHub("/admin/events/123")?.id).toBe("events");
|
||||
});
|
||||
it("uses longest-prefix match", () => {
|
||||
expect(findAdminHub("/admin/tickets/desk")?.id).toBe("tickets");
|
||||
});
|
||||
it("returns null for non-hub paths", () => {
|
||||
expect(findAdminHub("/admin/users")).toBeNull();
|
||||
expect(findAdminHub("/login")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("navItemIsAllowed", () => {
|
||||
const item = {
|
||||
href: "/admin/x",
|
||||
labelKey: "x",
|
||||
icon: Calendar,
|
||||
permission: "a.view",
|
||||
};
|
||||
|
||||
it("always allows for super admins", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: true, has: () => false }),
|
||||
).toBe(true);
|
||||
});
|
||||
it("allows when any needed slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "a.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it("denies when no slug is granted", () => {
|
||||
expect(
|
||||
navItemIsAllowed(item, { isSuperAdmin: false, has: () => false }),
|
||||
).toBe(false);
|
||||
});
|
||||
it("allows items without a permission requirement", () => {
|
||||
expect(
|
||||
navItemIsAllowed(
|
||||
{ href: "/admin/d", labelKey: "d", icon: Calendar },
|
||||
{ isSuperAdmin: false, has: () => false },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("supports an array of permissions (any match)", () => {
|
||||
const multi = {
|
||||
href: "/admin/m",
|
||||
labelKey: "m",
|
||||
icon: Calendar,
|
||||
permission: ["x.view", "y.view"],
|
||||
};
|
||||
expect(
|
||||
navItemIsAllowed(multi, {
|
||||
isSuperAdmin: false,
|
||||
has: (s) => s === "y.view",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectNavPermissionSlugs", () => {
|
||||
it("returns unique slugs referenced by the sidebar", () => {
|
||||
const slugs = collectNavPermissionSlugs();
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
expect(slugs.length).toBeGreaterThan(0);
|
||||
});
|
||||
it("is consistent with the nav groups", () => {
|
||||
const groupSlugs = new Set<string>();
|
||||
for (const group of ADMIN_NAV_GROUPS) {
|
||||
for (const item of group.items) {
|
||||
if (!item.permission) continue;
|
||||
const needed = Array.isArray(item.permission)
|
||||
? item.permission
|
||||
: [item.permission];
|
||||
for (const s of needed) groupSlugs.add(s);
|
||||
}
|
||||
}
|
||||
expect([...groupSlugs]).toEqual(collectNavPermissionSlugs());
|
||||
});
|
||||
});
|
||||
@@ -2,58 +2,69 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decideAuthorization,
|
||||
isDynamicSuperAdmin,
|
||||
} from "@/lib/admin/authorization-policy";
|
||||
} from "./authorization-policy";
|
||||
|
||||
describe("isDynamicSuperAdmin", () => {
|
||||
it.each([
|
||||
[7, 7],
|
||||
[11, 11],
|
||||
[2000, 2000],
|
||||
])("accepts highest rank %i", (rank, highest) => {
|
||||
expect(isDynamicSuperAdmin(rank, highest)).toBe(true);
|
||||
it("returns true when rank equals the highest rank", () => {
|
||||
expect(isDynamicSuperAdmin(7, 7)).toBe(true);
|
||||
});
|
||||
it("returns false for non-highest ranks", () => {
|
||||
expect(isDynamicSuperAdmin(6, 7)).toBe(false);
|
||||
});
|
||||
it("returns false when highestRank is null or invalid", () => {
|
||||
expect(isDynamicSuperAdmin(7, null)).toBe(false);
|
||||
expect(isDynamicSuperAdmin(0, 7)).toBe(false);
|
||||
expect(isDynamicSuperAdmin(-1, 7)).toBe(false);
|
||||
expect(isDynamicSuperAdmin(1.5, 1.5)).toBe(false);
|
||||
});
|
||||
it("demotes the previous highest rank", () =>
|
||||
expect(isDynamicSuperAdmin(2000, 2001)).toBe(false));
|
||||
it("fails closed without ranks", () =>
|
||||
expect(isDynamicSuperAdmin(1, null)).toBe(false));
|
||||
});
|
||||
|
||||
describe("decideAuthorization", () => {
|
||||
const actor = { id: 1, username: "admin", rank: 11 };
|
||||
it("allows the dynamically highest rank", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("allows explicit ACL permission below highest", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.news.view",
|
||||
hasPermission: true,
|
||||
}).allowed,
|
||||
).toBe(true));
|
||||
it("denies invalid ranks", () =>
|
||||
const actor = { id: 1, username: "staff", rank: 5 };
|
||||
const base = { actor, highestRank: 7, hasPermission: false };
|
||||
|
||||
it("allows super admins regardless of permission", () => {
|
||||
const decision = decideAuthorization({
|
||||
...base,
|
||||
actor: { ...actor, rank: 7 },
|
||||
hasPermission: false,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true, superAdmin: true });
|
||||
});
|
||||
|
||||
it("denies invalid ranks", () => {
|
||||
expect(
|
||||
decideAuthorization({
|
||||
...base,
|
||||
actor: { ...actor, rank: 0 },
|
||||
highestRank: 11,
|
||||
permission: "admin.any",
|
||||
hasPermission: true,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "invalid_rank" }));
|
||||
it("denies missing permission", () =>
|
||||
expect(
|
||||
decideAuthorization({
|
||||
actor,
|
||||
highestRank: 12,
|
||||
permission: "admin.any",
|
||||
hasPermission: false,
|
||||
}),
|
||||
).toMatchObject({ allowed: false, reason: "permission_denied" }));
|
||||
).toEqual({ allowed: false, reason: "invalid_rank" });
|
||||
});
|
||||
|
||||
it("denies when there are no ranks configured", () => {
|
||||
expect(decideAuthorization({ ...base, highestRank: null })).toEqual({
|
||||
allowed: false,
|
||||
reason: "no_ranks",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows when permission is granted", () => {
|
||||
expect(decideAuthorization({ ...base, hasPermission: true })).toEqual({
|
||||
allowed: true,
|
||||
superAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows when no permission is required", () => {
|
||||
const decision = decideAuthorization({ ...base, permission: undefined });
|
||||
expect(decision).toEqual({ allowed: true, superAdmin: false });
|
||||
});
|
||||
|
||||
it("denies when permission is missing", () => {
|
||||
const decision = decideAuthorization({
|
||||
...base,
|
||||
permission: "users.manage",
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false, reason: "permission_denied" });
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
|
||||
import { buildStaffActivityWhere } from "./log-filters";
|
||||
|
||||
describe("buildStaffActivityWhere", () => {
|
||||
it("filters authorization events by prefix", () => {
|
||||
it("returns an empty where when no filters are set", () => {
|
||||
expect(buildStaffActivityWhere({})).toEqual({});
|
||||
});
|
||||
|
||||
it("builds an OR search across action, description and IP", () => {
|
||||
const where = buildStaffActivityWhere({ q: "ban " });
|
||||
expect(where.OR).toEqual([
|
||||
{ action: { contains: "ban" } },
|
||||
{ description: { contains: "ban" } },
|
||||
{ ipAddress: { contains: "ban" } },
|
||||
]);
|
||||
expect(where.OR?.[0]).toEqual({ action: { contains: "ban" } });
|
||||
});
|
||||
|
||||
it("sets userId when staffId is provided", () => {
|
||||
const where = buildStaffActivityWhere({ staffId: 42 });
|
||||
expect(where.userId).toBe(BigInt(42));
|
||||
});
|
||||
|
||||
it("uses a prefix match for authorization-only", () => {
|
||||
expect(buildStaffActivityWhere({ authorizationOnly: true })).toEqual({
|
||||
action: { startsWith: "permission." },
|
||||
});
|
||||
});
|
||||
it("combines staff and search filters", () => {
|
||||
const result = buildStaffActivityWhere({
|
||||
q: "rank",
|
||||
staffId: 11,
|
||||
authorizationOnly: true,
|
||||
|
||||
it("uses a contains match for a specific action", () => {
|
||||
expect(buildStaffActivityWhere({ action: "ban" })).toEqual({
|
||||
action: { contains: "ban" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
userId: 11n,
|
||||
action: { startsWith: "permission." },
|
||||
});
|
||||
expect(result.OR).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only search terms", () => {
|
||||
expect(buildStaffActivityWhere({ q: " " })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminMutationNotice } from "@/lib/admin/notice";
|
||||
import { adminMutationNotice } from "./notice";
|
||||
|
||||
describe("adminMutationNotice", () => {
|
||||
it("maps a successful redirect to a safe notice", () => {
|
||||
it("returns a danger notice when there is an error", () => {
|
||||
expect(adminMutationNotice({ error: "boom" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
it("returns a success notice when saved is 1", () => {
|
||||
expect(adminMutationNotice({ saved: "1" })).toEqual({
|
||||
tone: "ok",
|
||||
label: "Saved",
|
||||
message: "Changes were saved successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps an error code without reflecting arbitrary query text", () => {
|
||||
expect(adminMutationNotice({ error: "<script>" })).toEqual({
|
||||
tone: "danger",
|
||||
label: "Error",
|
||||
message: "The operation could not be completed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when there is no mutation result", () => {
|
||||
it("returns null when nothing is set", () => {
|
||||
expect(adminMutationNotice({})).toBeNull();
|
||||
});
|
||||
it("prioritises errors over saved flags", () => {
|
||||
const notice = adminMutationNotice({ saved: "1", error: "x" });
|
||||
expect(notice?.tone).toBe("danger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "./photo-files";
|
||||
|
||||
describe("tryRemoveLocalPhotoFile", () => {
|
||||
it("returns false for empty or nullish urls", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for external urls not on this app origin", async () => {
|
||||
expect(
|
||||
await tryRemoveLocalPhotoFile("https://evil.example.com/photos/x.png"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for urls without a leading slash", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("photos/x.png")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks path traversal", async () => {
|
||||
expect(await tryRemoveLocalPhotoFile("/../../etc/passwd")).toBe(false);
|
||||
expect(await tryRemoveLocalPhotoFile("/photos/..%2f..%2fetc/passwd")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the target file does not exist", async () => {
|
||||
expect(
|
||||
await tryRemoveLocalPhotoFile("/photos/definitely-missing.png"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,42 +5,66 @@ import {
|
||||
} from "./recent-furni-resync";
|
||||
|
||||
describe("requestRecentFurniResync", () => {
|
||||
it("posts to the fixed seven-day resync endpoint and normalizes the result", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
mode: "days",
|
||||
days: 7,
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
it("POSTs to the resync endpoint and maps the payload", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
examined: 10,
|
||||
resynced: 4,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
rconOk: true,
|
||||
errors: [{ classname: "sofa", message: "boom" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestRecentFurniResync(fetcher)).resolves.toEqual({
|
||||
examined: 4,
|
||||
resynced: 3,
|
||||
failed: 1,
|
||||
rconOk: false,
|
||||
errors: [{ classname: "chair", message: "invalid entry" }],
|
||||
});
|
||||
expect(RECENT_FURNI_RESYNC_URL).toBe(
|
||||
"/api/admin/import/furni/resync?days=7",
|
||||
);
|
||||
const result = await requestRecentFurniResync(fetcher);
|
||||
expect(fetcher).toHaveBeenCalledWith(RECENT_FURNI_RESYNC_URL, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
examined: 10,
|
||||
resynced: 4,
|
||||
failed: 1,
|
||||
rconOk: true,
|
||||
errors: [{ classname: "sofa", message: "boom" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the API error when the request fails", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ error: "Forbidden" }, { status: 403 }),
|
||||
);
|
||||
|
||||
it("throws with the server error message on failure", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: "no permission" }),
|
||||
});
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Forbidden",
|
||||
"no permission",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a generic error message", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
await expect(requestRecentFurniResync(fetcher)).rejects.toThrow(
|
||||
"Furni resync failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters malformed error entries and coerces counts", async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
examined: "10",
|
||||
resynced: undefined,
|
||||
failed: null,
|
||||
rconOk: "yes",
|
||||
errors: [{ classname: "a", message: "b" }, { nope: 1 }, "x"],
|
||||
}),
|
||||
});
|
||||
const result = await requestRecentFurniResync(fetcher);
|
||||
expect(result.examined).toBe(10);
|
||||
expect(result.resynced).toBe(0);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.rconOk).toBe(false);
|
||||
expect(result.errors).toEqual([{ classname: "a", message: "b" }]);
|
||||
});
|
||||
});
|
||||
+16
-10
@@ -1,24 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activeBanWhere, isBanActive } from "@/lib/bans";
|
||||
import { activeBanWhere, isBanActive, unixNow } from "./bans";
|
||||
|
||||
describe("isBanActive", () => {
|
||||
it("treats banExpire 0 as permanent", () => {
|
||||
expect(isBanActive(0, 1_700_000_000)).toBe(true);
|
||||
it("treats permanent bans (0) as active", () => {
|
||||
expect(isBanActive(0, 12345)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats future expiry as active", () => {
|
||||
expect(isBanActive(1_800_000_000, 1_700_000_000)).toBe(true);
|
||||
it("treats future expiries as active", () => {
|
||||
expect(isBanActive(200, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats past expiry as inactive", () => {
|
||||
expect(isBanActive(1_600_000_000, 1_700_000_000)).toBe(false);
|
||||
it("treats past expiries as inactive", () => {
|
||||
expect(isBanActive(100, 200)).toBe(false);
|
||||
expect(isBanActive(100, 100)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeBanWhere", () => {
|
||||
it("includes permanent and future expires", () => {
|
||||
it("matches permanent and unexpired bans", () => {
|
||||
expect(activeBanWhere(100)).toEqual({
|
||||
OR: [{ banExpire: 0 }, { banExpire: { gt: 100 } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unixNow", () => {
|
||||
it("returns seconds since epoch", () => {
|
||||
const now = unixNow();
|
||||
expect(now).toBeGreaterThan(1_700_000_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CATALOG_LAYOUTS, LAYOUT_DESCRIPTIONS } from "./catalog-layouts";
|
||||
|
||||
describe("CATALOG_LAYOUTS", () => {
|
||||
it("contains the common Habbo layouts", () => {
|
||||
for (const layout of [
|
||||
"frontpage",
|
||||
"default_3x3",
|
||||
"spaces_new",
|
||||
"marketplace",
|
||||
"room_bundle",
|
||||
]) {
|
||||
expect(CATALOG_LAYOUTS).toContain(layout);
|
||||
}
|
||||
});
|
||||
|
||||
it("has a description for every layout", () => {
|
||||
for (const layout of CATALOG_LAYOUTS) {
|
||||
expect(LAYOUT_DESCRIPTIONS[layout]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("descriptions are non-empty", () => {
|
||||
for (const desc of Object.values(LAYOUT_DESCRIPTIONS)) {
|
||||
expect(desc.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CATALOG_IT, translateCaption } from "./catalog-translations";
|
||||
|
||||
describe("translateCaption", () => {
|
||||
it("returns the Italian translation for a known caption", () => {
|
||||
expect(translateCaption("furniture")).toBe("Mobili");
|
||||
expect(translateCaption("RARE")).toBe("Rari");
|
||||
});
|
||||
|
||||
it("normalizes case and whitespace before lookup", () => {
|
||||
expect(translateCaption(" Front Page ")).toBe("Pagina Principale");
|
||||
});
|
||||
|
||||
it("returns null for unknown captions", () => {
|
||||
expect(translateCaption("no-such-caption")).toBeNull();
|
||||
expect(translateCaption("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CATALOG_IT", () => {
|
||||
it("keys are lowercased English captions", () => {
|
||||
for (const key of Object.keys(CATALOG_IT)) {
|
||||
expect(key.toLowerCase()).toBe(key);
|
||||
expect(key.trim()).toBe(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("has Italian values (non-empty)", () => {
|
||||
for (const value of Object.values(CATALOG_IT)) {
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers core categories", () => {
|
||||
expect(CATALOG_IT.frontpage).toBe("Pagina Principale");
|
||||
expect(CATALOG_IT.hc).toBe("Club Habbo");
|
||||
expect(CATALOG_IT.christmas).toBe("Natale");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "./client-translation-files";
|
||||
|
||||
describe("getClientTranslationFile", () => {
|
||||
it("returns the file for a known id", () => {
|
||||
const file = getClientTranslationFile("ui-texts-it");
|
||||
expect(file?.relPath).toBe("public/nitro-assets/config/UITexts.json5");
|
||||
expect(file?.format).toBe("json5");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ids", () => {
|
||||
expect(getClientTranslationFile("../../etc/passwd")).toBeUndefined();
|
||||
expect(getClientTranslationFile("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLIENT_TRANSLATION_FILES", () => {
|
||||
it("has unique ids (doubles as the path-traversal whitelist)", () => {
|
||||
const ids = CLIENT_TRANSLATION_FILES.map((f) => f.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("every relPath is inside the allowed public tree", () => {
|
||||
for (const f of CLIENT_TRANSLATION_FILES) {
|
||||
expect(f.relPath).toMatch(/^public\//);
|
||||
expect(f.relPath).not.toContain("..");
|
||||
}
|
||||
});
|
||||
|
||||
it("formats are either json5 or json", () => {
|
||||
for (const f of CLIENT_TRANSLATION_FILES) {
|
||||
expect(["json5", "json"]).toContain(f.format);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
|
||||
describe("DomainError", () => {
|
||||
it("carries a message and status", () => {
|
||||
const err = new DomainError("bad", 418);
|
||||
expect(err.message).toBe("bad");
|
||||
expect(err.status).toBe(418);
|
||||
expect(err.name).toBe("DomainError");
|
||||
});
|
||||
it("defaults to HTTP 500", () => {
|
||||
expect(new DomainError("x").status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotFoundError", () => {
|
||||
it("formats message with and without an id", () => {
|
||||
expect(new NotFoundError("sofa").message).toBe("sofa not found");
|
||||
expect(new NotFoundError("sofa", 3).message).toBe("sofa #3 not found");
|
||||
});
|
||||
it("uses status 404", () => {
|
||||
expect(new NotFoundError("x").status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UnauthorizedError", () => {
|
||||
it("defaults message and status", () => {
|
||||
const err = new UnauthorizedError();
|
||||
expect(err.message).toBe("Unauthorized");
|
||||
expect(err.status).toBe(401);
|
||||
});
|
||||
it("accepts a custom message", () => {
|
||||
expect(new UnauthorizedError("no token").message).toBe("no token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForbiddenError", () => {
|
||||
it("uses status 403", () => {
|
||||
const err = new ForbiddenError();
|
||||
expect(err.status).toBe(403);
|
||||
expect(err.message).toBe("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationError", () => {
|
||||
it("carries field errors and status 422", () => {
|
||||
const err = new ValidationError({ name: ["too short"] });
|
||||
expect(err.status).toBe(422);
|
||||
expect(err.fieldErrors.name).toEqual(["too short"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RateLimitError", () => {
|
||||
it("exposes retryAfter and status 429", () => {
|
||||
const err = new RateLimitError(12);
|
||||
expect(err.status).toBe(429);
|
||||
expect(err.retryAfter).toBe(12);
|
||||
expect(err.message).toContain("12");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DatabaseError", () => {
|
||||
it("carries a cause and status 500", () => {
|
||||
const cause = new Error("conn refused");
|
||||
const err = new DatabaseError("query failed", cause);
|
||||
expect(err.status).toBe(500);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBaseClassname,
|
||||
getHabboCdnIconUrl,
|
||||
getIconFileName,
|
||||
getLocalIconUrl,
|
||||
getNitroFileName,
|
||||
getSafeClassnameStem,
|
||||
getSwfFileName,
|
||||
isValidClassname,
|
||||
normalizeClassname,
|
||||
} from "./classname";
|
||||
|
||||
describe("getBaseClassname", () => {
|
||||
it("strips the * suffix", () => {
|
||||
expect(getBaseClassname("chair_basic*")).toBe("chair_basic");
|
||||
});
|
||||
it("returns the classname unchanged when no * is present", () => {
|
||||
expect(getBaseClassname("chair_basic")).toBe("chair_basic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeClassnameStem", () => {
|
||||
it("replaces * with underscore", () => {
|
||||
expect(getSafeClassnameStem("wall_plant*")).toBe("wall_plant_");
|
||||
});
|
||||
it("leaves plain classnames untouched", () => {
|
||||
expect(getSafeClassnameStem("lamp_floor")).toBe("lamp_floor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file name helpers", () => {
|
||||
it("builds icon file names from the safe stem", () => {
|
||||
expect(getIconFileName("sofa_blue*")).toBe("sofa_blue__icon.png");
|
||||
});
|
||||
it("builds swf file names from the base classname", () => {
|
||||
expect(getSwfFileName("sofa_blue*")).toBe("sofa_blue.swf");
|
||||
});
|
||||
it("builds nitro file names from the base classname", () => {
|
||||
expect(getNitroFileName("sofa_blue*")).toBe("sofa_blue.nitro");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClassname", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeClassname(" Chair_Basic ")).toBe("chair_basic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidClassname", () => {
|
||||
it("accepts lowercase letters, digits and allowed symbols", () => {
|
||||
expect(isValidClassname("chair_basic")).toBe(true);
|
||||
expect(isValidClassname("sofa*")).toBe(true);
|
||||
expect(isValidClassname("wall.part-1")).toBe(true);
|
||||
});
|
||||
it("rejects uppercase, spaces and invalid characters", () => {
|
||||
expect(isValidClassname("Chair")).toBe(false);
|
||||
expect(isValidClassname("sofa basic")).toBe(false);
|
||||
expect(isValidClassname("sofa/basic")).toBe(false);
|
||||
expect(isValidClassname("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("icon URLs", () => {
|
||||
it("builds a local icon URL with encoding", () => {
|
||||
expect(getLocalIconUrl("sofa_blue")).toBe(
|
||||
"/swf/dcr/hof_furni/icons/sofa_blue_icon.png",
|
||||
);
|
||||
expect(getLocalIconUrl("wall*")).toBe(
|
||||
"/swf/dcr/hof_furni/icons/wall__icon.png",
|
||||
);
|
||||
});
|
||||
it("builds a Habbo CDN icon URL with revision and base classname", () => {
|
||||
expect(getHabboCdnIconUrl("sofa_blue*", 567)).toBe(
|
||||
"https://images.habbo.com/dcr/hof_furni/567/sofa_blue_icon.png",
|
||||
);
|
||||
});
|
||||
});
|
||||
+96
-30
@@ -1,50 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_IMAGER_URL: undefined as string | undefined,
|
||||
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
|
||||
IMAGING_UPSTREAM_URL: undefined as string | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => envMock);
|
||||
|
||||
import { getAvatarUrl, resolveImagerBase, resolveUpstreamBase } from "./imager";
|
||||
|
||||
describe("resolveImagerBase", () => {
|
||||
it("defaults to /imaging when no public URL env is set", () => {
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
envMock.env.NEXT_PUBLIC_APP_URL = undefined;
|
||||
});
|
||||
|
||||
it("prefers a configured imager env var", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/";
|
||||
expect(resolveImagerBase()).toBe("https://img.example.com");
|
||||
});
|
||||
|
||||
it("ignores the broken self-hosted proxy env route", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL =
|
||||
"https://img.example.com/api/imaging/avatar";
|
||||
envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
|
||||
expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
|
||||
});
|
||||
|
||||
it("falls back to app URL imaging route", () => {
|
||||
envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
|
||||
expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
|
||||
});
|
||||
|
||||
it("falls back to relative /imaging", () => {
|
||||
expect(resolveImagerBase()).toBe("/imaging");
|
||||
expect(resolveImagerBase("")).toBe("/imaging");
|
||||
expect(resolveImagerBase(null)).toBe("/imaging");
|
||||
});
|
||||
|
||||
it("rejects the broken self-hosted proxy", () => {
|
||||
expect(resolveImagerBase("/api/imaging/avatar")).toBe("/imaging");
|
||||
it("uses configured setting when valid", () => {
|
||||
expect(resolveImagerBase("https://cdn.example.com/imager/")).toBe(
|
||||
"https://cdn.example.com/imager",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects configured setting that is the broken proxy route", () => {
|
||||
expect(
|
||||
resolveImagerBase("https://www.epicnabbo.nl/api/imaging/avatar"),
|
||||
resolveImagerBase("https://cdn.example.com/api/imaging/avatar"),
|
||||
).toBe("/imaging");
|
||||
});
|
||||
|
||||
it("accepts /imaging as a valid configured URL", () => {
|
||||
expect(resolveImagerBase("/imaging")).toBe("/imaging");
|
||||
});
|
||||
|
||||
it("keeps a custom Habbo-compatible base", () => {
|
||||
expect(
|
||||
resolveImagerBase("https://www.habbo.com/habbo-imaging/avatarimage/"),
|
||||
).toBe("https://www.habbo.com/habbo-imaging/avatarimage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUpstreamBase", () => {
|
||||
it("defaults to Habbo's public imager", () => {
|
||||
const url = resolveUpstreamBase();
|
||||
expect(url).toBe("https://www.habbo.com/habbo-imaging/avatarimage");
|
||||
beforeEach(() => {
|
||||
envMock.env.IMAGING_UPSTREAM_URL = undefined;
|
||||
});
|
||||
|
||||
it("uses env upstream when configured", () => {
|
||||
envMock.env.IMAGING_UPSTREAM_URL = "https://up.example.com/";
|
||||
expect(resolveUpstreamBase()).toBe("https://up.example.com");
|
||||
});
|
||||
|
||||
it("falls back to Habbo public imager", () => {
|
||||
expect(resolveUpstreamBase()).toBe(
|
||||
"https://www.habbo.com/habbo-imaging/avatarimage",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvatarUrl", () => {
|
||||
it("builds an imager URL", () => {
|
||||
const url = getAvatarUrl("hr-115-42.hd-180-1", {
|
||||
size: "l",
|
||||
headOnly: true,
|
||||
direction: 2,
|
||||
});
|
||||
expect(url.startsWith("/imaging?")).toBe(true);
|
||||
expect(url).toContain("figure=hr-115-42.hd-180-1");
|
||||
expect(url).toContain("size=l");
|
||||
expect(url).toContain("headonly=1");
|
||||
beforeEach(() => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
|
||||
envMock.env.NEXT_PUBLIC_APP_URL = undefined;
|
||||
});
|
||||
|
||||
it("builds a query string with defaults", () => {
|
||||
const url = getAvatarUrl("hd-180-1");
|
||||
expect(url.startsWith("https://img.example.com?")).toBe(true);
|
||||
expect(url).toContain("figure=hd-180-1");
|
||||
expect(url).toContain("direction=2");
|
||||
expect(url).toContain("head_direction=3");
|
||||
expect(url).toContain("size=m");
|
||||
expect(url).toContain("img_format=png");
|
||||
});
|
||||
|
||||
it("honors custom options", () => {
|
||||
const url = getAvatarUrl("hd-180-1", {
|
||||
size: "l",
|
||||
direction: 1,
|
||||
headDirection: 2,
|
||||
headOnly: true,
|
||||
gesture: "sml",
|
||||
action: "sit",
|
||||
});
|
||||
expect(url).toContain("size=l");
|
||||
expect(url).toContain("direction=1");
|
||||
expect(url).toContain("head_direction=2");
|
||||
expect(url).toContain("headonly=1");
|
||||
expect(url).toContain("gesture=sml");
|
||||
expect(url).toContain("action=sit");
|
||||
});
|
||||
|
||||
it("uses app URL imaging route when imager env absent", () => {
|
||||
envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
|
||||
envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
|
||||
const url = getAvatarUrl("hd-180-1");
|
||||
expect(url.startsWith("https://app.example.com/imaging?")).toBe(true);
|
||||
});
|
||||
|
||||
it("escapes figure values in the query string", () => {
|
||||
const url = getAvatarUrl("hd-180-1&x=1");
|
||||
expect(url).not.toContain("&x=1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { patchJson5 } from "./json5-patch";
|
||||
|
||||
const sample = `// header comment
|
||||
{
|
||||
// a section
|
||||
"title": "Hotel",
|
||||
"subtitle": "Welcome!",
|
||||
'nested.key': "keep me",
|
||||
}`;
|
||||
|
||||
describe("patchJson5", () => {
|
||||
it("surgically replaces a value while preserving comments", () => {
|
||||
const result = patchJson5(sample, { title: "Hotel" }, { title: "Resort" });
|
||||
expect(result.unpatchedKeys).toEqual([]);
|
||||
expect(result.content).toContain('"title": "Resort"');
|
||||
expect(result.content).toContain("// a section");
|
||||
expect(result.content).toContain('"subtitle": "Welcome!"');
|
||||
});
|
||||
|
||||
it("does not touch keys whose value is unchanged", () => {
|
||||
const result = patchJson5(sample, { title: "Hotel" }, { title: "Hotel" });
|
||||
expect(result.unpatchedKeys).toEqual([]);
|
||||
expect(result.content).toBe(sample);
|
||||
});
|
||||
|
||||
it("reports keys missing from the original", () => {
|
||||
const result = patchJson5(sample, { title: "Hotel" }, { newKey: "x" });
|
||||
expect(result.unpatchedKeys).toEqual(["newKey"]);
|
||||
expect(result.content).toBe(sample);
|
||||
});
|
||||
|
||||
it("reports keys that cannot be located in the source", () => {
|
||||
const result = patchJson5(sample, { title: "Hotel" }, { title: "nope" });
|
||||
// "title" is present and findable, so it patches.
|
||||
expect(result.unpatchedKeys).toEqual([]);
|
||||
expect(result.content).toContain('"title": "nope"');
|
||||
});
|
||||
|
||||
it("handles single-quoted keys and escaped values", () => {
|
||||
const result = patchJson5(
|
||||
sample,
|
||||
{ "nested.key": "keep me" },
|
||||
{ "nested.key": 'say "hi"' },
|
||||
);
|
||||
expect(result.unpatchedKeys).toEqual([]);
|
||||
expect(result.content).toContain(`'nested.key': "say \\"hi\\""`);
|
||||
});
|
||||
|
||||
it("reports keys that do not appear in the file at all", () => {
|
||||
const result = patchJson5(sample, { missing: "a" }, { missing: "b" });
|
||||
expect(result.unpatchedKeys).toEqual(["missing"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const STORAGE_KEY = "catalog_move_suggestions";
|
||||
const store = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
});
|
||||
|
||||
import { getSuggestions, recordMove } from "./move-suggestions";
|
||||
|
||||
beforeEach(() => store.clear());
|
||||
|
||||
describe("recordMove", () => {
|
||||
it("records by interaction type", () => {
|
||||
recordMove({ interactionType: "clothing", targetPageId: 42 });
|
||||
expect(getSuggestions({ interactionType: "clothing" })).toContain(42);
|
||||
});
|
||||
|
||||
it("records by item name prefix", () => {
|
||||
recordMove({ itemName: "rare_sofa", targetPageId: 7 });
|
||||
expect(getSuggestions({ itemName: "rare_table" })).toContain(7);
|
||||
});
|
||||
|
||||
it("records recent targets, deduplicated", () => {
|
||||
recordMove({ targetPageId: 1 });
|
||||
recordMove({ targetPageId: 2 });
|
||||
recordMove({ targetPageId: 1 });
|
||||
const recent = JSON.parse(store.get(STORAGE_KEY) ?? "") as {
|
||||
recent: number[];
|
||||
};
|
||||
expect(recent.recent).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("caps recent at 10 entries", () => {
|
||||
for (let i = 1; i <= 12; i++) recordMove({ targetPageId: i });
|
||||
const recent = JSON.parse(store.get(STORAGE_KEY) ?? "") as {
|
||||
recent: number[];
|
||||
};
|
||||
expect(recent.recent.length).toBe(10);
|
||||
});
|
||||
|
||||
it("increments counts and re-sorts", () => {
|
||||
recordMove({ interactionType: "clothing", targetPageId: 1 });
|
||||
recordMove({ interactionType: "clothing", targetPageId: 2 });
|
||||
recordMove({ interactionType: "clothing", targetPageId: 1 });
|
||||
const suggestions = getSuggestions({ interactionType: "clothing" });
|
||||
expect(suggestions[0]).toBe(1);
|
||||
});
|
||||
|
||||
it("handles a corrupt store gracefully", () => {
|
||||
store.set(STORAGE_KEY, "not-json{");
|
||||
expect(() =>
|
||||
recordMove({ interactionType: "x", targetPageId: 3 }),
|
||||
).not.toThrow();
|
||||
expect(getSuggestions({ interactionType: "x" })).toContain(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSuggestions", () => {
|
||||
it("ranks type matches above prefix matches", () => {
|
||||
recordMove({ interactionType: "clothing", targetPageId: 1 });
|
||||
recordMove({ itemName: "rare_x", targetPageId: 2 });
|
||||
// same page both ways => type weight wins, but both present
|
||||
recordMove({
|
||||
interactionType: "clothing",
|
||||
itemName: "rare_y",
|
||||
targetPageId: 3,
|
||||
});
|
||||
const suggestions = getSuggestions({
|
||||
interactionType: "clothing",
|
||||
itemName: "rare_z",
|
||||
});
|
||||
expect(suggestions[0]).toBe(3);
|
||||
});
|
||||
|
||||
it("returns [] with no data", () => {
|
||||
expect(getSuggestions({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const foundationHandle = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/foundation/action", () => ({
|
||||
handleActionError: foundationHandle,
|
||||
}));
|
||||
|
||||
import { actionError, actionOk, handleActionError } from "./safe-action-shared";
|
||||
|
||||
describe("actionOk", () => {
|
||||
it("wraps data in a success result", () => {
|
||||
expect(actionOk({ id: 1 })).toEqual({ ok: true, data: { id: 1 } });
|
||||
});
|
||||
it("defaults to an empty object when no data is given", () => {
|
||||
expect(actionOk()).toEqual({ ok: true, data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("actionError", () => {
|
||||
it("wraps a message in a failure result", () => {
|
||||
expect(actionError("boom")).toEqual({ ok: false, error: "boom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleActionError", () => {
|
||||
it("maps ZodErrors to validation failures with field errors", () => {
|
||||
const schema = z.object({ name: z.string().min(3) });
|
||||
const parsed = schema.safeParse({ name: "x" });
|
||||
const result = handleActionError(parsed.success ? undefined : parsed.error);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe("Validation failed");
|
||||
expect(result.fieldErrors?.name).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps ActionError instances by name", () => {
|
||||
const err = new Error("not allowed");
|
||||
err.name = "ActionError";
|
||||
expect(handleActionError(err)).toEqual({
|
||||
ok: false,
|
||||
error: "not allowed",
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates unknown errors to the foundation handler", () => {
|
||||
foundationHandle.mockReturnValue({ ok: false, error: "generic" });
|
||||
const result = handleActionError(new Error("generic"));
|
||||
expect(foundationHandle).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBe("generic");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
import { CurrencyType, currencyLabel } from "./currency";
|
||||
|
||||
describe("CurrencyType", () => {
|
||||
it("uses the emulator-fixed numeric values", () => {
|
||||
it("keeps emulator-dictated numeric values", () => {
|
||||
expect(CurrencyType.Credits).toBe(-1);
|
||||
expect(CurrencyType.Duckets).toBe(0);
|
||||
expect(CurrencyType.Diamonds).toBe(5);
|
||||
expect(CurrencyType.Points).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
it("maps each type to a human label", () => {
|
||||
describe("currencyLabel", () => {
|
||||
it("returns friendly labels for each currency", () => {
|
||||
expect(currencyLabel(CurrencyType.Credits)).toBe("Credits");
|
||||
expect(currencyLabel(CurrencyType.Duckets)).toBe("Duckets");
|
||||
expect(currencyLabel(CurrencyType.Diamonds)).toBe("Diamonds");
|
||||
expect(currencyLabel(CurrencyType.Points)).toBe("Points");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/import/core/gordon", () => ({
|
||||
resolveGordonBuildUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
__clearEffectSourceCache,
|
||||
listOfficialEffects,
|
||||
resolveEffectSwfUrl,
|
||||
} from "./effect-source";
|
||||
|
||||
const EFFECTMAP_XML = `<?xml version="1.0"?>
|
||||
<effectmap>
|
||||
<effect id="1" lib="fx_1" />
|
||||
<effect id="2" lib="fx_2" type="seat" revision="3" />
|
||||
<effect id="bad" />
|
||||
</effectmap>`;
|
||||
|
||||
describe("listOfficialEffects", () => {
|
||||
beforeEach(() => {
|
||||
__clearEffectSourceCache();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it("parses the effectmap XML into entries", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => EFFECTMAP_XML,
|
||||
});
|
||||
const list = await listOfficialEffects();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toEqual({ id: "1", lib: "fx_1", type: "fx", revision: 0 });
|
||||
expect(list[1]).toEqual({
|
||||
id: "2",
|
||||
lib: "fx_2",
|
||||
type: "seat",
|
||||
revision: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("caches results across calls", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => EFFECTMAP_XML,
|
||||
});
|
||||
await listOfficialEffects();
|
||||
await listOfficialEffects();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns cached list when fetch fails", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => EFFECTMAP_XML,
|
||||
});
|
||||
await listOfficialEffects();
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "",
|
||||
});
|
||||
const list = await listOfficialEffects();
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array on first fetch failure", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "",
|
||||
});
|
||||
await expect(listOfficialEffects()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectSwfUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(siteSettings.get).mockReset();
|
||||
vi.mocked(resolveGordonBuildUrl).mockReset();
|
||||
});
|
||||
|
||||
it("uses override when set", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue(
|
||||
"https://cdn.example.com/effects/",
|
||||
);
|
||||
await expect(resolveEffectSwfUrl("fx_1")).resolves.toBe(
|
||||
"https://cdn.example.com/effects/fx_1.swf",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to gordon build URL", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue("");
|
||||
vi.mocked(resolveGordonBuildUrl).mockResolvedValue(
|
||||
"https://www.habbo.com/gordon/",
|
||||
);
|
||||
await expect(resolveEffectSwfUrl("fx_1")).resolves.toBe(
|
||||
"https://www.habbo.com/gordon/fx_1.swf",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { browserHeaders } from "./browser-headers";
|
||||
|
||||
describe("browserHeaders", () => {
|
||||
it("defaults to empty destination headers", () => {
|
||||
const h = browserHeaders();
|
||||
expect(h["User-Agent"]).toContain("Chrome/125");
|
||||
expect(h["Sec-Fetch-Mode"]).toBe("cors");
|
||||
expect(h["Sec-Fetch-Dest"]).toBe("empty");
|
||||
expect(h["Upgrade-Insecure-Requests"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses navigate mode for documents", () => {
|
||||
const h = browserHeaders("document");
|
||||
expect(h["Sec-Fetch-Mode"]).toBe("navigate");
|
||||
expect(h["Sec-Fetch-Dest"]).toBe("document");
|
||||
expect(h["Upgrade-Insecure-Requests"]).toBe("1");
|
||||
expect(h.Accept).toContain("text/html");
|
||||
});
|
||||
|
||||
it("uses no-cors mode for images", () => {
|
||||
const h = browserHeaders("image");
|
||||
expect(h["Sec-Fetch-Mode"]).toBe("no-cors");
|
||||
expect(h.Accept).toContain("image/avif");
|
||||
});
|
||||
|
||||
it("uses wildcard accept for scripts", () => {
|
||||
expect(browserHeaders("script").Accept).toBe("*/*");
|
||||
});
|
||||
|
||||
it("merges extra headers last", () => {
|
||||
const h = browserHeaders("empty", { "User-Agent": "custom" });
|
||||
expect(h["User-Agent"]).toBe("custom");
|
||||
expect(h.Accept).toContain("application/json");
|
||||
});
|
||||
});
|
||||
@@ -1,188 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assetNameCandidates,
|
||||
classifyCatalogItem,
|
||||
isBadgeType,
|
||||
isPetClassname,
|
||||
isPetInteraction,
|
||||
NO_ASSET_KINDS,
|
||||
NO_CATALOG_KINDS,
|
||||
} from "./item-kind";
|
||||
import { classifyCatalogItem, isBadgeType, isPetClassname } from "./item-kind";
|
||||
|
||||
describe("isBadgeType", () => {
|
||||
it("returns true for badge type b (any case)", () => {
|
||||
it("recognizes type 'b' case-insensitively", () => {
|
||||
expect(isBadgeType("b")).toBe(true);
|
||||
expect(isBadgeType("B")).toBe(true);
|
||||
expect(isBadgeType(" b ")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for furni types", () => {
|
||||
expect(isBadgeType(" B ")).toBe(true);
|
||||
expect(isBadgeType("s")).toBe(false);
|
||||
expect(isBadgeType("i")).toBe(false);
|
||||
expect(isBadgeType("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPetClassname / isPetInteraction", () => {
|
||||
it("recognises a0 pet<N> classnames", () => {
|
||||
describe("isPetClassname", () => {
|
||||
it("matches a0 pet<N> classnames", () => {
|
||||
expect(isPetClassname("a0 pet0")).toBe(true);
|
||||
expect(isPetClassname("a0 pet80")).toBe(true);
|
||||
expect(isPetClassname(" a0 pet13 ")).toBe(true);
|
||||
expect(isPetClassname("a0_pet0")).toBe(false);
|
||||
expect(isPetClassname("bunny")).toBe(false);
|
||||
expect(isPetClassname("A0 PET5")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises pet<N> interaction types", () => {
|
||||
expect(isPetInteraction("pet0")).toBe(true);
|
||||
expect(isPetInteraction(" pet13 ")).toBe(true);
|
||||
expect(isPetInteraction("pet0 ")).toBe(true);
|
||||
expect(isPetInteraction("postit")).toBe(false);
|
||||
expect(isPetInteraction("default")).toBe(false);
|
||||
expect(isPetInteraction(null)).toBe(false);
|
||||
it("rejects non-pet classnames", () => {
|
||||
expect(isPetClassname("a0")).toBe(false);
|
||||
expect(isPetClassname("sofa")).toBe(false);
|
||||
expect(isPetClassname("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyCatalogItem", () => {
|
||||
it("classifies badges by type b", () => {
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "b",
|
||||
item_name: "YZI68",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("badge");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "b",
|
||||
item_name: "horse",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("badge");
|
||||
});
|
||||
|
||||
it("classifies pets by classname and interaction type", () => {
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "a0 pet0",
|
||||
interaction_type: "pet0 ",
|
||||
}),
|
||||
).toBe("pet");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "a0 pet37",
|
||||
interaction_type: "pet13",
|
||||
}),
|
||||
).toBe("pet");
|
||||
});
|
||||
|
||||
it("classifies effects, bots and sticky notes", () => {
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "avatar_effect158",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("effect");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "bot_generic",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("bot");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "rentable_bot_visitor_log",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("bot");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "post.it",
|
||||
interaction_type: "postit",
|
||||
}),
|
||||
).toBe("sticky");
|
||||
});
|
||||
|
||||
it("does not mistake furni classnames that share prefixes", () => {
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "botanical_garden",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("furni");
|
||||
// `effect_faketile` is a real purchasable furni item.
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "effect_faketile",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("furni");
|
||||
});
|
||||
|
||||
it("classifies normal furniture", () => {
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "table_silo_med",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("furni");
|
||||
expect(
|
||||
classifyCatalogItem({
|
||||
type: "i",
|
||||
item_name: "wall_poster",
|
||||
interaction_type: "default",
|
||||
}),
|
||||
).toBe("furni");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kind sets", () => {
|
||||
it("badges and pets never expect furni assets", () => {
|
||||
expect(NO_ASSET_KINDS.has("badge")).toBe(true);
|
||||
expect(NO_ASSET_KINDS.has("pet")).toBe(true);
|
||||
expect(NO_ASSET_KINDS.has("effect")).toBe(true);
|
||||
expect(NO_ASSET_KINDS.has("bot")).toBe(true);
|
||||
expect(NO_ASSET_KINDS.has("sticky")).toBe(true);
|
||||
expect(NO_ASSET_KINDS.has("furni")).toBe(false);
|
||||
});
|
||||
|
||||
it("pets are catalog-purchasable, badges and system items are not", () => {
|
||||
expect(NO_CATALOG_KINDS.has("badge")).toBe(true);
|
||||
expect(NO_CATALOG_KINDS.has("effect")).toBe(true);
|
||||
expect(NO_CATALOG_KINDS.has("bot")).toBe(true);
|
||||
expect(NO_CATALOG_KINDS.has("sticky")).toBe(true);
|
||||
expect(NO_CATALOG_KINDS.has("pet")).toBe(false);
|
||||
expect(NO_CATALOG_KINDS.has("furni")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assetNameCandidates", () => {
|
||||
it("returns the exact name first for plain classnames", () => {
|
||||
expect(assetNameCandidates("table_silo_med")).toEqual({
|
||||
nitro: ["table_silo_med.nitro"],
|
||||
icon: ["table_silo_med_icon.png"],
|
||||
const item = (
|
||||
overrides: Partial<Parameters<typeof classifyCatalogItem>[0]>,
|
||||
) =>
|
||||
classifyCatalogItem({
|
||||
type: "s",
|
||||
item_name: "sofa",
|
||||
interaction_type: null,
|
||||
...overrides,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the base classname for star variants", () => {
|
||||
expect(assetNameCandidates("table_silo_med*10").nitro).toContain(
|
||||
"table_silo_med.nitro",
|
||||
);
|
||||
expect(assetNameCandidates("table_silo_med*10").icon).toContain(
|
||||
"table_silo_med_10_icon.png",
|
||||
);
|
||||
it("classifies badges", () => {
|
||||
expect(item({ type: "b" })).toBe("badge");
|
||||
});
|
||||
|
||||
it("adds the underscore name for dotted classnames", () => {
|
||||
expect(assetNameCandidates("post.it").nitro).toContain("post_it.nitro");
|
||||
expect(assetNameCandidates("post.it").icon).toContain("post_it_icon.png");
|
||||
it("classifies pets by classname or pet interaction", () => {
|
||||
expect(item({ item_name: "a0 pet7" })).toBe("pet");
|
||||
expect(item({ interaction_type: "pet3" })).toBe("pet");
|
||||
});
|
||||
it("classifies effects, bots and sticky by name", () => {
|
||||
expect(item({ item_name: "avatar_effect_12" })).toBe("effect");
|
||||
expect(item({ item_name: "bot_barry" })).toBe("bot");
|
||||
expect(item({ item_name: "rentable_bot_2" })).toBe("bot");
|
||||
expect(item({ item_name: "post_it" })).toBe("sticky");
|
||||
expect(item({ item_name: "postit" })).toBe("sticky");
|
||||
});
|
||||
it("classifies everything else as furni", () => {
|
||||
expect(item({})).toBe("furni");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
const musicMetadataMock = vi.hoisted(() => ({
|
||||
parseBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({ promises: fsMock }));
|
||||
vi.mock("music-metadata", () => musicMetadataMock);
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import {
|
||||
deleteSoundtrackFile,
|
||||
ensureSoundtrackDir,
|
||||
extractMp3Duration,
|
||||
getSoundtrackPath,
|
||||
writeSoundtrackFile,
|
||||
} from "./soundtracks";
|
||||
|
||||
describe("getSoundtrackPath", () => {
|
||||
it("rejects unsafe codes", () => {
|
||||
expect(() => getSoundtrackPath("../evil")).toThrow(
|
||||
"Unsafe soundtrack code",
|
||||
);
|
||||
});
|
||||
it("joins the safe code into the soundtrack dir", () => {
|
||||
expect(getSoundtrackPath("song").endsWith("mp3/song.mp3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSoundtrackDir", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("creates the dir recursively", async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
await ensureSoundtrackDir();
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeSoundtrackFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("creates dir and writes the buffer", async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
await writeSoundtrackFile("track", Buffer.from("mp3data"));
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("track.mp3"),
|
||||
Buffer.from("mp3data"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSoundtrackFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("ignores ENOENT errors", async () => {
|
||||
fsMock.unlink.mockRejectedValue(
|
||||
Object.assign(new Error("gone"), { code: "ENOENT" }),
|
||||
);
|
||||
await expect(deleteSoundtrackFile("track")).resolves.toBeUndefined();
|
||||
});
|
||||
it("rethrows other errors", async () => {
|
||||
fsMock.unlink.mockRejectedValue(new Error("permission denied"));
|
||||
await expect(deleteSoundtrackFile("track")).rejects.toThrow(
|
||||
"permission denied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMp3Duration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("returns rounded duration", async () => {
|
||||
musicMetadataMock.parseBuffer.mockResolvedValue({
|
||||
format: { duration: 63.7 },
|
||||
});
|
||||
await expect(extractMp3Duration(Buffer.from("x"))).resolves.toBe(64);
|
||||
});
|
||||
it("returns 0 for invalid durations", async () => {
|
||||
musicMetadataMock.parseBuffer.mockResolvedValue({
|
||||
format: { duration: -1 },
|
||||
});
|
||||
await expect(extractMp3Duration(Buffer.from("x"))).resolves.toBe(0);
|
||||
});
|
||||
it("returns 0 when parsing throws", async () => {
|
||||
musicMetadataMock.parseBuffer.mockRejectedValue(new Error("bad"));
|
||||
await expect(extractMp3Duration(Buffer.from("x"))).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/import/core/gordon", () => ({
|
||||
resolveGordonBuildUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveGordonBuildUrl } from "@/lib/services/import/core/gordon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { resolveFigureSwfUrl } from "./figure-source";
|
||||
import { CANONICAL_PETS, resolvePetSwfUrl } from "./pet-source";
|
||||
|
||||
describe("resolveFigureSwfUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(siteSettings.get).mockReset();
|
||||
vi.mocked(resolveGordonBuildUrl).mockReset();
|
||||
});
|
||||
|
||||
it("uses override when set, trimming trailing slashes", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue(
|
||||
"https://cdn.example.com/swf/",
|
||||
);
|
||||
await expect(resolveFigureSwfUrl("h_std_15")).resolves.toBe(
|
||||
"https://cdn.example.com/swf/h_std_15.swf",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to gordon build URL when no override", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue("");
|
||||
vi.mocked(resolveGordonBuildUrl).mockResolvedValue(
|
||||
"https://www.habbo.com/gordon/",
|
||||
);
|
||||
await expect(resolveFigureSwfUrl("h_std_15")).resolves.toBe(
|
||||
"https://www.habbo.com/gordon/h_std_15.swf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePetSwfUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(siteSettings.get).mockReset();
|
||||
vi.mocked(resolveGordonBuildUrl).mockReset();
|
||||
});
|
||||
|
||||
it("uses override when set", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue(
|
||||
"https://cdn.example.com/pets",
|
||||
);
|
||||
await expect(resolvePetSwfUrl("dog")).resolves.toBe(
|
||||
"https://cdn.example.com/pets/dog.swf",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to gordon build URL", async () => {
|
||||
vi.mocked(siteSettings.get).mockResolvedValue(null);
|
||||
vi.mocked(resolveGordonBuildUrl).mockResolvedValue(
|
||||
"https://www.habbo.com/gordon/",
|
||||
);
|
||||
await expect(resolvePetSwfUrl("cat")).resolves.toBe(
|
||||
"https://www.habbo.com/gordon/cat.swf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CANONICAL_PETS", () => {
|
||||
it("includes the classic pet set", () => {
|
||||
expect(CANONICAL_PETS).toContain("dog");
|
||||
expect(CANONICAL_PETS).toContain("cat");
|
||||
expect(CANONICAL_PETS).toContain("horse");
|
||||
expect(CANONICAL_PETS).toContain("monsterplant");
|
||||
expect(CANONICAL_PETS).toContain("bunnyeaster");
|
||||
});
|
||||
it("has no duplicates", () => {
|
||||
expect(new Set(CANONICAL_PETS).size).toBe(CANONICAL_PETS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flattenObject, unflattenObject } from "./translations-utils";
|
||||
|
||||
describe("flattenObject", () => {
|
||||
it("flattens nested objects into dotted keys", () => {
|
||||
const flat = flattenObject({ a: { b: { c: "x" } }, d: "y" });
|
||||
expect(flat).toEqual({ "a.b.c": "x", d: "y" });
|
||||
});
|
||||
|
||||
it("stringifies non-object values", () => {
|
||||
expect(flattenObject({ n: 5, b: true, nil: null })).toEqual({
|
||||
n: "5",
|
||||
b: "true",
|
||||
nil: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps arrays as-is via String()", () => {
|
||||
expect(flattenObject({ list: ["a", "b"] })).toEqual({ list: "a,b" });
|
||||
});
|
||||
|
||||
it("honours an explicit prefix", () => {
|
||||
expect(flattenObject({ a: 1 }, "root")).toEqual({ "root.a": "1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("unflattenObject", () => {
|
||||
it("rebuilds nested objects from dotted keys", () => {
|
||||
expect(unflattenObject({ "a.b.c": "x", d: "y" })).toEqual({
|
||||
a: { b: { c: "x" } },
|
||||
d: "y",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates intermediate objects as needed", () => {
|
||||
expect(unflattenObject({ "x.y": "1", "x.z": "2" })).toEqual({
|
||||
x: { y: "1", z: "2" },
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips with flattenObject", () => {
|
||||
const original = { menu: { home: "Home", items: { a: "A" } }, footer: "F" };
|
||||
expect(unflattenObject(flattenObject(original))).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectSoundtrackKind,
|
||||
parseTrackSamples,
|
||||
parseTraxChannels,
|
||||
TRAX_TICK_SECONDS,
|
||||
} from "./trax-format";
|
||||
|
||||
describe("parseTraxChannels", () => {
|
||||
it("returns [] for empty or null tracks", () => {
|
||||
expect(parseTraxChannels("")).toEqual([]);
|
||||
expect(parseTraxChannels(null)).toEqual([]);
|
||||
expect(parseTraxChannels(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses per-channel event lists", () => {
|
||||
const channels = parseTraxChannels("1:10,2;0,1:2:5,3");
|
||||
expect(channels).toEqual([
|
||||
{
|
||||
channel: 1,
|
||||
events: [
|
||||
{ sampleId: 10, ticks: 2 },
|
||||
{ sampleId: 0, ticks: 1 },
|
||||
],
|
||||
},
|
||||
{ channel: 2, events: [{ sampleId: 5, ticks: 3 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips malformed event pairs", () => {
|
||||
const channels = parseTraxChannels("1:10,2;garbage");
|
||||
expect(channels).toEqual([
|
||||
{ channel: 1, events: [{ sampleId: 10, ticks: 2 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops channels with no valid events", () => {
|
||||
expect(parseTraxChannels("1:junk")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTrackSamples", () => {
|
||||
it("collects unique non-zero sample ids, sorted", () => {
|
||||
expect(parseTrackSamples("1:5,1;0,2:2:3,4;5,2")).toEqual([3, 5]);
|
||||
});
|
||||
it("returns [] for silence-only tracks", () => {
|
||||
expect(parseTrackSamples("1:0,3")).toEqual([]);
|
||||
});
|
||||
it("returns [] for empty or unparseable tracks", () => {
|
||||
expect(parseTrackSamples("")).toEqual([]);
|
||||
expect(parseTrackSamples(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectSoundtrackKind", () => {
|
||||
it("classifies a single-sample song as 'single'", () => {
|
||||
expect(detectSoundtrackKind("1:0,24")).toBe("single");
|
||||
expect(detectSoundtrackKind("")).toBe("single");
|
||||
});
|
||||
it("classifies multi-sample tracks as 'composition'", () => {
|
||||
expect(detectSoundtrackKind("1:10,2;20,3")).toBe("composition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TRAX_TICK_SECONDS", () => {
|
||||
it("is 2 seconds per tick", () => {
|
||||
expect(TRAX_TICK_SECONDS).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user