mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:53:02 +00:00
## 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>
570 lines
16 KiB
TypeScript
570 lines
16 KiB
TypeScript
import {
|
|
ClaimAllRewardsResponseSchema,
|
|
ClaimRewardResponseSchema,
|
|
GoogleUser,
|
|
GoogleUserSchema,
|
|
isTemporaryUsername,
|
|
PlayerGameModeFilterSchema,
|
|
PlayerGameResultSchema,
|
|
PlayerGameTypeFilterSchema,
|
|
PlayerProfileSchema,
|
|
PublicPlayerGameSchema,
|
|
PublicPlayerGamesResponseSchema,
|
|
PutUsernameResponseSchema,
|
|
RewardSchema,
|
|
UserMeResponseSchema,
|
|
} from "../src/core/ApiSchemas";
|
|
|
|
describe("GoogleUserSchema", () => {
|
|
it("accepts a valid email", () => {
|
|
const result = GoogleUserSchema.safeParse({ email: "user@example.com" });
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.email).toBe("user@example.com");
|
|
}
|
|
});
|
|
|
|
it("rejects a missing email", () => {
|
|
expect(GoogleUserSchema.safeParse({}).success).toBe(false);
|
|
});
|
|
|
|
it("rejects a non-string email", () => {
|
|
expect(GoogleUserSchema.safeParse({ email: 123 }).success).toBe(false);
|
|
});
|
|
|
|
it("infers the GoogleUser type from the schema", () => {
|
|
// Compile-time check that GoogleUser is derived from the schema.
|
|
const user: GoogleUser = { email: "typed@example.com" };
|
|
expect(user.email).toBe("typed@example.com");
|
|
});
|
|
});
|
|
|
|
describe("PlayerProfileSchema", () => {
|
|
const base = {
|
|
createdAt: "2024-01-15T12:00:00.000Z",
|
|
stats: {},
|
|
};
|
|
|
|
it("accepts a profile without a games array (moved to its own endpoint)", () => {
|
|
const result = PlayerProfileSchema.safeParse(base);
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it("ignores a legacy games field rather than failing the parse", () => {
|
|
const result = PlayerProfileSchema.safeParse({
|
|
...base,
|
|
games: [{ gameId: "g1" }],
|
|
});
|
|
// Zod strips unknown keys by default, so an old server response that still
|
|
// carries games[] parses cleanly without the field surfacing.
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect("games" in result.data).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("rejects a non-ISO createdAt", () => {
|
|
expect(
|
|
PlayerProfileSchema.safeParse({ ...base, createdAt: "yesterday" })
|
|
.success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PlayerGameModeFilterSchema", () => {
|
|
it.each(["ffa", "team", "hvn", "ranked"])("accepts %s", (value) => {
|
|
expect(PlayerGameModeFilterSchema.safeParse(value).success).toBe(true);
|
|
});
|
|
|
|
it("rejects 'all' (filter omission is encoded by absence, not a value)", () => {
|
|
expect(PlayerGameModeFilterSchema.safeParse("all").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PlayerGameTypeFilterSchema", () => {
|
|
it.each(["public", "private", "singleplayer"])("accepts %s", (value) => {
|
|
expect(PlayerGameTypeFilterSchema.safeParse(value).success).toBe(true);
|
|
});
|
|
|
|
it("rejects an unknown type value", () => {
|
|
expect(PlayerGameTypeFilterSchema.safeParse("ranked").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PlayerGameResultSchema", () => {
|
|
it.each(["victory", "defeat", "incomplete"])("accepts %s", (value) => {
|
|
expect(PlayerGameResultSchema.safeParse(value).success).toBe(true);
|
|
});
|
|
|
|
it("rejects an unknown result value", () => {
|
|
expect(PlayerGameResultSchema.safeParse("win").success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PublicPlayerGameSchema", () => {
|
|
const validGame = {
|
|
gameId: "g1",
|
|
start: "2024-06-01T00:00:00.000Z",
|
|
durationSeconds: 1234,
|
|
map: "World",
|
|
mode: "Team",
|
|
type: "Public",
|
|
playerTeams: "Duos",
|
|
rankedType: "unranked",
|
|
result: "victory" as const,
|
|
totalPlayers: 8,
|
|
username: "alice",
|
|
clanTag: "ABC",
|
|
};
|
|
|
|
it("accepts a fully-populated game", () => {
|
|
expect(PublicPlayerGameSchema.safeParse(validGame).success).toBe(true);
|
|
});
|
|
|
|
it("normalizes accidental whitespace around archived map names", () => {
|
|
const result = PublicPlayerGameSchema.safeParse({
|
|
...validGame,
|
|
map: "Deglaciated Antarctica ",
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) return;
|
|
expect(result.data.map).toBe("Deglaciated Antarctica");
|
|
});
|
|
|
|
it("accepts clanTag: null (not repping a clan)", () => {
|
|
expect(
|
|
PublicPlayerGameSchema.safeParse({ ...validGame, clanTag: null }).success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects a missing username", () => {
|
|
const withoutUsername: Record<string, unknown> = { ...validGame };
|
|
delete withoutUsername.username;
|
|
expect(PublicPlayerGameSchema.safeParse(withoutUsername).success).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("accepts playerTeams: null (FFA / non-team games)", () => {
|
|
expect(
|
|
PublicPlayerGameSchema.safeParse({ ...validGame, playerTeams: null })
|
|
.success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("accepts totalPlayers: null (historical rows)", () => {
|
|
expect(
|
|
PublicPlayerGameSchema.safeParse({ ...validGame, totalPlayers: null })
|
|
.success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects a negative durationSeconds", () => {
|
|
expect(
|
|
PublicPlayerGameSchema.safeParse({ ...validGame, durationSeconds: -1 })
|
|
.success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects a non-ISO start", () => {
|
|
expect(
|
|
PublicPlayerGameSchema.safeParse({ ...validGame, start: "June 1 2024" })
|
|
.success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("PublicPlayerGamesResponseSchema", () => {
|
|
const validGame = {
|
|
gameId: "g1",
|
|
start: "2024-06-01T00:00:00.000Z",
|
|
durationSeconds: 1234,
|
|
map: "World",
|
|
mode: "Free For All",
|
|
type: "Public",
|
|
playerTeams: null,
|
|
rankedType: "unranked",
|
|
result: "defeat" as const,
|
|
totalPlayers: 20,
|
|
username: "bob",
|
|
clanTag: null,
|
|
};
|
|
|
|
it("accepts a non-empty page with an opaque cursor", () => {
|
|
const result = PublicPlayerGamesResponseSchema.safeParse({
|
|
results: [validGame],
|
|
nextCursor: "opaque-cursor-abc123",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.nextCursor).toBe("opaque-cursor-abc123");
|
|
}
|
|
});
|
|
|
|
it("accepts an empty page with a null cursor", () => {
|
|
expect(
|
|
PublicPlayerGamesResponseSchema.safeParse({
|
|
results: [],
|
|
nextCursor: null,
|
|
}).success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects when nextCursor is missing (must be string or null)", () => {
|
|
expect(
|
|
PublicPlayerGamesResponseSchema.safeParse({ results: [] }).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("RewardSchema", () => {
|
|
const validReward = {
|
|
id: "42",
|
|
currencyType: "hard",
|
|
amount: "500",
|
|
reason: "subscription_signup_bonus",
|
|
note: "Subscription signup bonus (Gold)",
|
|
};
|
|
|
|
it("accepts a fully-populated reward", () => {
|
|
expect(RewardSchema.safeParse(validReward).success).toBe(true);
|
|
});
|
|
|
|
it("keeps id and amount as strings (bigints can exceed MAX_SAFE_INTEGER)", () => {
|
|
const result = RewardSchema.safeParse({
|
|
...validReward,
|
|
amount: "9007199254740993",
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.id).toBe("42");
|
|
expect(result.data.amount).toBe("9007199254740993");
|
|
}
|
|
});
|
|
|
|
it("accepts note: null", () => {
|
|
expect(RewardSchema.safeParse({ ...validReward, note: null }).success).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("accepts an unknown reason (open-ended by design)", () => {
|
|
expect(
|
|
RewardSchema.safeParse({ ...validReward, reason: "future_source" })
|
|
.success,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects an unknown currencyType", () => {
|
|
expect(
|
|
RewardSchema.safeParse({ ...validReward, currencyType: "gems" }).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("UserMeResponseSchema rewards", () => {
|
|
const basePlayer = {
|
|
publicId: "p1",
|
|
adfree: false,
|
|
unlimitedRanked: false,
|
|
canCreatePublicLobbies: false,
|
|
achievements: { singleplayerMap: [] },
|
|
friends: [],
|
|
subscription: null,
|
|
};
|
|
|
|
it("accepts a player with unclaimed rewards", () => {
|
|
const result = UserMeResponseSchema.safeParse({
|
|
user: {},
|
|
player: {
|
|
...basePlayer,
|
|
rewards: [
|
|
{
|
|
id: "42",
|
|
currencyType: "soft",
|
|
amount: "150",
|
|
reason: "subscription_daily",
|
|
note: "Daily Gold subscription reward (5 days)",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.player.rewards).toHaveLength(1);
|
|
}
|
|
});
|
|
|
|
it("accepts a response without rewards (older API versions)", () => {
|
|
expect(
|
|
UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("UserMeResponseSchema unlimitedRanked", () => {
|
|
const basePlayer = {
|
|
publicId: "p1",
|
|
adfree: false,
|
|
canCreatePublicLobbies: false,
|
|
achievements: { singleplayerMap: [] },
|
|
friends: [],
|
|
subscription: null,
|
|
};
|
|
|
|
it("accepts a player exempt from ranked play limits", () => {
|
|
const result = UserMeResponseSchema.safeParse({
|
|
user: {},
|
|
player: { ...basePlayer, unlimitedRanked: true },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.player.unlimitedRanked).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects a response without unlimitedRanked", () => {
|
|
expect(
|
|
UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("claim response schemas", () => {
|
|
it("coerces claim balances from bigint strings to numbers", () => {
|
|
const result = ClaimRewardResponseSchema.safeParse({
|
|
id: "42",
|
|
currencyType: "hard",
|
|
amount: "500",
|
|
claimedAt: "2026-07-09T18:03:11.000Z",
|
|
currency: { soft: "1200", hard: "850" },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.currency).toEqual({ soft: 1200, hard: 850 });
|
|
}
|
|
});
|
|
|
|
it("accepts a claim-all with nothing pending", () => {
|
|
const result = ClaimAllRewardsResponseSchema.safeParse({
|
|
claimed: [],
|
|
currency: { soft: "0", hard: "0" },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.claimed).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("accepts a claim-all with claimed rewards", () => {
|
|
const result = ClaimAllRewardsResponseSchema.safeParse({
|
|
claimed: [
|
|
{
|
|
id: "42",
|
|
currencyType: "hard",
|
|
amount: "500",
|
|
reason: "subscription_signup_bonus",
|
|
note: null,
|
|
claimedAt: "2026-07-09T18:03:11.000Z",
|
|
},
|
|
],
|
|
currency: { soft: "1200", hard: "850" },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.claimed).toEqual([{ id: "42" }]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("UserMeResponseSchema canCreatePublicLobbies", () => {
|
|
const basePlayer = {
|
|
publicId: "p1",
|
|
adfree: false,
|
|
unlimitedRanked: false,
|
|
achievements: { singleplayerMap: [] },
|
|
friends: [],
|
|
subscription: null,
|
|
};
|
|
|
|
it("accepts a player allowed to list lobbies publicly", () => {
|
|
const result = UserMeResponseSchema.safeParse({
|
|
user: {},
|
|
player: { ...basePlayer, canCreatePublicLobbies: true },
|
|
});
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data.player.canCreatePublicLobbies).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects a response without canCreatePublicLobbies", () => {
|
|
expect(
|
|
UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success,
|
|
).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);
|
|
});
|
|
});
|