feat(client): account-level custom usernames (base.suffix + premium bare names) (#4644)

## Summary

Client integration for account-level usernames (backend:
openfrontio/infra#434). Players get one canonical account name stored as
a base plus a server-assigned 4-digit suffix (`bob.4821`); subscribers
display the bare base (`bob`) with an exclusive case-insensitive claim
on it.

- **Schemas** (`src/core/ApiSchemas.ts`): new `player` username fields
on `GET /users/@me`, `UsernameStatusSchema`,
`PutUsernameResponseSchema`, and an `isTemporaryUsername()` helper. The
display name is rendered exactly as the server resolves it — the client
never assembles `base.suffix`. Discriminators stay strings (leading
zeros).
- **API** (`src/client/Api.ts`): `updateUsername()` for `PUT
/users/@me/username`, returning a discriminated result covering every
documented failure: `invalid` (400), `profane` (400 +
`USERNAME_PROFANE`), `taken` (both 409 bodies), `cooldown` (429 +
`Retry-After`), `failed`.
- **UI** (`src/client/components/UsernamePanel.ts`, in the Account tab):
set/change form prefilled with the base, live 3–20 / `[a-zA-Z0-9_-]`
validation (`validateAccountUsername`), form locked with the date while
the 30-day cooldown runs, grace-period warning for lapsed claim holders,
and a free-rename notice after a server-side `TEMPORARY####` rename. The
confirm dialog composes warnings by state: 30-day lock always,
case-only-change notice, and abandon-reservation warning for `claimed`
players. Suffixes are never mentioned in user-facing copy — subscribers
should feel like they own the bare name outright.
- **Load prompt** (`src/client/Main.ts`): on a clean homepage load, a
subscriber renamed to `TEMPORARY####` is prompted to pick a new name
(free); takes priority over the rewards popup (the account modal shows
rewards anyway).

## Deploy notes

**No deploy-order constraint.** All new `/users/@me` fields are optional
in the schema: against the current API the response still parses and the
panel simply doesn't render (`usernameStatus === undefined`). The client
can ship before or after infra#434.

## Test plan

- New schema tests (old-API absence, leading-zero discriminators,
unknown statuses, PUT payload) and validation tests (dots, spaces,
unicode, trim) — full suite passes (2,031 + 171).
- Drove the real app headless with synthetic `/users/@me` payloads:
verified all panel states (unclaimed, never-set, claimed+grace,
TEMPORARY, cooldown-locked, old API hidden), the confirm-dialog
warnings, and the inline error path on a failed PUT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-19 15:05:31 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent d6b4b0e00c
commit 9c14ab04e0
9 changed files with 716 additions and 9 deletions
+20
View File
@@ -67,6 +67,25 @@
"tab_stats": "Stats",
"title": "Account",
"unclaimed_rewards": "Unclaimed Rewards",
"username_changed": "Username changed to {name}.",
"username_confirm_abandon": "You will permanently give up your reserved name \"{name}\".",
"username_confirm_body": "Your new name will be locked for 30 days.",
"username_confirm_button": "Change name",
"username_confirm_case_only": "The new name only differs from your current one in capitalization — it will still restart the 30-day lock.",
"username_confirm_heading": "Change username?",
"username_cooldown_until": "You can change your username again on {date}.",
"username_error_cooldown": "You can change your username again in {days} day(s).",
"username_error_failed": "Couldn't change your username. Please try again later.",
"username_error_profane": "That username isn't allowed.",
"username_error_taken": "That username is already taken — try another.",
"username_grace_warning": "You'll lose the exclusive right to \"{name}\" on {date}. Resubscribe to keep it.",
"username_not_set": "Claim your username — it identifies you across all your games.",
"username_placeholder": "Enter a username",
"username_save": "Save",
"username_temporary_notice": "Your name was taken by another player. Pick a new one — this rename is free.",
"username_temporary_prompt": "Your username was taken by another player. Choose a new one now? The rename is free.",
"username_temporary_prompt_confirm": "Choose a name",
"username_title": "Username",
"your_subscription": "Your Subscription"
},
"build_menu": {
@@ -1594,6 +1613,7 @@
"zoom_out_desc": "Zoom out the map"
},
"username": {
"account_invalid_chars": "Username can only contain letters, numbers, underscores, and hyphens.",
"enter_username": "Enter your username",
"invalid_chars": "Username can only contain letters, numbers, spaces, and underscores.",
"not_string": "Username must be a string.",
+16 -2
View File
@@ -32,6 +32,7 @@ import "./components/RewardsPanel";
import type { RewardsChangedDetail } from "./components/RewardsPanel";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import "./components/UsernamePanel";
import { fetchCosmetics } from "./Cosmetics";
import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK";
import { playerProfileUrl } from "./PlayerProfileModal";
@@ -329,7 +330,8 @@ export class AccountModal extends BaseModal {
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
${this.renderUsernamePanel()} ${this.renderRewardsPanel()}
${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -359,7 +361,8 @@ export class AccountModal extends BaseModal {
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
${this.renderUsernamePanel()} ${this.renderRewardsPanel()}
${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -457,6 +460,17 @@ export class AccountModal extends BaseModal {
`;
}
// Account-username management (custom-usernames). Hidden when the API
// doesn't return the username fields yet (older backend).
private renderUsernamePanel(): TemplateResult | "" {
const player = this.userMeResponse?.player;
if (!player || player.usernameStatus === undefined) return "";
return html`<username-panel
.player=${player}
@username-changed=${() => this.requestUpdate()}
></username-panel>`;
}
private renderRewardsPanel(): TemplateResult | "" {
const rewards = this.userMeResponse?.player?.rewards ?? [];
if (rewards.length === 0) return "";
+75
View File
@@ -13,6 +13,8 @@ import {
PlayerProfileSchema,
PublicPlayerGamesResponse,
PublicPlayerGamesResponseSchema,
PutUsernameResponse,
PutUsernameResponseSchema,
RankedLeaderboardResponse,
RankedLeaderboardResponseSchema,
UserMeResponse,
@@ -229,6 +231,79 @@ export async function setMarketingConsent(
}
}
export type UpdateUsernameResult =
| { ok: true; data: PutUsernameResponse }
| { ok: false; code: "invalid"; message?: string }
| { ok: false; code: "profane" }
| { ok: false; code: "taken" }
| { ok: false; code: "cooldown"; retryAfterSeconds: number | null }
| { ok: false; code: "failed" };
// PUT /users/@me/username — renames the account username. Every failure is
// atomic (no name change, no cooldown consumed). Both 409 bodies ("name
// exclusively held" and "suffix space exhausted") map to "taken": the user
// remedy is the same — pick another name. Invalidates the cached /users/@me
// on success so the next read reflects the new name.
export async function updateUsername(
username: string,
): Promise<UpdateUsernameResult> {
try {
const response = await fetch(`${getApiBase()}/users/@me/username`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({ username }),
});
if (response.status === 401) {
await logOut();
return { ok: false, code: "failed" };
}
if (response.status === 400) {
const body = await response.json().catch(() => null);
if (body?.code === "USERNAME_PROFANE") {
return { ok: false, code: "profane" };
}
return {
ok: false,
code: "invalid",
message: typeof body?.reason === "string" ? body.reason : undefined,
};
}
if (response.status === 409) {
return { ok: false, code: "taken" };
}
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const seconds = retryAfter === null ? NaN : Number(retryAfter);
return {
ok: false,
code: "cooldown",
retryAfterSeconds: Number.isFinite(seconds) ? seconds : null,
};
}
if (!response.ok) {
console.error(
"updateUsername: request failed",
response.status,
response.statusText,
);
return { ok: false, code: "failed" };
}
const parsed = PutUsernameResponseSchema.safeParse(await response.json());
if (!parsed.success) {
console.error("updateUsername: Zod validation failed", parsed.error);
return { ok: false, code: "failed" };
}
invalidateUserMe();
return { ok: true, data: parsed.data };
} catch (e) {
console.error("updateUsername: request failed", e);
return { ok: false, code: "failed" };
}
}
export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag" | "crown" | "effect",
cosmeticName: string,
+34 -7
View File
@@ -1,6 +1,6 @@
import version from "resources/version.txt?raw";
import { ClientEnv } from "src/client/ClientEnv";
import { UserMeResponse } from "../core/ApiSchemas";
import { isTemporaryUsername, UserMeResponse } from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { EventBus } from "../core/EventBus";
import {
@@ -536,14 +536,41 @@ class Client {
"Sharing this ID will allow others to view your game history and stats.",
);
// Unclaimed-rewards popup — only on a clean homepage load, never over
// a deep link (join URL, #modal=..., #purchase-completed, ...).
const rewards = userMeResponse.player.rewards ?? [];
// Popups below only on a clean homepage load, never over a deep link
// (join URL, #modal=..., #purchase-completed, ...).
const cleanHomepage =
window.location.pathname === "/" && window.location.hash === "";
// The server renamed this subscriber to TEMPORARY#### because their
// bare name was exclusively taken while they were unentitled; the
// rename is free (cooldown cleared). Prompt for a real name; takes
// priority over the rewards popup — the account modal shows the
// rewards panel anyway.
const { usernameStatus, usernameBase } = userMeResponse.player;
if (
rewards.length > 0 &&
window.location.pathname === "/" &&
window.location.hash === ""
cleanHomepage &&
(usernameStatus === "premium" || usernameStatus === "indefinite") &&
isTemporaryUsername(usernameBase)
) {
const goRename = await showInGameConfirm(
translateText("account_modal.username_temporary_prompt"),
{
heading: translateText("account_modal.username_title"),
variant: "warning",
confirmText: translateText(
"account_modal.username_temporary_prompt_confirm",
),
},
);
if (goRename) {
window.location.hash = "modal=account";
}
return;
}
// Unclaimed-rewards popup.
const rewards = userMeResponse.player.rewards ?? [];
if (rewards.length > 0 && cleanHomepage) {
this.rewardsModal?.openWithRewards(rewards);
}
}
+270
View File
@@ -0,0 +1,270 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { isTemporaryUsername, UserMeResponse } from "../../core/ApiSchemas";
import {
MAX_ACCOUNT_USERNAME_LENGTH,
MIN_ACCOUNT_USERNAME_LENGTH,
validateAccountUsername,
} from "../../core/validations/username";
import { updateUsername, UpdateUsernameResult } from "../Api";
import { showInGameConfirm } from "../InGameModal";
import { translateText } from "../Utils";
import "./baseComponents/Button";
type UserMePlayer = UserMeResponse["player"];
/**
* Account-username management card for the Account tab. Renders the
* server-resolved display name as-is (never assembles base + suffix), the
* set/change form with client-side validation and the 30-day cooldown, the
* grace-period warning for lapsed claim holders, and the free-rename notice
* after a TEMPORARY#### server rename.
*/
@customElement("username-panel")
export class UsernamePanel extends LitElement {
@property({ attribute: false }) player!: UserMePlayer;
@state() private draft = "";
@state() private busy = false;
@state() private error = "";
createRenderRoot() {
return this;
}
willUpdate(changed: Map<string, unknown>) {
if (changed.has("player")) {
// Prefill with the base only — never put ".suffix" in the input.
this.draft = this.player?.usernameBase ?? "";
this.error = "";
}
}
// The date the player may next self-rename, or null when a rename is
// allowed now (nextUsernameChangeAt may be null OR in the past).
private cooldownEnd(): Date | null {
const at = this.player.nextUsernameChangeAt;
if (!at) return null;
const date = new Date(at);
return date.getTime() > Date.now() ? date : null;
}
private isTemporary(): boolean {
const status = this.player.usernameStatus;
return (
(status === "premium" || status === "indefinite") &&
isTemporaryUsername(this.player.usernameBase)
);
}
private formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
private handleInput(e: Event) {
this.draft = (e.target as HTMLInputElement).value;
const trimmed = this.draft.trim();
if (trimmed.length === 0) {
this.error = "";
return;
}
const result = validateAccountUsername(trimmed);
this.error = result.isValid ? "" : (result.error ?? "");
}
private async handleSave() {
if (this.busy) return;
const name = this.draft.trim();
const validation = validateAccountUsername(name);
if (!validation.isValid) {
this.error = validation.error ?? "";
return;
}
const base = this.player.usernameBase;
const warnings = [translateText("account_modal.username_confirm_body")];
// A case-only (or identical) resubmission still counts as a full rename
// and restarts the cooldown.
if (base !== null && base !== undefined) {
if (name.toLowerCase() === base.toLowerCase()) {
warnings.push(
translateText("account_modal.username_confirm_case_only"),
);
}
// A lapsed claim holder who renames abandons the old reservation
// permanently — it does not transfer to the new name.
if (this.player.usernameStatus === "claimed") {
warnings.push(
translateText("account_modal.username_confirm_abandon", {
name: base,
}),
);
}
}
const confirmed = await showInGameConfirm(warnings.join(" "), {
heading: translateText("account_modal.username_confirm_heading"),
variant: "warning",
confirmText: translateText("account_modal.username_confirm_button"),
});
if (!confirmed) return;
this.busy = true;
const result = await updateUsername(name);
this.busy = false;
if (result.ok) {
// The panel and AccountModal share the same player object, so updating
// it here keeps every consumer consistent; the event just triggers the
// parent re-render.
this.player.username = result.data.username;
this.player.usernameBase = result.data.base;
this.player.usernameDiscriminator = result.data.discriminator;
this.player.usernameStatus = result.data.usernameStatus;
this.player.nextUsernameChangeAt = result.data.nextUsernameChangeAt;
// A rename either kept the claim (premium/indefinite) or abandoned it
// (unclaimed) — either way no grace deadline remains.
this.player.usernameClaimExpiresAt = null;
this.draft = result.data.base;
this.error = "";
this.requestUpdate();
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("account_modal.username_changed", {
name: result.data.username,
}),
color: "green",
duration: 4000,
},
}),
);
this.dispatchEvent(
new CustomEvent("username-changed", {
detail: result.data,
bubbles: true,
composed: true,
}),
);
} else {
this.error = this.errorMessage(result);
}
}
private errorMessage(
result: Exclude<UpdateUsernameResult, { ok: true }>,
): string {
switch (result.code) {
case "profane":
return translateText("account_modal.username_error_profane");
case "taken":
return translateText("account_modal.username_error_taken");
case "cooldown": {
// Only reachable via a race (e.g. a rename on another device) — the
// form is disabled while the client-known cooldown runs.
if (result.retryAfterSeconds !== null) {
return translateText("account_modal.username_error_cooldown", {
days: Math.max(1, Math.ceil(result.retryAfterSeconds / 86_400)),
});
}
return translateText("account_modal.username_error_failed");
}
case "invalid":
return (
result.message ?? translateText("account_modal.username_error_failed")
);
default:
return translateText("account_modal.username_error_failed");
}
}
private renderNotices(): TemplateResult | typeof nothing {
if (this.isTemporary()) {
return html`
<div
class="mt-3 px-3 py-2 rounded-lg border border-amber-500/50 bg-amber-900/30 text-amber-200 text-sm"
>
${translateText("account_modal.username_temporary_notice")}
</div>
`;
}
const claimExpiresAt = this.player.usernameClaimExpiresAt;
if (this.player.usernameStatus === "claimed" && claimExpiresAt) {
return html`
<div
class="mt-3 px-3 py-2 rounded-lg border border-amber-500/50 bg-amber-900/30 text-amber-200 text-sm"
>
${translateText("account_modal.username_grace_warning", {
name: this.player.usernameBase ?? "",
date: this.formatDate(new Date(claimExpiresAt)),
})}
</div>
`;
}
return nothing;
}
render() {
if (!this.player || this.player.usernameStatus === undefined)
return nothing;
const cooldownEnd = this.cooldownEnd();
const locked = cooldownEnd !== null;
const trimmed = this.draft.trim();
const canSave =
!locked &&
!this.busy &&
trimmed.length >= MIN_ACCOUNT_USERNAME_LENGTH &&
this.error === "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">🏷️</span>
${translateText("account_modal.username_title")}
</h3>
${this.player.username
? html`<div class="text-white text-lg font-medium">
${this.player.username}
</div>`
: html`<div class="text-white/50 text-sm">
${translateText("account_modal.username_not_set")}
</div>`}
${this.renderNotices()}
<div class="mt-4 flex items-stretch gap-2">
<input
type="text"
.value=${this.draft}
@input=${this.handleInput}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter" && canSave) void this.handleSave();
}}
placeholder=${translateText("account_modal.username_placeholder")}
maxlength=${MAX_ACCOUNT_USERNAME_LENGTH}
?disabled=${locked || this.busy}
class="flex-1 min-w-0 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 disabled:opacity-50 disabled:hover:bg-white/5"
/>
<o-button
variant="primary"
size="md"
translationKey="account_modal.username_save"
.disable=${!canSave}
@click=${this.handleSave}
></o-button>
</div>
${locked
? html`<div class="mt-2 text-white/50 text-sm">
${translateText("account_modal.username_cooldown_until", {
date: this.formatDate(cooldownEnd),
})}
</div>`
: nothing}
${this.error
? html`<div class="mt-2 text-red-400 text-sm">${this.error}</div>`
: nothing}
</div>
`;
}
}
+49
View File
@@ -108,6 +108,26 @@ export type ClaimAllRewardsResponse = z.infer<
typeof ClaimAllRewardsResponseSchema
>;
// Account-username lifecycle. `unclaimed`: no bare-name reservation (default).
// `claimed`: reservation held but subscription lapsed — the suffix shows again
// and a grace deadline runs. `premium`: subscribed, bare display. `indefinite`:
// admin-locked bare display. Statuses change server-side without client action
// (Stripe webhooks, admin edits) — re-fetch /users/@me rather than caching.
export const UsernameStatusSchema = z.enum([
"unclaimed",
"claimed",
"premium",
"indefinite",
]);
export type UsernameStatus = z.infer<typeof UsernameStatusSchema>;
// When a player subscribes while someone else exclusively holds their bare
// name, the server renames them to TEMPORARY#### and clears their cooldown so
// the rename is free. Detect it to prompt for a real name.
export function isTemporaryUsername(base: string | null | undefined): boolean {
return typeof base === "string" && /^TEMPORARY\d{4}$/.test(base);
}
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -123,6 +143,23 @@ export const UserMeResponseSchema = z.object({
// True when the player may list a custom lobby publicly. The API decides
// which subscriptions/grants confer this.
canCreatePublicLobbies: z.boolean(),
// Account username (custom-usernames). All optional so responses from an
// API without the feature still parse; absent means the same as never set.
// `username` is the server-resolved DISPLAY form — the bare base for an
// entitled claim holder, otherwise "base.suffix". Render it as-is; never
// assemble base + discriminator client-side. The discriminator is exactly
// 4 digits and may have leading zeros — keep it a string.
username: z.string().nullable().optional(),
usernameBase: z.string().nullable().optional(),
usernameDiscriminator: z.string().nullable().optional(),
usernameStatus: UsernameStatusSchema.optional(),
// Only non-null in `claimed`: when the exclusive right to the bare name
// becomes takeable by another subscriber. A past date means "at risk",
// not "lost" — it stays set until the name is actually taken.
usernameClaimExpiresAt: z.iso.datetime().nullable().optional(),
// When the player may next self-rename. May be in the past — past or
// null both mean a rename is allowed.
nextUsernameChangeAt: z.iso.datetime().nullable().optional(),
flares: z.string().array().optional(),
achievements: z.object({
singleplayerMap: z.array(SingleplayerMapAchievementSchema),
@@ -190,6 +227,18 @@ export type UserSubscription = NonNullable<
NonNullable<UserMeResponse["player"]["subscription"]>
>;
// PUT /users/@me/username success payload. `username` is the resolved display
// form (safe for optimistic UI). The suffix is re-rolled on every rename and
// the response carries the fresh 30-day cooldown.
export const PutUsernameResponseSchema = z.object({
username: z.string(),
base: z.string(),
discriminator: z.string(),
usernameStatus: UsernameStatusSchema,
nextUsernameChangeAt: z.iso.datetime().nullable(),
});
export type PutUsernameResponse = z.infer<typeof PutUsernameResponseSchema>;
export const PlayerStatsLeafSchema = z.object({
wins: BigIntStringSchema,
losses: BigIntStringSchema,
+50
View File
@@ -1,3 +1,4 @@
import { z } from "zod";
import { translateText } from "../../client/Utils";
import { ClanTagSchema, UsernameSchema } from "../Schemas";
@@ -6,6 +7,19 @@ export const MAX_USERNAME_LENGTH = 27;
export const MIN_CLAN_TAG_LENGTH = 2;
export const MAX_CLAN_TAG_LENGTH = 5;
export const MIN_ACCOUNT_USERNAME_LENGTH = 3;
export const MAX_ACCOUNT_USERNAME_LENGTH = 20;
// Mirrors the API's account-username rules (infra src/api/lib/Usernames.ts)
// for instant form feedback; profanity and uniqueness stay server-side. No
// dots (the dot separates base from suffix) and no spaces/unicode.
export const AccountUsernameSchema = z
.string()
.trim()
.min(MIN_ACCOUNT_USERNAME_LENGTH)
.max(MAX_ACCOUNT_USERNAME_LENGTH)
.regex(/^[a-zA-Z0-9_-]+$/);
export function validateUsername(username: string): {
isValid: boolean;
error?: string;
@@ -47,6 +61,42 @@ export function validateUsername(username: string): {
return { isValid: true };
}
export function validateAccountUsername(username: string): {
isValid: boolean;
error?: string;
} {
const parsed = AccountUsernameSchema.safeParse(username);
if (!parsed.success) {
const errType = parsed.error.issues[0].code;
if (errType === "too_small") {
return {
isValid: false,
error: translateText("username.too_short", {
min: MIN_ACCOUNT_USERNAME_LENGTH,
}),
};
}
if (errType === "too_big") {
return {
isValid: false,
error: translateText("username.too_long", {
max: MAX_ACCOUNT_USERNAME_LENGTH,
}),
};
}
return {
isValid: false,
error: translateText("username.account_invalid_chars"),
};
}
return { isValid: true };
}
export function validateClanTag(clanTag: string): {
isValid: boolean;
error?: string;
+164
View File
@@ -3,12 +3,14 @@ import {
ClaimRewardResponseSchema,
GoogleUser,
GoogleUserSchema,
isTemporaryUsername,
PlayerGameModeFilterSchema,
PlayerGameResultSchema,
PlayerGameTypeFilterSchema,
PlayerProfileSchema,
PublicPlayerGameSchema,
PublicPlayerGamesResponseSchema,
PutUsernameResponseSchema,
RewardSchema,
UserMeResponseSchema,
} from "../src/core/ApiSchemas";
@@ -403,3 +405,165 @@ describe("UserMeResponseSchema canCreatePublicLobbies", () => {
).toBe(false);
});
});
describe("UserMeResponseSchema account username", () => {
const basePlayer = {
publicId: "p1",
adfree: false,
unlimitedRanked: false,
canCreatePublicLobbies: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
};
it("accepts a lapsed claim holder (suffix showing, grace deadline set)", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: {
...basePlayer,
username: "Bob.4821",
usernameBase: "Bob",
usernameDiscriminator: "4821",
usernameStatus: "claimed",
usernameClaimExpiresAt: "2026-08-17T19:42:00.000Z",
nextUsernameChangeAt: null,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.player.username).toBe("Bob.4821");
expect(result.data.player.usernameStatus).toBe("claimed");
}
});
it("accepts a response without any username fields (older API)", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: basePlayer,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.player.usernameStatus).toBeUndefined();
}
});
it("accepts a player who never set a name (all null, unclaimed)", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: {
...basePlayer,
username: null,
usernameBase: null,
usernameDiscriminator: null,
usernameStatus: "unclaimed",
usernameClaimExpiresAt: null,
nextUsernameChangeAt: null,
},
});
expect(result.success).toBe(true);
});
it("keeps a leading-zero discriminator as a string", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: {
...basePlayer,
username: "Ann.0042",
usernameBase: "Ann",
usernameDiscriminator: "0042",
usernameStatus: "unclaimed",
usernameClaimExpiresAt: null,
nextUsernameChangeAt: null,
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.player.usernameDiscriminator).toBe("0042");
}
});
it("rejects an unknown usernameStatus", () => {
expect(
UserMeResponseSchema.safeParse({
user: {},
player: { ...basePlayer, usernameStatus: "expired" },
}).success,
).toBe(false);
});
it("rejects a non-ISO usernameClaimExpiresAt", () => {
expect(
UserMeResponseSchema.safeParse({
user: {},
player: {
...basePlayer,
usernameStatus: "claimed",
usernameClaimExpiresAt: "next month",
},
}).success,
).toBe(false);
});
});
describe("PutUsernameResponseSchema", () => {
const base = {
username: "NewName.7302",
base: "NewName",
discriminator: "7302",
usernameStatus: "unclaimed",
nextUsernameChangeAt: "2026-08-17T19:42:00.000Z",
};
it("accepts a rename result", () => {
const result = PutUsernameResponseSchema.safeParse(base);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("NewName.7302");
expect(result.data.discriminator).toBe("7302");
}
});
it("accepts a null cooldown", () => {
expect(
PutUsernameResponseSchema.safeParse({
...base,
nextUsernameChangeAt: null,
}).success,
).toBe(true);
});
it("rejects a numeric discriminator (leading zeros would be lost)", () => {
expect(
PutUsernameResponseSchema.safeParse({ ...base, discriminator: 7302 })
.success,
).toBe(false);
});
it("rejects a missing base", () => {
const rest: Record<string, unknown> = { ...base };
delete rest.base;
expect(PutUsernameResponseSchema.safeParse(rest).success).toBe(false);
});
});
describe("isTemporaryUsername", () => {
it.each(["TEMPORARY0042", "TEMPORARY9999"])("detects %s", (name) => {
expect(isTemporaryUsername(name)).toBe(true);
});
it.each([
"temporary1234",
"TEMPORARY123",
"TEMPORARY12345",
"TEMPORARYabcd",
"Bob",
])("rejects %s", (name) => {
expect(isTemporaryUsername(name)).toBe(false);
});
it("handles null and undefined bases", () => {
expect(isTemporaryUsername(null)).toBe(false);
expect(isTemporaryUsername(undefined)).toBe(false);
});
});
+38
View File
@@ -5,8 +5,10 @@ vi.mock("../src/client/Utils", () => ({
}));
import {
MAX_ACCOUNT_USERNAME_LENGTH,
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
validateAccountUsername,
validateClanTag,
validateUsername,
} from "../src/core/validations/username";
@@ -42,6 +44,42 @@ describe("username.ts functions", () => {
});
});
describe("validateAccountUsername", () => {
test("rejects too short", () => {
const res = validateAccountUsername("ab");
expect(res.isValid).toBe(false);
expect(res.error).toContain("username.too_short");
});
test("rejects too long", () => {
const res = validateAccountUsername(
"a".repeat(MAX_ACCOUNT_USERNAME_LENGTH + 1),
);
expect(res.isValid).toBe(false);
expect(res.error).toContain("username.too_long");
});
test("rejects dots (the dot separates base from suffix)", () => {
const res = validateAccountUsername("bob.4821");
expect(res.isValid).toBe(false);
expect(res.error).toBe("username.account_invalid_chars");
});
test("rejects spaces and unicode", () => {
expect(validateAccountUsername("bob smith").isValid).toBe(false);
expect(validateAccountUsername("Üser").isValid).toBe(false);
});
test("accepts letters, digits, underscore, and hyphen", () => {
expect(validateAccountUsername("Good_Name-123").isValid).toBe(true);
});
test("trims before validating length", () => {
expect(validateAccountUsername(" bob ").isValid).toBe(true);
expect(validateAccountUsername(" ab ").isValid).toBe(false);
});
});
describe("validateClanTag", () => {
test("accepts empty clan tag", () => {
const res = validateClanTag("");