Add map import and export with durable refresh progress
This commit is contained in:
@@ -74,6 +74,7 @@ test("authentication redirects accept supported local routes and reject open red
|
||||
])
|
||||
assert.equal(authDestination(next), "/dashboard");
|
||||
assert.equal(authDestination("/settings"), "/settings");
|
||||
assert.equal(authDestination("/servers"), "/servers");
|
||||
assert.equal(authDestination("/pair?code=1234ABCD"), "/pair?code=1234ABCD");
|
||||
assert.equal(authDestination("/auth/reset-password"), "/auth/reset-password");
|
||||
});
|
||||
|
||||
228
tests/fabric-processing.test.ts
Normal file
228
tests/fabric-processing.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import sharp from "sharp";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { datasetSchema, faces } from "../shared/protocol";
|
||||
import type { Database } from "../shared/database";
|
||||
import { processFabricImages } from "../lib/fabric-images";
|
||||
import { processFabricJob } from "../lib/fabric-jobs";
|
||||
|
||||
async function fixture() {
|
||||
const map = await sharp({
|
||||
create: { width: 32, height: 32, channels: 3, background: "#349d66" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const face = await sharp({
|
||||
create: { width: 256, height: 256, channels: 3, background: "#548fda" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
y: 64,
|
||||
z: i,
|
||||
}));
|
||||
const files = new Map<string, Buffer>([
|
||||
["map.png", map],
|
||||
...viewpoints.flatMap((p) =>
|
||||
faces.map((f) => [`${p.id}/${f}.png`, face] as [string, Buffer]),
|
||||
),
|
||||
]);
|
||||
const manifest = datasetSchema.parse({
|
||||
format: "minegeo-dataset/v1",
|
||||
minecraftVersion: "26.2",
|
||||
dimension: "minecraft:overworld",
|
||||
source: "fabric",
|
||||
name: "Image worker fixture",
|
||||
serverName: "Local",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
|
||||
north: "-z",
|
||||
viewpoints,
|
||||
assets: [...files].map(([path, bytes]) => ({
|
||||
path,
|
||||
bytes: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
})),
|
||||
});
|
||||
return { files, manifest };
|
||||
}
|
||||
|
||||
test("mod images become a lossless map, six WebP faces per location, and previews", async () => {
|
||||
const { manifest, files } = await fixture();
|
||||
const images = new Map<string, Buffer>();
|
||||
const progress: number[] = [];
|
||||
await processFabricImages(manifest, {
|
||||
signal: new AbortController().signal,
|
||||
async download(path) {
|
||||
return files.get(path)!;
|
||||
},
|
||||
async save(path, bytes) {
|
||||
images.set(path, bytes);
|
||||
},
|
||||
async progress(value) {
|
||||
progress.push(value);
|
||||
},
|
||||
});
|
||||
assert.equal(images.size, 36);
|
||||
for (const point of manifest.viewpoints) {
|
||||
for (const face of faces) {
|
||||
const meta = await sharp(
|
||||
images.get(`${point.id}/${face}.webp`),
|
||||
).metadata();
|
||||
assert.equal(meta.format, "webp");
|
||||
assert.equal(meta.width, 256);
|
||||
assert.equal(meta.height, 256);
|
||||
}
|
||||
assert.equal(
|
||||
(await sharp(images.get(`${point.id}/preview.webp`)).metadata()).width,
|
||||
512,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(
|
||||
await sharp(images.get("map.webp")).raw().toBuffer(),
|
||||
await sharp(files.get("map.png")).raw().toBuffer(),
|
||||
);
|
||||
assert.equal(progress.at(-1), 95);
|
||||
assert.ok(progress.every((p, i) => i === 0 || p > progress[i - 1]));
|
||||
});
|
||||
|
||||
test("map-only refresh processes only the captured map", async () => {
|
||||
const { manifest, files } = await fixture();
|
||||
const saved: string[] = [],
|
||||
downloaded: string[] = [];
|
||||
await processFabricImages(manifest, {
|
||||
mapOnly: true,
|
||||
signal: new AbortController().signal,
|
||||
async download(path) {
|
||||
downloaded.push(path);
|
||||
return files.get(path)!;
|
||||
},
|
||||
async save(path) {
|
||||
saved.push(path);
|
||||
},
|
||||
async progress() {},
|
||||
});
|
||||
assert.deepEqual(downloaded, ["map.png"]);
|
||||
assert.deepEqual(saved, ["map.webp"]);
|
||||
});
|
||||
|
||||
test("corrupt assets, non-square faces, and cancelled work cannot complete", async () => {
|
||||
const { manifest, files } = await fixture();
|
||||
const io = {
|
||||
signal: new AbortController().signal,
|
||||
async download(path: string) {
|
||||
return files.get(path)!;
|
||||
},
|
||||
async save() {},
|
||||
async progress() {},
|
||||
};
|
||||
await assert.rejects(
|
||||
processFabricImages(manifest, {
|
||||
...io,
|
||||
async download() {
|
||||
return Buffer.from("corrupt");
|
||||
},
|
||||
}),
|
||||
/checksum\/size mismatch/,
|
||||
);
|
||||
const rectangular = await sharp({
|
||||
create: { width: 512, height: 256, channels: 3, background: "white" },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
files.set("p0/px.png", rectangular);
|
||||
const asset = manifest.assets.find((a) => a.path === "p0/px.png")!;
|
||||
asset.bytes = rectangular.length;
|
||||
asset.sha256 = createHash("sha256").update(rectangular).digest("hex");
|
||||
await assert.rejects(
|
||||
processFabricImages(manifest, io),
|
||||
/must be square PNGs/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("Cancelled fixture"));
|
||||
await assert.rejects(
|
||||
processFabricImages(manifest, { ...io, signal: controller.signal }),
|
||||
/Cancelled fixture/,
|
||||
);
|
||||
});
|
||||
|
||||
// Exercise the actual Supabase client against a local Storage/PostgREST service.
|
||||
// No credentials, game clients, or real user data are involved.
|
||||
test("Vercel worker claims the existing queue job, stores images, and completes through the existing RPC", async (t) => {
|
||||
const { files, manifest } = await fixture();
|
||||
const owner = randomUUID(),
|
||||
datasetId = randomUUID();
|
||||
let job: Record<string, any> = {
|
||||
id: randomUUID(),
|
||||
dataset_id: datasetId,
|
||||
source_dataset_id: null,
|
||||
kind: "upload",
|
||||
state: "queued",
|
||||
attempts: 0,
|
||||
progress: 0,
|
||||
lease_until: null,
|
||||
};
|
||||
let completed = 0;
|
||||
const saved = new Map<string, Buffer>();
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost"),
|
||||
chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const bytes = Buffer.concat(chunks);
|
||||
function json(data: unknown) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
if (url.pathname === "/rest/v1/upload_jobs") {
|
||||
if (req.method === "PATCH") {
|
||||
const body = JSON.parse(bytes.toString());
|
||||
assert.equal(url.searchParams.get("id"), `eq.${job.id}`);
|
||||
if (body.lease_owner)
|
||||
assert.equal(url.searchParams.get("state"), "eq.queued");
|
||||
job = { ...job, ...body };
|
||||
}
|
||||
return json([job]);
|
||||
}
|
||||
if (url.pathname === "/rest/v1/datasets")
|
||||
return json({ owner_id: owner, source: "fabric" });
|
||||
if (url.pathname === "/rest/v1/dataset_inputs") return json({ manifest });
|
||||
if (url.pathname === "/rest/v1/rpc/complete_job") {
|
||||
const body = JSON.parse(bytes.toString());
|
||||
assert.equal(body.p_job, job.id);
|
||||
assert.equal(body.p_worker, job.lease_owner);
|
||||
assert.equal(body.p_count, 5);
|
||||
assert.equal(body.p_cover, "p0");
|
||||
assert.equal(saved.size, 36);
|
||||
completed++;
|
||||
job.state = "complete";
|
||||
return json(null);
|
||||
}
|
||||
const raw = `/storage/v1/object/minegeo-raw/${owner}/${datasetId}/`;
|
||||
if (url.pathname.startsWith(raw))
|
||||
return res.end(files.get(url.pathname.slice(raw.length)));
|
||||
const media = `/storage/v1/object/minegeo-media/${datasetId}/`;
|
||||
if (url.pathname.startsWith(media)) {
|
||||
assert.equal(req.headers["x-upsert"], "true");
|
||||
assert.equal((await sharp(bytes).metadata()).format, "webp");
|
||||
saved.set(url.pathname.slice(media.length), bytes);
|
||||
return json({ Key: url.pathname, Id: randomUUID() });
|
||||
}
|
||||
res.statusCode = 404;
|
||||
json({ error: url.pathname });
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
const client = createClient<Database>(
|
||||
`http://127.0.0.1:${(server.address() as any).port}`,
|
||||
"local-fixture-only",
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
assert.equal(await processFabricJob(client, datasetId), "complete");
|
||||
assert.equal(job.state, "complete");
|
||||
assert.equal(job.attempts, 1);
|
||||
assert.equal(completed, 1);
|
||||
});
|
||||
27
tests/map-refresh.test.ts
Normal file
27
tests/map-refresh.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { refreshCapture } from "../shared/map-refresh";
|
||||
|
||||
const original = {
|
||||
id: "original",
|
||||
name: "My Minecraft map",
|
||||
created_at: "2026-10-07T09:51:39Z",
|
||||
};
|
||||
const contribution = {
|
||||
id: "accepted",
|
||||
name: "My Minecraft map",
|
||||
created_at: "2026-10-07T10:28:01Z",
|
||||
};
|
||||
test("refresh chooses the newest eligible capture, including newly accepted contributions with the same name", () => {
|
||||
assert.equal(refreshCapture([original]), "original");
|
||||
assert.equal(refreshCapture([original, contribution]), "accepted");
|
||||
assert.equal(refreshCapture([contribution, original]), "accepted");
|
||||
});
|
||||
test("a deliberately selected older capture stays selected when the list reloads", () => {
|
||||
assert.equal(
|
||||
refreshCapture([contribution, original], "original"),
|
||||
"original",
|
||||
);
|
||||
assert.equal(refreshCapture([contribution], "original"), "accepted");
|
||||
assert.equal(refreshCapture([]), "");
|
||||
});
|
||||
170
tests/map-sharing.test.ts
Normal file
170
tests/map-sharing.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { zipSync, strToU8 } from "fflate";
|
||||
import {
|
||||
archiveSchema,
|
||||
importManifest,
|
||||
readMapArchive,
|
||||
sha256,
|
||||
archiveManifestName,
|
||||
} from "../shared/map-archive";
|
||||
import { datasetSchema, faces, parseUploadManifest } from "../shared/protocol";
|
||||
import {
|
||||
archiveSignature,
|
||||
verifyArchiveSignature,
|
||||
} from "../lib/map-archive-signature";
|
||||
import { processFabricImages } from "../lib/fabric-images";
|
||||
|
||||
async function fixture(source: "fabric" | "plugin" = "plugin") {
|
||||
const bytes = await sharp({
|
||||
create: { width: 256, height: 256, channels: 3, background: "#78ab23" },
|
||||
})
|
||||
.webp()
|
||||
.toBuffer();
|
||||
const viewpoints = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
y: 70,
|
||||
z: i,
|
||||
}));
|
||||
const files: Record<string, Uint8Array> = { "map.webp": bytes };
|
||||
for (const p of viewpoints)
|
||||
for (const f of faces) files[`${p.id}/${f}.webp`] = bytes;
|
||||
const archive = archiveSchema.parse({
|
||||
format: "minegeo-map/v1",
|
||||
name: "Shared world",
|
||||
description: "Map for friends",
|
||||
serverName: "Test server",
|
||||
source,
|
||||
dimension: "minecraft:overworld",
|
||||
north: "-z",
|
||||
minecraftVersion: "26.2",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 32, maxZ: 32 },
|
||||
viewpoints,
|
||||
originalMapId: randomUUID(),
|
||||
originalOwner: "Original explorer",
|
||||
signature: "0".repeat(64),
|
||||
assets: await Promise.all(
|
||||
Object.entries(files).map(async ([path, content]) => ({
|
||||
path,
|
||||
bytes: content.length,
|
||||
sha256: await sha256(content),
|
||||
})),
|
||||
),
|
||||
});
|
||||
archive.signature = archiveSignature(archive, "test-key");
|
||||
files[archiveManifestName] = strToU8(JSON.stringify(archive));
|
||||
return { archive, files };
|
||||
}
|
||||
|
||||
test("ZIP roundtrip preserves source, six faces, coordinates, and signed original owner", async () => {
|
||||
for (const source of ["plugin", "fabric"] as const) {
|
||||
const { archive, files } = await fixture(source);
|
||||
for (const level of [0, 6] as const) {
|
||||
const restored = await readMapArchive(zipSync(files, { level }));
|
||||
assert.deepEqual(restored.archive, archive);
|
||||
const manifest = importManifest(restored.archive);
|
||||
assert.equal(manifest.source, source);
|
||||
assert.equal(manifest.mediaFormat, "webp");
|
||||
assert.equal(manifest.assets.length, 31);
|
||||
assert.equal(manifest.contributionTo, undefined);
|
||||
assert.equal(manifest.mapPath, undefined);
|
||||
assert.equal(manifest.serverAddress, undefined);
|
||||
verifyArchiveSignature(restored.archive, "test-key");
|
||||
assert.throws(() => parseUploadManifest(manifest), /Use map import/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ZIP import rejects corruption, missing faces, unsafe files, and false ownership", async () => {
|
||||
const { archive, files } = await fixture();
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync({ ...files, "p0/px.webp": strToU8("corrupt") })),
|
||||
/checksum or size/,
|
||||
);
|
||||
const missing = { ...files };
|
||||
delete missing["p0/px.webp"];
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync(missing)),
|
||||
/unexpected files|checksum/,
|
||||
);
|
||||
await assert.rejects(
|
||||
readMapArchive(zipSync({ ...files, "../secret": strToU8("file") })),
|
||||
/unexpected/,
|
||||
);
|
||||
await assert.rejects(readMapArchive(strToU8("not a zip")), /ZIP/);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyArchiveSignature(
|
||||
{ ...archive, originalOwner: "Impersonator" },
|
||||
"test-key",
|
||||
),
|
||||
/ownership/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyArchiveSignature({ ...archive, name: "Changed world" }, "test-key"),
|
||||
/ownership/,
|
||||
);
|
||||
assert.throws(() =>
|
||||
importManifest({ ...archive, contributionTo: randomUUID() }),
|
||||
);
|
||||
assert.throws(() =>
|
||||
importManifest({
|
||||
...archive,
|
||||
viewpoints: archive.viewpoints.map((p) => ({
|
||||
...p,
|
||||
assetRoot: randomUUID(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("ZIP extraction rejects oversized entries before inflating them", async () => {
|
||||
const { files } = await fixture();
|
||||
const zip = zipSync(files, { level: 0 });
|
||||
// The first local header advertises an enormous uncompressed map.
|
||||
new DataView(zip.buffer).setUint32(22, 60 * 1024 * 1024, true);
|
||||
await assert.rejects(readMapArchive(zip), /oversized/);
|
||||
});
|
||||
|
||||
test("rendered server map imports are validated and processed without the terrain renderer", async () => {
|
||||
const { archive, files } = await fixture();
|
||||
const manifest = importManifest(archive);
|
||||
const images = new Map<string, Buffer>();
|
||||
await processFabricImages(manifest, {
|
||||
signal: new AbortController().signal,
|
||||
download: async (path) => Buffer.from(files[path]),
|
||||
save: async (path, bytes) => {
|
||||
images.set(path, bytes);
|
||||
},
|
||||
progress: async () => {},
|
||||
});
|
||||
assert.equal(images.size, 36);
|
||||
assert.equal((await sharp(images.get("map.webp")).metadata()).format, "webp");
|
||||
assert.equal(
|
||||
(await sharp(images.get("p0/preview.webp")).metadata()).width,
|
||||
512,
|
||||
);
|
||||
assert.equal(datasetSchema.parse(manifest).viewpoints.length, 5);
|
||||
const corrupt = Buffer.from("not an image");
|
||||
const bad = datasetSchema.parse({
|
||||
...manifest,
|
||||
assets: manifest.assets.map((a) =>
|
||||
a.path === "map.webp"
|
||||
? { ...a, bytes: corrupt.length, sha256: "0".repeat(64) }
|
||||
: a,
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
processFabricImages(bad, {
|
||||
signal: new AbortController().signal,
|
||||
download: async () => corrupt,
|
||||
save: async () => {},
|
||||
progress: async () => {},
|
||||
}),
|
||||
/checksum/,
|
||||
);
|
||||
});
|
||||
38
tests/sql/map-sharing.sql
Normal file
38
tests/sql/map-sharing.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
begin;
|
||||
do $$
|
||||
declare owner uuid:=gen_random_uuid(); friend uuid:=gen_random_uuid(); other uuid:=gen_random_uuid(); original uuid; copy uuid; manifest jsonb; sid uuid; rid uuid; before_games integer;
|
||||
begin
|
||||
before_games:=(public.player_leaderboard()->>'total_games')::integer;
|
||||
insert into auth.users(id) values(owner),(friend),(other);
|
||||
insert into public.profiles(id) values(owner),(friend),(other) on conflict(id) do nothing;
|
||||
manifest:='{"format":"minegeo-dataset/v1","source":"plugin","mediaFormat":"webp","name":"Shared fixture","description":"","serverName":"Shared fixture server","bounds":{"minX":0,"minZ":0,"maxX":64,"maxZ":64},"viewpoints":[{"id":"p0","x":0,"y":70,"z":0},{"id":"p1","x":1,"y":70,"z":1},{"id":"p2","x":2,"y":70,"z":2},{"id":"p3","x":3,"y":70,"z":3},{"id":"p4","x":4,"y":70,"z":4}],"assets":[]}';
|
||||
original:=public.create_upload(owner,manifest);
|
||||
update public.dataset_inputs set validated=true where dataset_id=original;
|
||||
update public.datasets set state='draft',panorama_count=5 where id=original;
|
||||
copy:=public.create_map_import(friend,manifest,original,'Original owner');
|
||||
update public.dataset_inputs set validated=true where dataset_id=copy;
|
||||
update public.datasets set state='draft',panorama_count=5 where id=copy;
|
||||
sid:=public.create_game(copy,'normal',friend,'private-fixture');
|
||||
if (select count(*) from public.game_rounds where session_id=sid)<>5 then raise exception 'Private game was not created'; end if;
|
||||
rid:=public.create_map_room(copy,'easy','Friend',4,repeat('a',64),friend);
|
||||
if (select count(*) from public.multiplayer_rounds where room_id=rid)<>5 then raise exception 'Private room was not created'; end if;
|
||||
begin perform public.create_game(copy,'normal',other,'unauthorized'); raise exception 'Other account played private map';
|
||||
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
|
||||
begin perform public.create_game(copy,'normal',null,'guest'); raise exception 'Guest played private map';
|
||||
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
|
||||
begin perform public.create_map_room(copy,'easy','Other',4,repeat('b',64),other); raise exception 'Other account hosted private map';
|
||||
exception when others then if sqlerrm<>'Map unavailable' then raise; end if; end;
|
||||
begin perform public.submit_draft(copy,friend); raise exception 'Friend submitted imported map';
|
||||
exception when others then if sqlerrm not like 'Owned by "Original owner".%' then raise; end if; end;
|
||||
begin update public.datasets set state='published' where id=copy; raise exception 'Shared map published';
|
||||
exception when others then if sqlerrm not like 'Owned by "Original owner".%' then raise; end if; end;
|
||||
begin update public.datasets set imported_from_id=null,imported_owner_name=null where id=copy; raise exception 'Attribution removed';
|
||||
exception when others then if sqlerrm<>'Shared map ownership cannot be changed' then raise; end if; end;
|
||||
perform public.submit_draft(original,owner);
|
||||
if (select state from public.datasets where id=original)<>'review' then raise exception 'Original owner cannot submit'; end if;
|
||||
if (select state from public.datasets where id=copy)<>'draft' then raise exception 'Private copy changed'; end if;
|
||||
update public.game_rounds set result='{"score":5000}' where session_id=sid;
|
||||
update public.game_sessions set current_round=5 where id=sid;
|
||||
if (public.player_leaderboard()->>'total_games')::integer<>before_games then raise exception 'Private scores reached public leaderboard'; end if;
|
||||
end; $$;
|
||||
rollback;
|
||||
Reference in New Issue
Block a user