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 {
|
||||
difficulties,
|
||||
customDifficultySchema,
|
||||
type DifficultyPreset,
|
||||
} from "@/shared/protocol";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
|
||||
export { api } from "@/lib/client-api";
|
||||
export function StartGame({
|
||||
datasetId,
|
||||
@@ -32,13 +34,19 @@ export function StartGame({
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const starting = useRef(false);
|
||||
const savingPreset = useRef(false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
|
||||
.then(({ presets }) => {
|
||||
if (!disposed) setCustomPresets(presets);
|
||||
if (!disposed)
|
||||
setCustomPresets((current) =>
|
||||
mergeDifficultyPresets(current, presets),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((failure) => {
|
||||
if (!disposed) setError(failure.message);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
@@ -54,19 +62,25 @@ export function StartGame({
|
||||
];
|
||||
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
|
||||
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);
|
||||
setError("");
|
||||
try {
|
||||
const { preset } = await api<{ preset: DifficultyPreset }>(
|
||||
"difficulty-presets",
|
||||
{
|
||||
name: difficultyName.trim(),
|
||||
seconds: difficultySeconds,
|
||||
zoom: difficultyZoom,
|
||||
},
|
||||
parsed.data,
|
||||
);
|
||||
setCustomPresets((current) => [...current, preset]);
|
||||
setCustomPresets((current) => mergeDifficultyPresets(current, [preset]));
|
||||
setDifficulty(preset.id);
|
||||
setDifficultyName("");
|
||||
setDifficultySeconds(90);
|
||||
@@ -75,11 +89,12 @@ export function StartGame({
|
||||
} catch (failure) {
|
||||
setError((failure as Error).message);
|
||||
} finally {
|
||||
savingPreset.current = false;
|
||||
setSavingDifficulty(false);
|
||||
}
|
||||
}
|
||||
async function start() {
|
||||
if (starting.current) return;
|
||||
if (starting.current || savingPreset.current) return;
|
||||
starting.current = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -168,17 +183,17 @@ export function StartGame({
|
||||
<input
|
||||
id="custom-difficulty-name"
|
||||
value={difficultyName}
|
||||
onChange={(event) => setDifficultyName(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setDifficultyName([...event.target.value].slice(0, 40).join(""))
|
||||
}
|
||||
minLength={2}
|
||||
maxLength={40}
|
||||
maxLength={80}
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={t("Name this difficulty")}
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
<label htmlFor="custom-difficulty-seconds">
|
||||
{t("Round time")}
|
||||
</label>
|
||||
<label htmlFor="custom-difficulty-seconds">{t("Round time")}</label>
|
||||
<div className="difficulty-builder-time">
|
||||
<input
|
||||
id="custom-difficulty-seconds"
|
||||
@@ -253,7 +268,9 @@ export function StartGame({
|
||||
<button
|
||||
className="button primary full"
|
||||
onClick={start}
|
||||
disabled={loading || (mode === "multiplayer" && !hostName)}
|
||||
disabled={
|
||||
loading || savingDifficulty || (mode === "multiplayer" && !hostName)
|
||||
}
|
||||
>
|
||||
{loading
|
||||
? t("Preparing your world…")
|
||||
|
||||
18
lib/api.ts
18
lib/api.ts
@@ -13,7 +13,11 @@ import {
|
||||
viewpointSchema,
|
||||
} from "../shared/protocol";
|
||||
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 { browserAccount } from "./request-security";
|
||||
import { jsonBody } from "./json-body";
|
||||
@@ -165,12 +169,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.insert(body)
|
||||
.select("id,name,seconds,zoom")
|
||||
.single();
|
||||
if (error?.code === "23505")
|
||||
throw new ServiceError(
|
||||
"A difficulty with that name already exists",
|
||||
409,
|
||||
);
|
||||
checkServiceError(error);
|
||||
checkDifficultyPresetError(error);
|
||||
return NextResponse.json({ preset: data });
|
||||
}
|
||||
}
|
||||
@@ -202,10 +201,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
const body = z
|
||||
.object({
|
||||
datasetId: idSchema,
|
||||
difficulty: z.union([
|
||||
z.enum(["easy", "normal", "hard"]),
|
||||
idSchema,
|
||||
]),
|
||||
difficulty: z.union([z.enum(["easy", "normal", "hard"]), idSchema]),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
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.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);
|
||||
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:multiplayer": "node scripts/test-multiplayer-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",
|
||||
"render:map": "node scripts/render-map.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
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(40)
|
||||
.refine(
|
||||
(name) => [...name].length >= 2 && [...name].length <= 40,
|
||||
"Difficulty names must contain 2–40 characters",
|
||||
)
|
||||
.refine(
|
||||
(name) =>
|
||||
!/[\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 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) => {
|
||||
t.mock.method(console, "error", () => {});
|
||||
assert.throws(() => checkServiceError({ code: "42P01", message: 'relation "private_table" does not exist' }), (error) => {
|
||||
assert.ok(error instanceof ServiceError);
|
||||
assert.equal(error.status, 503);
|
||||
assert.doesNotMatch(error.message, /private_table|42P01/);
|
||||
return true;
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
checkServiceError({
|
||||
code: "42P01",
|
||||
message: 'relation "private_table" does not exist',
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error instanceof ServiceError);
|
||||
assert.equal(error.status, 503);
|
||||
assert.doesNotMatch(error.message, /private_table|42P01/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
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(() => checkServiceError({ code: "P0001", message: "Guess outside map" }), /Guess outside map/);
|
||||
assert.throws(
|
||||
() => 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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user