Files
EpicNext-Cms/e2e/ui/attachment-recovery.spec.ts
T
openhands adffac7360
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat(catalog): store furniture bundles as .hab instead of .nitro
Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.

Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.

Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.

Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.

Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
2026-10-10 17:09:36 +02:00

108 lines
3.1 KiB
TypeScript

import { expect, test } from "@playwright/test";
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const file = {
name: "fixture_chair.hab",
mimeType: "application/octet-stream",
buffer: Buffer.from("isolated upload fixture"),
};
test.beforeEach(async ({ page }) => {
await page.goto("/admin/attachment-harness");
await page
.getByText("Attach original file and resume", { exact: true })
.click();
});
test("attachment upload and double-click resume send one retry and refresh history", async ({
page,
}) => {
let retries = 0;
await page.route("**/api/admin/studio/import-attachment", async (route) => {
expect(route.request().method()).toBe("POST");
expect(route.request().postData()).toContain('name="classname"');
expect(route.request().postData()).toContain("fixture_chair");
expect(route.request().postData()).toContain(
'filename="fixture_chair.hab"',
);
await route.fulfill({ json: { ok: true, attachmentId } });
});
await page.route("**/api/admin/studio/import-jobs", async (route) => {
retries++;
expect(route.request().method()).toBe("PATCH");
expect(route.request().postDataJSON()).toEqual({
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
action: "retry",
attachments: { fixture_chair: attachmentId },
});
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({ json: { ok: true } });
});
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await expect(
page.getByText("Matching original file attached", { exact: true }),
).toBeVisible();
await page
.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
})
.dblclick();
await expect(page.getByLabel("Resumed count")).toHaveText("1");
expect(retries).toBe(1);
await expect(
page.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
}),
).toHaveCount(0);
});
for (const scenario of [
{
name: "owner rejection",
status: 403,
code: "forbidden",
message: "Could not attach the file.",
},
{
name: "mismatched furniture",
status: 400,
code: "mismatchedFile",
message: "This file belongs to different furniture.",
},
{
name: "network failure",
status: 0,
code: "",
message: "Network connection failed",
},
])
test(`attachment ${scenario.name} shows readable error without enabling resume`, async ({
page,
}) => {
let retries = 0;
await page.route("**/api/admin/studio/import-jobs", async (route) => {
retries++;
await route.fulfill({ json: { ok: true } });
});
await page.route("**/api/admin/studio/import-attachment", (route) =>
scenario.status
? route.fulfill({
status: scenario.status,
json: { ok: false, code: scenario.code },
})
: route.abort("failed"),
);
await page.getByLabel("Choose the original .hab file").setInputFiles(file);
await expect(page.getByRole("alert")).toContainText(scenario.message);
await expect(
page.getByRole("button", {
name: "Resume failed imports with this file",
exact: true,
}),
).toHaveCount(0);
await expect(page.getByLabel("Resumed count")).toHaveText("0");
expect(retries).toBe(0);
});