refactor/simplify

This commit is contained in:
Ryan Barlow
2026-05-28 20:19:00 +01:00
parent 590f0444ff
commit 87b69eb144
12 changed files with 152 additions and 426 deletions
+2 -1
View File
@@ -662,7 +662,8 @@
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
"tag_too_long": "Clan tag cannot exceed 5 characters.",
"tag_invalid_chars": "Clan tag can only contain letters and numbers.",
"tag_not_member": "Join the {tag} clan before using its tag."
"tag_not_member": "Join the {tag} clan before using its tag.",
"tag_check_failed": "Couldn't verify clan tag. Try again or remove it."
},
"host_modal": {
"title": "Create Private Lobby",
+5 -21
View File
@@ -1,7 +1,4 @@
import {
ClanExistsResponseSchema,
clanExistsApiPath,
} from "../core/ApiSchemas";
import { clanExistsApiPath } from "../core/ApiSchemas";
import {
type ClanBansResponse,
ClanBansResponseSchema,
@@ -36,9 +33,9 @@ export type {
ClanInfo,
ClanJoinRequest,
ClanMember,
ClanMembersResponse,
ClanMemberStats,
ClanMemberWL,
ClanMembersResponse,
ClanRequestsResponse,
} from "../core/ClanApiSchemas";
@@ -131,28 +128,15 @@ export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
}
}
// Lightweight existence probe. Public endpoint, no auth required — used to
// detect clan-tag ownership conflicts when a user types a tag into the input.
// Returns null on unexpected statuses, timeouts, or transport errors so callers
// can fail open.
// Public existence probe (no auth). null = inconclusive (timeout / error /
// unexpected status); the caller decides how to handle it.
export async function fetchClanExists(tag: string): Promise<boolean | null> {
try {
const res = await fetch(`${getApiBase()}${clanExistsApiPath(tag)}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
});
if (res.status === 200) {
try {
const text = await res.text();
if (text.length > 0) {
const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text));
if (parsed.success && parsed.data?.exists === false) return false;
}
} catch {
// Body parsing is forward-compat only; ignore failures.
}
return true;
}
if (res.status === 200) return true;
if (res.status === 404) return false;
return null;
} catch {
+3 -10
View File
@@ -7,7 +7,6 @@ import {
} from "../core/validations/username";
import { IdentityReadyController } from "./identity/IdentityReadyController";
import {
awaitIdentityReady,
getClanTagForSubmit,
initIdentityFromStorage,
revalidateIdentityTranslations,
@@ -94,9 +93,9 @@ export class ClanTagInput extends LitElement {
private translatedError(raw: string): string {
if (!raw) return "";
// Ownership errors are stored as i18n keys (with optional tag param);
// format errors are already-translated strings from validateClanTag.
if (raw === "username.tag_not_member") {
// Ownership errors are stored as i18n keys; format errors are already
// translated strings from validateClanTag.
if (raw.startsWith("username.")) {
return translateText(raw, { tag: this.identity.state.clanTag.value });
}
return raw;
@@ -122,12 +121,6 @@ export class ClanTagInput extends LitElement {
input.value = sanitized;
}
// Resolves once any in-flight async ownership check settles. Returns
// immediately when nothing is in flight.
public async awaitValidation(): Promise<void> {
await awaitIdentityReady();
}
public showValidationFeedback() {
const message =
this.translatedError(this.identity.state.clanTag.error) ||
+3 -4
View File
@@ -812,10 +812,9 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
this.mostRecentJoinEvent = event.timeStamp;
// Final identity gate. Callers (play buttons, modals) already disable
// themselves until identity is ready, so this is a defense-in-depth check
// for stray dispatches — bail silently rather than show a toast since
// the inputs already render their own inline errors.
// Wait for any in-flight clan-tag check, then gate: buttons disable
// themselves until ready, and this also covers programmatic/deep-link
// joins. Bail silently the inputs render their own inline errors.
const ready = await awaitIdentityReady();
if (!ready) {
console.warn("join-lobby blocked: identity not ready");
@@ -1,14 +1,12 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import {
awaitIdentityReady,
getIdentityState,
subscribeIdentity,
type IdentityState,
} from "./IdentityStore";
// Subscribes a Lit host to the identity store and triggers a re-render
// whenever the ready/validating state changes. Lets play buttons bind
// `?disabled=${!identity.ready}` without bespoke wiring per consumer.
// Subscribes a Lit host to the identity store and re-renders it whenever the
// ready state changes, so play buttons can bind `?disabled=${!identity.ready}`.
export class IdentityReadyController implements ReactiveController {
private unsubscribe: (() => void) | null = null;
private snapshot: IdentityState;
@@ -41,8 +39,4 @@ export class IdentityReadyController implements ReactiveController {
get state(): IdentityState {
return this.snapshot;
}
async awaitReady(): Promise<boolean> {
return awaitIdentityReady();
}
}
+42 -78
View File
@@ -1,7 +1,5 @@
import { sanitizeClanTag } from "../../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MIN_CLAN_TAG_LENGTH,
validateClanTag,
validateUsername,
} from "../../core/validations/username";
@@ -36,18 +34,13 @@ const state: IdentityState = {
ready: false,
};
let lastInput: { username: string; clanTag: string } = {
username: "",
clanTag: "",
};
function recomputeReady() {
state.ready =
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
}
let lastInput = { username: "", clanTag: "" };
function emit() {
recomputeReady();
// Play is gated until the username is valid AND the clan tag is proven OK
// (owned or fictional). While a check is in flight, nothing is proven yet.
state.ready =
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
for (const listener of listeners) listener(state);
}
@@ -67,19 +60,10 @@ export function getUsernameForSubmit(): string {
return state.username.value;
}
// Mirrors the legacy ClanTagInput.getValue contract: only emit a non-null
// value when the tag is valid AND meets length AND format. Empty / pending /
// failed states submit as null so the server falls back to "no tag".
export function getClanTagForSubmit(): string | null {
// Don't submit a tag while the ownership check is in flight — callers
// either gate on `state.ready` first or await `awaitIdentityReady()`.
if (state.clanTagChecking) return null;
const { value, valid } = state.clanTag;
if (!valid) return null;
if (value.length < MIN_CLAN_TAG_LENGTH) return null;
if (value.length > MAX_CLAN_TAG_LENGTH) return null;
if (!validateClanTag(value).isValid) return null;
return value;
return valid && value.length > 0 ? value : null;
}
export function setUsername(raw: string) {
@@ -91,9 +75,7 @@ export function setUsername(raw: string) {
valid: result.isValid,
error: result.isValid ? "" : (result.error ?? ""),
};
if (result.isValid) {
localStorage.setItem(USERNAME_KEY, trimmed);
}
if (result.isValid) localStorage.setItem(USERNAME_KEY, trimmed);
emit();
}
@@ -107,12 +89,12 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
const tag = sanitizeClanTag(raw);
const result = validateClanTag(tag);
// Cancel any pending/in-flight ownership work. checkCounter++ marks stale
// chains; resolving the prior debounce lets awaitReady() callers unblock.
// A new value supersedes any pending/in-flight check and unblocks
// awaitIdentityReady() callers waiting on the prior one.
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
clanCheckTimer = null;
clanCheckCounter++;
if (resolveDebounce) resolveDebounce();
resolveDebounce?.();
resolveDebounce = null;
state.clanTag = {
@@ -122,8 +104,6 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
};
if (!result.isValid || tag.length === 0) {
// Nothing to ask the server about. Wipe the stored tag so a reload
// doesn't restore a stale value that no longer matches input.
state.clanTagChecking = false;
localStorage.setItem(CLAN_TAG_KEY, "");
currentCheck = Promise.resolve();
@@ -131,15 +111,15 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
return;
}
// Well-formed tag: nothing is proven until the ownership check resolves.
state.clanTagChecking = true;
emit();
const generation = clanCheckCounter;
const run = (): Promise<void> => {
if (generation !== clanCheckCounter) return Promise.resolve();
return runOwnershipCheck(tag, generation);
};
const run = () =>
generation === clanCheckCounter
? runOwnershipCheck(tag, generation)
: Promise.resolve();
if (options.immediate) {
currentCheck = run();
} else {
@@ -156,6 +136,9 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
}
}
// Members are always accepted. A non-member keeps a tag only if the clan is
// fictional; a real clan they don't belong to, or anything we can't verify,
// is rejected so play stays gated until the tag is proven.
async function runOwnershipCheck(tag: string, generation: number) {
const stillCurrent = () =>
generation === clanCheckCounter && state.clanTag.value === tag;
@@ -165,16 +148,16 @@ async function runOwnershipCheck(tag: string, generation: number) {
const myTags = me
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
: [];
if (!myTags.includes(tag.toUpperCase())) {
const exists = await fetchClanExists(tag);
if (!stillCurrent()) return;
if (exists !== false) {
rejectTag(tag);
return;
}
if (myTags.includes(tag.toUpperCase())) {
acceptTag(tag);
return;
}
acceptTag(tag);
const exists = await fetchClanExists(tag);
if (!stillCurrent()) return;
if (exists === false) acceptTag(tag);
else if (exists === true) rejectTag(tag, "username.tag_not_member");
else rejectTag(tag, "username.tag_check_failed");
}
function acceptTag(tag: string) {
@@ -184,19 +167,14 @@ function acceptTag(tag: string) {
emit();
}
function rejectTag(tag: string) {
state.clanTag = {
value: tag,
valid: false,
error: "username.tag_not_member",
};
function rejectTag(tag: string, error: string) {
state.clanTag = { value: tag, valid: false, error };
state.clanTagChecking = false;
localStorage.removeItem(CLAN_TAG_KEY);
emit();
}
// Resolves once any in-flight async clan check settles. Returns the final
// ready state so callers can branch without a second read.
// Resolves once any in-flight clan check settles; returns the final ready state.
export async function awaitIdentityReady(): Promise<boolean> {
let last: Promise<void> | undefined;
while (currentCheck !== last) {
@@ -206,28 +184,21 @@ export async function awaitIdentityReady(): Promise<boolean> {
return state.ready;
}
// Re-runs sync validation against the last raw input so error messages get
// re-translated when the active language changes. Does NOT re-trigger the
// async ownership check (the cached result is still correct).
// Re-runs sync validation against the last raw input so error strings get
// re-translated on a language change. A confirmed ownership error (i18n key,
// format-valid) is preserved.
export function revalidateIdentityTranslations() {
const trimmed = lastInput.username.trim();
const usernameResult = validateUsername(trimmed);
const u = validateUsername(trimmed);
state.username = {
value: trimmed,
valid: usernameResult.isValid,
error: usernameResult.isValid ? "" : (usernameResult.error ?? ""),
valid: u.isValid,
error: u.isValid ? "" : (u.error ?? ""),
};
const tag = sanitizeClanTag(lastInput.clanTag);
const tagResult = validateClanTag(tag);
// Preserve any existing ownership error (it's already an i18n key); only
// refresh the format-level error so language changes pick up new strings.
if (!tagResult.isValid) {
state.clanTag = {
value: tag,
valid: false,
error: tagResult.error ?? "",
};
const t = validateClanTag(tag);
if (!t.isValid) {
state.clanTag = { value: tag, valid: false, error: t.error ?? "" };
}
emit();
}
@@ -236,18 +207,11 @@ let initialized = false;
export function initIdentityFromStorage() {
if (initialized) return;
initialized = true;
const storedUsername = localStorage.getItem(USERNAME_KEY) ?? "";
setUsername(storedUsername);
const storedClanTag = localStorage.getItem(CLAN_TAG_KEY) ?? "";
if (storedClanTag.length > 0) {
setClanTag(storedClanTag, { immediate: true });
} else {
setClanTag("", { immediate: true });
}
setUsername(localStorage.getItem(USERNAME_KEY) ?? "");
setClanTag(localStorage.getItem(CLAN_TAG_KEY) ?? "", { immediate: true });
}
// Test-only reset; not part of the public surface but exported so unit tests
// can wipe singleton state between cases.
// Test-only reset for the singleton module state.
export function __resetIdentityStoreForTests() {
initialized = false;
listeners.clear();
+2 -15
View File
@@ -21,25 +21,12 @@ export const RefreshResponseSchema = z.object({
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
// Auth API path for the clan existence probe. Uppercased here so the URL
// matches the canonical tag form (membership checks also uppercase), avoiding
// case-sensitivity mismatches against the upstream endpoint.
// Existence-probe path (200 = exists, 404 = not); uppercased to match the
// canonical tag form. Shared so the client probe and server enforcement agree.
export function clanExistsApiPath(tag: string): string {
return `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`;
}
// The upstream contract uses HTTP status alone (200 = exists, 404 = not).
// This schema is kept for forward-compat in case a body is added; today it
// matches an empty/absent body too.
export const ClanExistsResponseSchema = z
.object({
exists: z.boolean().optional(),
})
.partial()
.or(z.null())
.or(z.undefined());
export type ClanExistsResponse = z.infer<typeof ClanExistsResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
+13 -115
View File
@@ -1,129 +1,41 @@
import {
ClanExistsResponseSchema,
clanExistsApiPath,
type UserMeResponse,
} from "../core/ApiSchemas";
// Clan-existence probe used by the join-time ownership check.
//
// Only positive results are cached: a fictional tag (clan does not exist
// upstream) could legitimately become real moments later, and we don't want a
// stale "false" to leak a tag past the ownership check until TTL expiry.
// Positive results are safe to cache because a real clan stays real for the
// life of any reasonable cache entry, and a cache hit returns "exists=true",
// which only ever causes the tag to be dropped (fail-closed).
import { clanExistsApiPath, type UserMeResponse } from "../core/ApiSchemas";
export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
export const CLAN_EXISTS_CACHE_TTL_MS = 60_000;
export const CLAN_EXISTS_CACHE_MAX_ENTRIES = 1024;
interface CacheEntry {
expiresAt: number;
}
// Insertion-ordered Map gives us a trivial FIFO/LRU: re-set on hit to bump
// freshness, evict from the oldest end when the bound is reached.
const positiveCache = new Map<string, CacheEntry>();
interface ProbeDeps {
/** Base URL of the upstream auth API (issuer). */
baseUrl: string;
/** Injected so tests can stub network behavior. */
fetcher?: typeof fetch;
/** Injected so tests control time. */
now?: () => number;
/** Logger callback for unexpected statuses / transport errors. */
onWarn?: (event: string, ctx: Record<string, unknown>) => void;
/** Override the shared cache (tests). */
cache?: Map<string, CacheEntry>;
/** Override TTL / bound (tests). */
ttlMs?: number;
maxEntries?: number;
}
function getCachedExists(
cache: Map<string, CacheEntry>,
key: string,
nowMs: number,
): boolean | undefined {
const entry = cache.get(key);
if (entry === undefined) return undefined;
if (nowMs >= entry.expiresAt) {
cache.delete(key);
return undefined;
}
// Bump LRU recency.
cache.delete(key);
cache.set(key, entry);
return true;
}
function setCachedExists(
cache: Map<string, CacheEntry>,
key: string,
expiresAt: number,
maxEntries: number,
): void {
cache.set(key, { expiresAt });
while (cache.size > maxEntries) {
const oldest = cache.keys().next();
if (oldest.done) break;
cache.delete(oldest.value);
}
}
/**
* Returns true if the tag matches a real clan upstream, false if it does not,
* and null when the result is inconclusive (transport error, timeout, or
* unexpected status). Callers should treat null as fail-closed (drop the tag).
* unexpected status). Callers treat null as fail-closed (drop the tag).
*/
export async function clanExistsByTag(
tag: string,
deps: ProbeDeps,
): Promise<boolean | null> {
const cache = deps.cache ?? positiveCache;
const now = deps.now ?? Date.now;
const fetcher = deps.fetcher ?? fetch;
const ttlMs = deps.ttlMs ?? CLAN_EXISTS_CACHE_TTL_MS;
const maxEntries = deps.maxEntries ?? CLAN_EXISTS_CACHE_MAX_ENTRIES;
const cacheKey = tag.toUpperCase();
const cached = getCachedExists(cache, cacheKey, now());
if (cached === true) return true;
try {
const url = `${deps.baseUrl}${clanExistsApiPath(tag)}`;
const response = await fetcher(url, {
const response = await fetcher(`${deps.baseUrl}${clanExistsApiPath(tag)}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
});
if (response.status === 200) {
// Upstream currently has no body; tolerate {exists:false} for forward-compat.
try {
const text = await response.text();
if (text.length > 0) {
const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text));
if (parsed.success && parsed.data?.exists === false) {
return false;
}
}
} catch {
// Forward-compat parsing only; ignore failures.
}
setCachedExists(cache, cacheKey, now() + ttlMs, maxEntries);
return true;
}
if (response.status === 404) {
return false;
}
if (response.status === 200) return true;
if (response.status === 404) return false;
deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", {
tag: cacheKey,
tag: tag.toUpperCase(),
status: response.status,
});
return null;
} catch (e) {
deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", {
tag: cacheKey,
tag: tag.toUpperCase(),
error: e instanceof Error ? e.message : String(e),
});
return null;
@@ -133,13 +45,11 @@ export async function clanExistsByTag(
/**
* Decide whether a player may wear the given (already-censored) clan tag.
*
* - Members of the tag's clan always pass through unchanged.
* - Non-members keep the tag only when the upstream API confirms the clan
* does not exist (fictional tag).
* - Inconclusive existence results drop the tag (fail-closed).
*
* @returns the resolved tag (the original or null) along with a `dropped` flag
* so callers can log impersonation attempts.
* - Members of the tag's clan pass through unchanged.
* - Non-members keep the tag only when the API confirms no such clan exists
* (a fictional tag).
* - A real clan the player isn't in, or an inconclusive check, drops the tag
* (fail-closed) — `reason` lets callers log the impersonation attempt.
*/
export async function resolveClanTag(
censoredTag: string | null,
@@ -162,22 +72,10 @@ export async function resolveClanTag(
}
const exists = await existsChecker(censoredTag);
if (exists === false) {
return { tag: censoredTag, dropped: false };
}
if (exists === false) return { tag: censoredTag, dropped: false };
return {
tag: null,
dropped: true,
reason: exists === true ? "exists" : "inconclusive",
};
}
/** Exposed for tests so each spec starts with a clean cache. */
export function __resetClanExistsCacheForTests(): void {
positiveCache.clear();
}
/** Exposed for tests/observability. */
export function __peekClanExistsCacheSize(): number {
return positiveCache.size;
}
+44 -40
View File
@@ -39,11 +39,21 @@ import {
} from "../../src/client/identity/IdentityStore";
const flushPromises = async () => {
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
for (let i = 0; i < 5; i++) await Promise.resolve();
};
const anonMe = {
user: {},
player: {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
clans: [],
},
} as any;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getUserMe).mockReset();
@@ -59,18 +69,8 @@ afterEach(() => {
});
describe("IdentityStore clan-tag ownership check", () => {
it("surfaces tag_not_member when user is not a member and clan exists", async () => {
vi.mocked(getUserMe).mockResolvedValue({
user: {},
player: {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
clans: [],
},
} as any);
it("rejects with tag_not_member when not a member and the clan exists", async () => {
vi.mocked(getUserMe).mockResolvedValue(anonMe);
vi.mocked(fetchClanExists).mockResolvedValue(true);
setClanTag("ABC");
@@ -83,18 +83,8 @@ describe("IdentityStore clan-tag ownership check", () => {
expect(state.clanTag.error).toBe("username.tag_not_member");
});
it("clears any stored clanTag when async detects ownership conflict", async () => {
vi.mocked(getUserMe).mockResolvedValue({
user: {},
player: {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
clans: [],
},
} as any);
it("clears any stored clanTag on an ownership conflict", async () => {
vi.mocked(getUserMe).mockResolvedValue(anonMe);
vi.mocked(fetchClanExists).mockResolvedValue(true);
localStorage.setItem("clanTag", "ABC");
@@ -106,7 +96,7 @@ describe("IdentityStore clan-tag ownership check", () => {
expect(localStorage.getItem("clanTag")).toBeNull();
});
it("keeps the tag when the clan does not exist (fictional)", async () => {
it("accepts a fictional tag (clan does not exist)", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
@@ -119,7 +109,22 @@ describe("IdentityStore clan-tag ownership check", () => {
expect(localStorage.getItem("clanTag")).toBe("FIC");
});
it("fails closed: rejects the tag when existence check is inconclusive", async () => {
it("accepts a member's tag without probing existence", async () => {
vi.mocked(getUserMe).mockResolvedValue({
...anonMe,
player: { ...anonMe.player, clans: [{ tag: "ABC" }] },
});
setClanTag("ABC");
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(getIdentityState().clanTag.valid).toBe(true);
expect(fetchClanExists).not.toHaveBeenCalled();
});
it("rejects with tag_check_failed when existence is inconclusive", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(null);
@@ -128,7 +133,10 @@ describe("IdentityStore clan-tag ownership check", () => {
await flushPromises();
await flushPromises();
expect(getIdentityState().clanTag.valid).toBe(false);
// Can't prove ownership -> stays gated, with a message telling the user.
const state = getIdentityState();
expect(state.clanTag.valid).toBe(false);
expect(state.clanTag.error).toBe("username.tag_check_failed");
});
it("discards stale async results when the tag has changed", async () => {
@@ -146,18 +154,16 @@ describe("IdentityStore clan-tag ownership check", () => {
vi.advanceTimersByTime(401);
await flushPromises();
// User switches to a different tag before the first response lands.
// Switch tags before the first probe lands.
setClanTag("BBB");
vi.advanceTimersByTime(401);
await flushPromises();
// First (stale) response would have said "AAA exists" → conflict, but the
// tag is no longer AAA, so this must NOT clobber the result for BBB.
// Stale "AAA exists" must not clobber BBB.
resolveFirst(true);
await flushPromises();
expect(getIdentityState().clanTag.error).toBe("");
// Second response says BBB doesn't exist → fictional, accept.
resolveSecond(false);
await flushPromises();
await flushPromises();
@@ -166,13 +172,12 @@ describe("IdentityStore clan-tag ownership check", () => {
expect(localStorage.getItem("clanTag")).toBe("BBB");
});
it("flips ready false while a check is in flight, true on success", async () => {
it("gates play while a check is in flight, then clears it", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
setClanTag("ABC");
// Pre-debounce: checking flag already set so play buttons disable
// immediately, not after the debounce.
// Pre-debounce: checking is already set so buttons disable immediately.
expect(getIdentityState().clanTagChecking).toBe(true);
expect(getIdentityState().ready).toBe(false);
@@ -183,18 +188,17 @@ describe("IdentityStore clan-tag ownership check", () => {
expect(getIdentityState().clanTag.valid).toBe(true);
});
it("getClanTagForSubmit returns null while empty/short/pending; tag once accepted", async () => {
it("getClanTagForSubmit: null while empty/short/checking; tag once accepted", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
setClanTag("");
expect(getClanTagForSubmit()).toBeNull();
setClanTag("A");
setClanTag("A"); // too short -> format invalid
expect(getClanTagForSubmit()).toBeNull();
setClanTag("ABC");
// Ownership check hasn't resolved yet → not submittable.
expect(getIdentityState().clanTagChecking).toBe(true);
expect(getClanTagForSubmit()).toBeNull();
-10
View File
@@ -121,16 +121,6 @@ describe("fetchClanExists", () => {
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
expect(calledUrl).toContain("/public/clan/ABC/exists");
});
it("treats a body {exists:false} as false on 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(okStatus(200, JSON.stringify({ exists: false }))),
),
);
await expect(fetchClanExists("ABC")).resolves.toBe(false);
});
});
describe("fetchClanDetail", () => {
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { clanExistsApiPath } from "../../src/core/ApiSchemas";
describe("clanExistsApiPath", () => {
it("uppercases and URL-encodes the tag", () => {
expect(clanExistsApiPath("abc")).toBe("/public/clan/ABC/exists");
expect(clanExistsApiPath("a/b")).toBe("/public/clan/A%2FB/exists");
});
});
+27 -124
View File
@@ -1,15 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { UserMeResponse } from "../../src/core/ApiSchemas";
import {
clanExistsByTag,
resolveClanTag,
} from "../../src/server/ClanTagOwnership";
const okResponse = (status: number, body = ""): Response =>
({
status,
text: async () => body,
}) as unknown as Response;
const okResponse = (status: number): Response =>
({ status }) as unknown as Response;
const userWithClans = (tags: string[]): UserMeResponse =>
({
@@ -32,145 +29,51 @@ const userWithClans = (tags: string[]): UserMeResponse =>
}) as UserMeResponse;
describe("clanExistsByTag", () => {
let cache: Map<string, { expiresAt: number }>;
let now: number;
beforeEach(() => {
cache = new Map();
now = 1_000_000;
const deps = (fetcher: () => Promise<Response>) => ({
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
});
it("returns true on HTTP 200", async () => {
const fetcher = vi.fn(async () => okResponse(200));
const result = await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
const result = await clanExistsByTag(
"ABC",
deps(async () => okResponse(200)),
);
expect(result).toBe(true);
});
it("returns false on HTTP 404 without caching it", async () => {
const fetcher = vi.fn(async () => okResponse(404));
const result = await clanExistsByTag("XYZ", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
it("returns false on HTTP 404", async () => {
const result = await clanExistsByTag(
"XYZ",
deps(async () => okResponse(404)),
);
expect(result).toBe(false);
// Negative results must not poison the cache — a clan can be created
// moments after a 404 and a stale "false" would briefly let non-members
// wear the tag.
expect(cache.size).toBe(0);
});
it("returns null on unexpected status (fail-closed) and does not cache", async () => {
const fetcher = vi.fn(async () => okResponse(503));
const result = await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
it("returns null on unexpected status (fail-closed)", async () => {
const result = await clanExistsByTag(
"ABC",
deps(async () => okResponse(503)),
);
expect(result).toBeNull();
expect(cache.size).toBe(0);
});
it("returns null on transport error (fail-closed)", async () => {
const fetcher = vi.fn(async () => {
throw new Error("offline");
});
const result = await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
const result = await clanExistsByTag(
"ABC",
deps(async () => {
throw new Error("offline");
}),
);
expect(result).toBeNull();
});
it("uppercases the tag in the request URL", async () => {
const fetcher = vi.fn(async () => okResponse(200));
await clanExistsByTag("abc", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
await clanExistsByTag("abc", deps(fetcher));
const calledUrl = (fetcher.mock.calls[0] as unknown[])[0] as string;
expect(calledUrl).toContain("/public/clan/ABC/exists");
});
it("serves positive results from cache without re-fetching", async () => {
const fetcher = vi.fn(async () => okResponse(200));
await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("re-fetches positive entries after TTL expiry", async () => {
const fetcher = vi.fn(async () => okResponse(200));
await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
ttlMs: 1000,
});
now += 2000;
await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
ttlMs: 1000,
});
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("evicts the oldest entry when maxEntries is exceeded", async () => {
const fetcher = vi.fn(async () => okResponse(200));
const deps = {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
maxEntries: 2,
};
await clanExistsByTag("A", deps);
await clanExistsByTag("B", deps);
await clanExistsByTag("C", deps);
expect(cache.size).toBe(2);
expect(cache.has("A")).toBe(false);
expect(cache.has("B")).toBe(true);
expect(cache.has("C")).toBe(true);
});
it("treats body {exists:false} as false on 200 without caching", async () => {
const fetcher = vi.fn(async () =>
okResponse(200, JSON.stringify({ exists: false })),
);
const result = await clanExistsByTag("ABC", {
baseUrl: "https://auth.example",
fetcher: fetcher as unknown as typeof fetch,
cache,
now: () => now,
});
expect(result).toBe(false);
expect(cache.size).toBe(0);
});
});
describe("resolveClanTag", () => {