Validate and deduplicate custom difficulty presets
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m4s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m4s
This commit is contained in:
@@ -7,9 +7,11 @@ import { useRouter } from "next/navigation";
|
|||||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
difficulties,
|
difficulties,
|
||||||
|
customDifficultySchema,
|
||||||
type DifficultyPreset,
|
type DifficultyPreset,
|
||||||
} from "@/shared/protocol";
|
} from "@/shared/protocol";
|
||||||
import { api } from "@/lib/client-api";
|
import { api } from "@/lib/client-api";
|
||||||
|
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
|
||||||
export { api } from "@/lib/client-api";
|
export { api } from "@/lib/client-api";
|
||||||
export function StartGame({
|
export function StartGame({
|
||||||
datasetId,
|
datasetId,
|
||||||
@@ -32,13 +34,19 @@ export function StartGame({
|
|||||||
[error, setError] = useState("");
|
[error, setError] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const starting = useRef(false);
|
const starting = useRef(false);
|
||||||
|
const savingPreset = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
|
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
|
||||||
.then(({ presets }) => {
|
.then(({ presets }) => {
|
||||||
if (!disposed) setCustomPresets(presets);
|
if (!disposed)
|
||||||
|
setCustomPresets((current) =>
|
||||||
|
mergeDifficultyPresets(current, presets),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((failure) => {
|
||||||
|
if (!disposed) setError(failure.message);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
@@ -54,19 +62,25 @@ export function StartGame({
|
|||||||
];
|
];
|
||||||
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
|
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (savingDifficulty) return;
|
if (savingPreset.current || starting.current) return;
|
||||||
|
const parsed = customDifficultySchema.safeParse({
|
||||||
|
name: difficultyName,
|
||||||
|
seconds: difficultySeconds,
|
||||||
|
zoom: difficultyZoom,
|
||||||
|
});
|
||||||
|
if (!parsed.success) {
|
||||||
|
setError(parsed.error.issues[0].message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingPreset.current = true;
|
||||||
setSavingDifficulty(true);
|
setSavingDifficulty(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const { preset } = await api<{ preset: DifficultyPreset }>(
|
const { preset } = await api<{ preset: DifficultyPreset }>(
|
||||||
"difficulty-presets",
|
"difficulty-presets",
|
||||||
{
|
parsed.data,
|
||||||
name: difficultyName.trim(),
|
|
||||||
seconds: difficultySeconds,
|
|
||||||
zoom: difficultyZoom,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
setCustomPresets((current) => [...current, preset]);
|
setCustomPresets((current) => mergeDifficultyPresets(current, [preset]));
|
||||||
setDifficulty(preset.id);
|
setDifficulty(preset.id);
|
||||||
setDifficultyName("");
|
setDifficultyName("");
|
||||||
setDifficultySeconds(90);
|
setDifficultySeconds(90);
|
||||||
@@ -75,11 +89,12 @@ export function StartGame({
|
|||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError((failure as Error).message);
|
setError((failure as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
|
savingPreset.current = false;
|
||||||
setSavingDifficulty(false);
|
setSavingDifficulty(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function start() {
|
async function start() {
|
||||||
if (starting.current) return;
|
if (starting.current || savingPreset.current) return;
|
||||||
starting.current = true;
|
starting.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -168,17 +183,17 @@ export function StartGame({
|
|||||||
<input
|
<input
|
||||||
id="custom-difficulty-name"
|
id="custom-difficulty-name"
|
||||||
value={difficultyName}
|
value={difficultyName}
|
||||||
onChange={(event) => setDifficultyName(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setDifficultyName([...event.target.value].slice(0, 40).join(""))
|
||||||
|
}
|
||||||
minLength={2}
|
minLength={2}
|
||||||
maxLength={40}
|
maxLength={80}
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={t("Name this difficulty")}
|
placeholder={t("Name this difficulty")}
|
||||||
disabled={savingDifficulty || loading}
|
disabled={savingDifficulty || loading}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="custom-difficulty-seconds">
|
<label htmlFor="custom-difficulty-seconds">{t("Round time")}</label>
|
||||||
{t("Round time")}
|
|
||||||
</label>
|
|
||||||
<div className="difficulty-builder-time">
|
<div className="difficulty-builder-time">
|
||||||
<input
|
<input
|
||||||
id="custom-difficulty-seconds"
|
id="custom-difficulty-seconds"
|
||||||
@@ -253,7 +268,9 @@ export function StartGame({
|
|||||||
<button
|
<button
|
||||||
className="button primary full"
|
className="button primary full"
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={loading || (mode === "multiplayer" && !hostName)}
|
disabled={
|
||||||
|
loading || savingDifficulty || (mode === "multiplayer" && !hostName)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? t("Preparing your world…")
|
? t("Preparing your world…")
|
||||||
|
|||||||
18
lib/api.ts
18
lib/api.ts
@@ -13,7 +13,11 @@ import {
|
|||||||
viewpointSchema,
|
viewpointSchema,
|
||||||
} from "../shared/protocol";
|
} from "../shared/protocol";
|
||||||
import { mediaUrl, mediaUrls } from "./catalog";
|
import { mediaUrl, mediaUrls } from "./catalog";
|
||||||
import { ServiceError, checkServiceError } from "./service-error";
|
import {
|
||||||
|
ServiceError,
|
||||||
|
checkServiceError,
|
||||||
|
checkDifficultyPresetError,
|
||||||
|
} from "./service-error";
|
||||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||||
import { browserAccount } from "./request-security";
|
import { browserAccount } from "./request-security";
|
||||||
import { jsonBody } from "./json-body";
|
import { jsonBody } from "./json-body";
|
||||||
@@ -165,12 +169,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
.insert(body)
|
.insert(body)
|
||||||
.select("id,name,seconds,zoom")
|
.select("id,name,seconds,zoom")
|
||||||
.single();
|
.single();
|
||||||
if (error?.code === "23505")
|
checkDifficultyPresetError(error);
|
||||||
throw new ServiceError(
|
|
||||||
"A difficulty with that name already exists",
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
checkServiceError(error);
|
|
||||||
return NextResponse.json({ preset: data });
|
return NextResponse.json({ preset: data });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,10 +201,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
const body = z
|
const body = z
|
||||||
.object({
|
.object({
|
||||||
datasetId: idSchema,
|
datasetId: idSchema,
|
||||||
difficulty: z.union([
|
difficulty: z.union([z.enum(["easy", "normal", "hard"]), idSchema]),
|
||||||
z.enum(["easy", "normal", "hard"]),
|
|
||||||
idSchema,
|
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
.parse(await jsonBody(request));
|
.parse(await jsonBody(request));
|
||||||
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
||||||
|
|||||||
@@ -7,10 +7,28 @@ export class ServiceError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkServiceError(error: { message: string; code?: string } | null) {
|
export function checkDifficultyPresetError(
|
||||||
|
error: { message: string; code?: string } | null,
|
||||||
|
) {
|
||||||
|
if (error?.code === "23505")
|
||||||
|
throw new ServiceError("A difficulty with that name already exists", 409);
|
||||||
|
if (error?.code === "23514")
|
||||||
|
throw new ServiceError(
|
||||||
|
"Use a name with 2–40 characters and a round time of 15–600 seconds in 15-second steps",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
checkServiceError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkServiceError(
|
||||||
|
error: { message: string; code?: string } | null,
|
||||||
|
) {
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
if (error.code === "P0001") throw new Error(error.message);
|
if (error.code === "P0001") throw new Error(error.message);
|
||||||
if (error.code === "PGRST116") throw new ServiceError("Record not found", 404);
|
if (error.code === "PGRST116")
|
||||||
|
throw new ServiceError("Record not found", 404);
|
||||||
console.error("Supabase request failed:", error.code, error.message);
|
console.error("Supabase request failed:", error.code, error.message);
|
||||||
throw new ServiceError("MineGeo could not reach its data service. Please try again.");
|
throw new ServiceError(
|
||||||
|
"MineGeo could not reach its data service. Please try again.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
|
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
|
||||||
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
|
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
|
||||||
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
|
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
|
||||||
|
"test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs",
|
||||||
"test:renderer": "node scripts/test-renderer.mjs",
|
"test:renderer": "node scripts/test-renderer.mjs",
|
||||||
"render:map": "node scripts/render-map.mjs",
|
"render:map": "node scripts/render-map.mjs",
|
||||||
"test:production": "node scripts/test-production.mjs",
|
"test:production": "node scripts/test-production.mjs",
|
||||||
|
|||||||
181
scripts/test-difficulty-presets-sql.mjs
Normal file
181
scripts/test-difficulty-presets-sql.mjs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||||
|
|
||||||
|
const db = new PGlite();
|
||||||
|
try {
|
||||||
|
await db.exec(`create role anon; create role authenticated; create role service_role;
|
||||||
|
create schema auth; create table auth.users(id uuid primary key);
|
||||||
|
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||||
|
create schema storage;
|
||||||
|
create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
|
||||||
|
create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
|
||||||
|
create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`);
|
||||||
|
for (const file of (await readdir("supabase/migrations"))
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort()) {
|
||||||
|
if (file.endsWith("fix_difficulty_preset_names.sql"))
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values('Normal',90,true)",
|
||||||
|
),
|
||||||
|
(error) => error.code === "23514",
|
||||||
|
);
|
||||||
|
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||||
|
}
|
||||||
|
const valid = [
|
||||||
|
"Normal",
|
||||||
|
"Speed 90",
|
||||||
|
"Hard",
|
||||||
|
"Тест",
|
||||||
|
"中文",
|
||||||
|
"😎😎",
|
||||||
|
"😎".repeat(40),
|
||||||
|
"a".repeat(40),
|
||||||
|
"a\\b",
|
||||||
|
];
|
||||||
|
for (const name of valid)
|
||||||
|
await db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)",
|
||||||
|
[name],
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
"",
|
||||||
|
"a",
|
||||||
|
"😎",
|
||||||
|
"a".repeat(41),
|
||||||
|
"😎".repeat(41),
|
||||||
|
" leading",
|
||||||
|
"trailing ",
|
||||||
|
"x\nname",
|
||||||
|
"x\u0085name",
|
||||||
|
"x\u202ename",
|
||||||
|
"x\u2066name",
|
||||||
|
])
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values($1,90,true)",
|
||||||
|
[name],
|
||||||
|
),
|
||||||
|
(error) => error.code === "23514",
|
||||||
|
);
|
||||||
|
for (const seconds of [0, 14, 16, 601])
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)",
|
||||||
|
[seconds],
|
||||||
|
),
|
||||||
|
(error) => error.code === "23514",
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values('normal',90,true)",
|
||||||
|
),
|
||||||
|
(error) => error.code === "23505",
|
||||||
|
);
|
||||||
|
|
||||||
|
const preset = (
|
||||||
|
await db.query(
|
||||||
|
"insert into public.difficulty_presets(name,seconds,zoom) values('Playable preset 45',45,false) returning id",
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
for (const [id, seconds] of [
|
||||||
|
["easy", 180],
|
||||||
|
["normal", 90],
|
||||||
|
["hard", 30],
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select public.resolve_difficulty_preset($1) preset", [
|
||||||
|
id,
|
||||||
|
])
|
||||||
|
).rows[0].preset.seconds,
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
db.query(
|
||||||
|
"select public.resolve_difficulty_preset('00000000-0000-4000-8000-000000000000')",
|
||||||
|
),
|
||||||
|
/Choose a saved difficulty/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query("select public.resolve_difficulty_preset($1) preset", [
|
||||||
|
preset.toUpperCase(),
|
||||||
|
])
|
||||||
|
).rows[0].preset.id,
|
||||||
|
preset,
|
||||||
|
);
|
||||||
|
const owner = (
|
||||||
|
await db.query(
|
||||||
|
"insert into auth.users values(gen_random_uuid()) returning id",
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
const map = (
|
||||||
|
await db.query(
|
||||||
|
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||||
|
values($1,'Difficulty fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
|
||||||
|
[owner],
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
const manifest = {
|
||||||
|
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
id: `p${i}`,
|
||||||
|
x: i,
|
||||||
|
y: 65,
|
||||||
|
z: i,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const revision = (
|
||||||
|
await db.query(
|
||||||
|
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
|
||||||
|
[map, JSON.stringify(manifest)],
|
||||||
|
)
|
||||||
|
).rows[0].id;
|
||||||
|
await db.query("update public.datasets set revision_id=$1 where id=$2", [
|
||||||
|
revision,
|
||||||
|
map,
|
||||||
|
]);
|
||||||
|
const solo = (
|
||||||
|
await db.query("select public.create_game($1,$2,$3,$4) id", [
|
||||||
|
map,
|
||||||
|
preset,
|
||||||
|
owner,
|
||||||
|
"a".repeat(64),
|
||||||
|
])
|
||||||
|
).rows[0].id;
|
||||||
|
const multi = (
|
||||||
|
await db.query("select public.create_map_room($1,$2,'Host',2,$3,$4) id", [
|
||||||
|
map,
|
||||||
|
preset,
|
||||||
|
"b".repeat(64),
|
||||||
|
owner,
|
||||||
|
])
|
||||||
|
).rows[0].id;
|
||||||
|
for (const [table, id] of [
|
||||||
|
["game_sessions", solo],
|
||||||
|
["multiplayer_rooms", multi],
|
||||||
|
])
|
||||||
|
assert.deepEqual(
|
||||||
|
(
|
||||||
|
await db.query(
|
||||||
|
`select difficulty_settings from public.${table} where id=$1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
).rows[0].difficulty_settings,
|
||||||
|
{ id: preset, name: "Playable preset 45", seconds: 45, zoom: false },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.query(
|
||||||
|
"select has_table_privilege('anon','public.difficulty_presets','INSERT') allowed",
|
||||||
|
)
|
||||||
|
).rows[0].allowed,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Difficulty SQL regressions passed: original failure reproduced, normal/Unicode names, unsafe names, duplicates, time limits, solo/multiplayer rules and permissions.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
13
shared/difficulty-presets.ts
Normal file
13
shared/difficulty-presets.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { DifficultyPreset } from "./protocol";
|
||||||
|
|
||||||
|
// A list fetched before a save must not remove the newly saved preset.
|
||||||
|
export function mergeDifficultyPresets(
|
||||||
|
current: DifficultyPreset[],
|
||||||
|
incoming: DifficultyPreset[],
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
...new Map(
|
||||||
|
[...current, ...incoming].map((preset) => [preset.id, preset]),
|
||||||
|
).values(),
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
@@ -194,8 +194,10 @@ export const customDifficultySchema = z
|
|||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(2)
|
.refine(
|
||||||
.max(40)
|
(name) => [...name].length >= 2 && [...name].length <= 40,
|
||||||
|
"Difficulty names must contain 2–40 characters",
|
||||||
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(name) =>
|
(name) =>
|
||||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- The original U& expression doubled its Unicode escapes, so the regex
|
||||||
|
-- interpreted code-point text as a character range and rejected normal names.
|
||||||
|
alter table public.difficulty_presets
|
||||||
|
drop constraint difficulty_presets_name_check;
|
||||||
|
alter table public.difficulty_presets
|
||||||
|
add constraint difficulty_presets_name_check check (
|
||||||
|
name = btrim(name)
|
||||||
|
and length(name) between 2 and 40
|
||||||
|
and name !~ U&'[\0001-\001F\007F-\009F\202A-\202E\2066-\2069]'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A PL/pgSQL CASE without ELSE raises case_not_found for every saved UUID,
|
||||||
|
-- before reaching the custom-preset lookup below it.
|
||||||
|
create or replace function public.resolve_difficulty_preset(p_id text)
|
||||||
|
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
|
||||||
|
declare preset jsonb;
|
||||||
|
begin
|
||||||
|
case p_id
|
||||||
|
when 'easy' then
|
||||||
|
return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true);
|
||||||
|
when 'normal' then
|
||||||
|
return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true);
|
||||||
|
when 'hard' then
|
||||||
|
return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false);
|
||||||
|
else null;
|
||||||
|
end case;
|
||||||
|
select jsonb_build_object('id', p.id::text, 'name', p.name, 'seconds', p.seconds, 'zoom', p.zoom)
|
||||||
|
into preset from public.difficulty_presets p where p.id::text = lower(p_id);
|
||||||
|
if not found then raise exception 'Choose a saved difficulty'; end if;
|
||||||
|
return preset;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
86
tests/difficulty-presets.test.ts
Normal file
86
tests/difficulty-presets.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { customDifficultySchema } from "../shared/protocol";
|
||||||
|
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
|
||||||
|
|
||||||
|
const rules = { seconds: 90, zoom: true };
|
||||||
|
test("difficulty names accept normal text and count Unicode characters consistently with PostgreSQL", () => {
|
||||||
|
for (const name of [
|
||||||
|
"Normal",
|
||||||
|
"Speed 90",
|
||||||
|
"Тест",
|
||||||
|
"中文",
|
||||||
|
"😎😎",
|
||||||
|
"😎".repeat(40),
|
||||||
|
"a".repeat(40),
|
||||||
|
"a\\b",
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.safeParse({ name, ...rules }).success,
|
||||||
|
true,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.parse({ name: " My challenge ", ...rules }).name,
|
||||||
|
"My challenge",
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
"",
|
||||||
|
"a",
|
||||||
|
"😎",
|
||||||
|
"a".repeat(41),
|
||||||
|
"😎".repeat(41),
|
||||||
|
"x\nname",
|
||||||
|
"x\u0085name",
|
||||||
|
"x\u202ename",
|
||||||
|
"x\u2066name",
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.safeParse({ name, ...rules }).success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("difficulty rules enforce their supported range and strict request shape", () => {
|
||||||
|
for (const seconds of [15, 90, 600])
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
||||||
|
.success,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const seconds of [0, 14, 16, 601, 15.5, NaN, Infinity, "90"])
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
||||||
|
.success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
customDifficultySchema.safeParse({
|
||||||
|
name: "Fixture",
|
||||||
|
...rules,
|
||||||
|
owner: "forged",
|
||||||
|
}).success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a delayed preset listing cannot erase a newly saved preset or duplicate it", async () => {
|
||||||
|
const old = { id: "old", name: "Old", ...rules };
|
||||||
|
const saved = { id: "saved", name: "New", ...rules };
|
||||||
|
let release!: (presets: (typeof old)[]) => void;
|
||||||
|
const response = new Promise<(typeof old)[]>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
let presets = [old];
|
||||||
|
const loading = response.then((snapshot) => {
|
||||||
|
presets = mergeDifficultyPresets(presets, snapshot);
|
||||||
|
});
|
||||||
|
presets = mergeDifficultyPresets(presets, [saved]);
|
||||||
|
release([old]);
|
||||||
|
await loading;
|
||||||
|
assert.deepEqual(
|
||||||
|
presets.map((p) => p.id),
|
||||||
|
["saved", "old"],
|
||||||
|
);
|
||||||
|
assert.equal(mergeDifficultyPresets(presets, [saved]).length, 2);
|
||||||
|
});
|
||||||
@@ -1,18 +1,58 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { checkServiceError, ServiceError } from "../lib/service-error";
|
import {
|
||||||
|
checkServiceError,
|
||||||
|
checkDifficultyPresetError,
|
||||||
|
ServiceError,
|
||||||
|
} from "../lib/service-error";
|
||||||
|
|
||||||
|
test("expected preset validation and duplicate-name errors return 400/409 without database log noise", (t) => {
|
||||||
|
const log = t.mock.method(console, "error", () => {});
|
||||||
|
for (const [code, status] of [
|
||||||
|
["23514", 400],
|
||||||
|
["23505", 409],
|
||||||
|
] as const)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
checkDifficultyPresetError({
|
||||||
|
code,
|
||||||
|
message: "private database constraint details",
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
|
assert.ok(error instanceof ServiceError);
|
||||||
|
assert.equal(error.status, status);
|
||||||
|
assert.doesNotMatch(error.message, /private database|constraint/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(log.mock.callCount(), 0);
|
||||||
|
assert.doesNotThrow(() => checkDifficultyPresetError(null));
|
||||||
|
});
|
||||||
|
|
||||||
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
|
test("backend failures use 503 and keep database internals out of browser errors", (t) => {
|
||||||
t.mock.method(console, "error", () => {});
|
t.mock.method(console, "error", () => {});
|
||||||
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
checkServiceError({
|
||||||
|
code: "42P01",
|
||||||
|
message: 'relation "private_table" does not exist',
|
||||||
|
}),
|
||||||
|
(error) => {
|
||||||
assert.ok(error instanceof ServiceError);
|
assert.ok(error instanceof ServiceError);
|
||||||
assert.equal(error.status, 503);
|
assert.equal(error.status, 503);
|
||||||
assert.doesNotMatch(error.message, /private_table|42P01/);
|
assert.doesNotMatch(error.message, /private_table|42P01/);
|
||||||
return true;
|
return true;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
|
test("missing records and expected RPC validation keep actionable statuses and messages", () => {
|
||||||
assert.throws(() => checkServiceError({ code: "PGRST116", message: "no rows" }), (error) => error instanceof ServiceError && error.status === 404);
|
assert.throws(
|
||||||
assert.throws(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
|
() => checkServiceError({ code: "PGRST116", message: "no rows" }),
|
||||||
|
(error) => error instanceof ServiceError && error.status === 404,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => checkServiceError({ code: "P0001", message: "Guess outside map" }),
|
||||||
|
/Guess outside map/,
|
||||||
|
);
|
||||||
assert.doesNotThrow(() => checkServiceError(null));
|
assert.doesNotThrow(() => checkServiceError(null));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user