diff --git a/resources/lang/en.json b/resources/lang/en.json index 29cb3b0d5..0dee11e64 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -40,7 +40,7 @@ "no_stats": "No stats available yet. Play some games to start tracking.", "not_found": "Not Found", "or": "OR", - "public_player_id": "Public Player ID:", + "public_player_id": "Public ID:", "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", "reward_daily": "Daily subscription reward", @@ -544,7 +544,7 @@ "no_friends": "You haven't added any friends yet.", "outgoing": "Outgoing", "pending_requests": "Pending Requests", - "public_id_placeholder": "Enter their public player ID", + "public_id_placeholder": "Enter their username or player ID", "remove": "Remove", "request_accepted": "Friend request accepted", "request_auto_accepted": "Friend request accepted — you are now friends", @@ -1624,6 +1624,7 @@ "too_long": "Username must not exceed {max} characters.", "too_short": "Username must be at least {min} characters long.", "verified_heading": "Verified name", + "verified_player": "Verified player", "verified_sub_required": "You need an active subscription to play under your verified name.", "verified_sub_required_confirm": "View store", "verified_toggle": "Verified" diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts index 65ea1c252..65e5153a2 100644 --- a/src/client/PlayerProfileModal.ts +++ b/src/client/PlayerProfileModal.ts @@ -1,11 +1,12 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import type { PlayerStatsTree } from "../core/ApiSchemas"; +import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas"; import { fetchPublicPlayerProfile } from "./Api"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; -import "./components/CopyButton"; +import "./components/PlayerName"; import { modalHeader } from "./components/ui/ModalHeader"; +import { verifiedBadge } from "./components/ui/VerifiedBadge"; import { translateText } from "./Utils"; /** Build a shareable profile URL for a publicId. */ @@ -18,9 +19,10 @@ export class PlayerProfileModal extends BaseModal { protected routerName = "profile"; @state() private publicId: string | null = null; + @state() private username: string | null = null; @state() private statsTree: PlayerStatsTree | null = null; @state() private loading = false; - private openedFrom: "clan" | null = null; + private openedFrom: "clan" | "leaderboard" | null = null; protected modalConfig() { return { maxWidth: "960px" }; @@ -29,17 +31,28 @@ export class PlayerProfileModal extends BaseModal { protected renderHeaderSlot() { return modalHeader({ title: translateText("player_profile.title"), + // The account username takes over the title when set — not uppercased + // like the default title, since name casing is meaningful — and the + // right chip then always shows the publicId. + titleContent: this.username + ? html` + ${this.username} + ${isVerifiedUsername(this.username) + ? verifiedBadge("w-5 h-5") + : nothing} + ` + : undefined, onBack: () => this.back(), ariaLabel: translateText("common.back"), rightContent: this.publicId ? html` - + > ` : undefined, }); @@ -82,6 +95,7 @@ export class PlayerProfileModal extends BaseModal { ? args.publicID : null; this.publicId = publicId; + this.username = null; this.statsTree = null; this.loading = publicId !== null; if (publicId !== null) { @@ -95,10 +109,12 @@ export class PlayerProfileModal extends BaseModal { if (this.publicId !== publicId) return; this.loading = false; this.statsTree = profile === false ? null : profile.stats; + this.username = profile === false ? null : (profile.username ?? null); } protected onClose(): void { this.publicId = null; + this.username = null; this.statsTree = null; this.loading = false; this.openedFrom = null; @@ -109,6 +125,11 @@ export class PlayerProfileModal extends BaseModal { this.open({ publicID: publicId }); } + public openFromLeaderboard(publicId: string): void { + this.openedFrom = "leaderboard"; + this.open({ publicID: publicId }); + } + private back(): void { const openedFrom = this.openedFrom; this.close(); @@ -116,6 +137,10 @@ export class PlayerProfileModal extends BaseModal { document .querySelector("clan-modal") ?.returnToMembers(); + } else if (openedFrom === "leaderboard") { + document + .querySelector("leaderboard-modal") + ?.open(); } } } diff --git a/src/client/components/FriendsList.ts b/src/client/components/FriendsList.ts index 5d3836208..8a9ec610d 100644 --- a/src/client/components/FriendsList.ts +++ b/src/client/components/FriendsList.ts @@ -10,7 +10,7 @@ import { sendFriendRequest, } from "../FriendsApi"; import { showToast, translateText } from "../Utils"; -import "./CopyButton"; +import "./PlayerName"; const PAGE_LIMIT = 20; @@ -116,10 +116,16 @@ export class FriendsList extends LitElement { await this.loadAll(); } else { showToast(translateText("friends.request_sent"), "green"); - this.outgoing = [ - ...this.outgoing, - { publicId: target, createdAt: new Date().toISOString() }, - ]; + // Refetch rather than inserting the raw input: the target may have + // been typed as a username, and the server-returned entry carries + // the real publicId + account username (so the verified check shows). + // If the refetch fails the list is just stale until the next load — + // never insert `target`, which may not be a publicId. + const requests = await fetchFriendRequests(); + if (requests) { + this.incoming = requests.incoming; + this.outgoing = requests.outgoing; + } } } finally { this.actionPending = false; @@ -261,7 +267,7 @@ export class FriendsList extends LitElement { }} class="flex-1 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-mono text-sm" placeholder=${translateText("friends.public_id_placeholder")} - maxlength="22" + maxlength="25" ?disabled=${this.actionPending} /> ` + : html``} + ${isVerifiedUsername(this.username) ? verifiedBadge() : nothing} + + `; + } +} diff --git a/src/client/components/clan/ClanBansView.ts b/src/client/components/clan/ClanBansView.ts index 96c580f9c..84177515c 100644 --- a/src/client/components/clan/ClanBansView.ts +++ b/src/client/components/clan/ClanBansView.ts @@ -2,7 +2,7 @@ import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { type ClanBan, fetchClanBans, unbanClanMember } from "../../ClanApi"; import { translateText } from "../../Utils"; -import "../CopyButton"; +import "../PlayerName"; import { formatClanDate, renderLoadingSpinner, @@ -94,9 +94,13 @@ export class ClanBansView extends LitElement { const totalPages = Math.ceil(this.bansTotal / this.bansLimit); const filtered = this.memberSearch - ? this.bans.filter((b) => - b.publicId.toLowerCase().includes(this.memberSearch.toLowerCase()), - ) + ? this.bans.filter((b) => { + const q = this.memberSearch.toLowerCase(); + return ( + b.publicId.toLowerCase().includes(q) || + (b.username?.toLowerCase().includes(q) ?? false) + ); + }) : this.bans; return html` @@ -146,23 +150,17 @@ export class ClanBansView extends LitElement { /> - + ${translateText("clan_modal.banned_by_label")} - + ${formatClanDate(ban.createdAt)} diff --git a/src/client/components/clan/ClanManageView.ts b/src/client/components/clan/ClanManageView.ts index 799c36c36..2cc089bc5 100644 --- a/src/client/components/clan/ClanManageView.ts +++ b/src/client/components/clan/ClanManageView.ts @@ -16,7 +16,7 @@ import { } from "../../ClanApi"; import { translateText } from "../../Utils"; import "../ConfirmDialog"; -import "../CopyButton"; +import "../PlayerName"; import { type ClanRole, defaultOrderForSort, @@ -554,13 +554,10 @@ export class ClanManageView extends LitElement { > ${renderRoleIcon(member.role)} - + ${translateText("clan_modal.joined_date", { date: formatClanDate(member.joinedAt), diff --git a/src/client/components/clan/ClanRequestsView.ts b/src/client/components/clan/ClanRequestsView.ts index 654b34eb5..6c63ae236 100644 --- a/src/client/components/clan/ClanRequestsView.ts +++ b/src/client/components/clan/ClanRequestsView.ts @@ -8,7 +8,7 @@ import { fetchClanRequests, } from "../../ClanApi"; import { translateText } from "../../Utils"; -import "../CopyButton"; +import "../PlayerName"; import { filterRequestsBySearch, formatClanDate, @@ -153,13 +153,10 @@ export class ClanRequestsView extends LitElement { class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4" >
- + ${translateText("clan_modal.requested_on", { tag: this.selectedClan?.tag ?? this.clanTag, diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index 5e36a1ba5..1f866b1b3 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -7,6 +7,7 @@ import type { ClanMemberStats, } from "../../ClanApi"; import { showToast, translateText } from "../../Utils"; +import "../PlayerName"; import "./ClanStatsBreakdown"; export { renderLoadingSpinner } from "../BaseModal"; export { showToast }; @@ -400,13 +401,14 @@ export function renderMemberRow(
- + onViewProfile(member.publicId) + : null} + >
- ${onViewProfile - ? html`` - : ""}
@@ -442,7 +434,9 @@ export function filterMembersBySearch( const q = search.toLowerCase(); return members.filter( (m) => - m.publicId.toLowerCase().includes(q) || m.role.toLowerCase().includes(q), + m.publicId.toLowerCase().includes(q) || + m.role.toLowerCase().includes(q) || + (m.username?.toLowerCase().includes(q) ?? false), ); } @@ -452,5 +446,9 @@ export function filterRequestsBySearch( ): ClanJoinRequest[] { if (!search) return requests; const q = search.toLowerCase(); - return requests.filter((r) => r.publicId.toLowerCase().includes(q)); + return requests.filter( + (r) => + r.publicId.toLowerCase().includes(q) || + (r.username?.toLowerCase().includes(q) ?? false), + ); } diff --git a/src/client/components/clan/ClanTransferView.ts b/src/client/components/clan/ClanTransferView.ts index 8a0944624..2b4c759ba 100644 --- a/src/client/components/clan/ClanTransferView.ts +++ b/src/client/components/clan/ClanTransferView.ts @@ -9,7 +9,7 @@ import { } from "../../ClanApi"; import { translateText } from "../../Utils"; import "../ConfirmDialog"; -import "../CopyButton"; +import "../PlayerName"; import { filterMembersBySearch, renderLoadingSpinner, @@ -169,13 +169,10 @@ export class ClanTransferView extends LitElement { ${renderRoleIcon(m.role)}
- +
({ rank: entry.rank, playerId: entry.public_id, - username: entry.username, + accountUsername: entry.accountUsername ?? null, clanTag: entry.clanTag ?? undefined, elo: entry.elo, games: entry.total, @@ -140,6 +141,16 @@ export class LeaderboardPlayerList extends LitElement { this.schedulePlayerFillCheck(); } + // Same handoff as the clan views: the profile modal's back button reopens + // the leaderboard (its loaded pages survive the close). + private openProfile(publicId: string) { + document + .querySelector< + HTMLElement & { openFromLeaderboard(publicId: string): void } + >("player-profile-modal") + ?.openFromLeaderboard(publicId); + } + // TODO: consider IntersectionObserver for better visibility detection? private isVisible() { return this.isConnected && this.getClientRects().length > 0; @@ -248,9 +259,12 @@ export class LeaderboardPlayerList extends LitElement { ${player.clanTag} ` : ""} - ${player.username} + this.openProfile(player.playerId)} + > @@ -440,9 +454,13 @@ export class LeaderboardPlayerList extends LitElement { ${this.currentUserEntry.clanTag} ` : ""} - ${this.currentUserEntry.username} + + this.openProfile(this.currentUserEntry!.playerId)} + >
diff --git a/src/client/components/ui/VerifiedBadge.ts b/src/client/components/ui/VerifiedBadge.ts new file mode 100644 index 000000000..dd56b1467 --- /dev/null +++ b/src/client/components/ui/VerifiedBadge.ts @@ -0,0 +1,25 @@ +import { html, TemplateResult } from "lit"; +import { translateText } from "../../Utils"; + +// The same blue check-circle as the UsernameInput verified toggle, sized for +// inline placement next to a player name in lists. Callers gate on +// isVerifiedUsername(accountUsername) — never on session/free-form names. +export function verifiedBadge(sizeClass = "w-4 h-4"): TemplateResult { + return html` + ${translateText("username.verified_player")} + + + `; +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index bd0c587bd..482818c30 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -128,6 +128,23 @@ export function isTemporaryUsername(base: string | null | undefined): boolean { return typeof base === "string" && /^TEMPORARY\d{4}$/.test(base); } +// Whether a pre-rendered account username belongs to a verified player +// (premium/indefinite bare-name claim holder). The server renders everyone +// else as "base.suffix" and bases can never contain dots +// (AccountUsernameSchema), so a dotless name IS the bare display — no +// dedicated flag needed on list endpoints. TEMPORARY#### server renames are +// bare too but aren't a chosen name, so they don't get the badge (matches +// the verified-name play toggle's eligibility rule). +export function isVerifiedUsername( + username: string | null | undefined, +): boolean { + return ( + typeof username === "string" && + !username.includes(".") && + !isTemporaryUsername(username) + ); +} + export const UserMeResponseSchema = z.object({ user: z.object({ discord: DiscordUserSchema.optional(), @@ -263,6 +280,11 @@ export type PlayerStatsTree = z.infer; export const PlayerProfileSchema = z.object({ createdAt: z.iso.datetime(), user: DiscordUserSchema.optional(), + // Account username, pre-rendered by the server (bare base or "base.suffix"). + // null = the player never set one. Render `username ?? publicId`; never + // parse it — compare players by publicId only. Optional so responses from + // an API without the field still parse. + username: z.string().nullable().optional(), stats: PlayerStatsTreeSchema, }); export type PlayerProfile = z.infer; @@ -321,7 +343,9 @@ export type PublicPlayerGamesResponse = z.infer< export const PlayerLeaderboardEntrySchema = z.object({ rank: z.number(), playerId: z.string(), - username: LeaderboardUsernameSchema, + // Account username (null = never set). The leaderboard displays this or + // the playerId — the per-session name is deliberately ignored. + accountUsername: z.string().nullable().optional(), clanTag: RequiredClanTagSchema.nullable().optional(), flag: z.string().optional(), elo: z.number(), @@ -351,6 +375,10 @@ export const RankedLeaderboardEntrySchema = z.object({ public_id: z.string(), user: DiscordUserSchema.nullable().optional(), username: LeaderboardUsernameSchema, + // Account username (null = never set), unlike `username` which is the name + // from the player's most recent ranked session. The client displays + // `accountUsername ?? public_id` and ignores the session name. + accountUsername: z.string().nullable().optional(), clanTag: RequiredClanTagSchema.nullable().optional(), }); export type RankedLeaderboardEntry = z.infer< @@ -366,6 +394,9 @@ export type RankedLeaderboardResponse = z.infer< export const FriendEntrySchema = z.object({ publicId: z.string(), + // Account username (null = never set), always the other party's. Render + // `username ?? publicId`; identify players by publicId only. + username: z.string().nullable().optional(), createdAt: z.iso.datetime(), }); export type FriendEntry = z.infer; diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index 99da2b46c..be1d37780 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -128,6 +128,10 @@ export const ClanMemberSchema = z.object({ role: z.enum(["leader", "officer", "member"]), joinedAt: z.iso.datetime(), publicId: z.string(), + // Account username, pre-rendered by the server (null = never set). Render + // `username ?? publicId`; identify players by publicId only. Optional so + // responses from an API without the field still parse. + username: z.string().nullable().optional(), stats: ClanMemberStatsSchema.optional(), }); export type ClanMember = z.infer; @@ -143,6 +147,8 @@ export type ClanMembersResponse = z.infer; export const ClanJoinRequestSchema = z.object({ publicId: z.string(), + // Requester's account username (null = never set). + username: z.string().nullable().optional(), createdAt: z.iso.datetime(), }); export type ClanJoinRequest = z.infer; @@ -157,7 +163,11 @@ export type ClanRequestsResponse = z.infer; export const ClanBanSchema = z.object({ publicId: z.string(), + // Banned player's account username (null = never set). + username: z.string().nullable().optional(), bannedBy: z.string(), + // Account username of the officer who issued the ban. + bannedByUsername: z.string().nullable().optional(), reason: z.string().max(200).nullable(), createdAt: z.iso.datetime(), }); diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts index 00f54b836..732651434 100644 --- a/tests/ApiSchemas.test.ts +++ b/tests/ApiSchemas.test.ts @@ -1,9 +1,11 @@ import { ClaimAllRewardsResponseSchema, ClaimRewardResponseSchema, + FriendEntrySchema, GoogleUser, GoogleUserSchema, isTemporaryUsername, + isVerifiedUsername, PlayerGameModeFilterSchema, PlayerGameResultSchema, PlayerGameTypeFilterSchema, @@ -11,6 +13,7 @@ import { PublicPlayerGameSchema, PublicPlayerGamesResponseSchema, PutUsernameResponseSchema, + RankedLeaderboardEntrySchema, RewardSchema, UserMeResponseSchema, } from "../src/core/ApiSchemas"; @@ -69,6 +72,91 @@ describe("PlayerProfileSchema", () => { .success, ).toBe(false); }); + + it("accepts a pre-rendered account username", () => { + const result = PlayerProfileSchema.safeParse({ + ...base, + username: "bob.4821", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.username).toBe("bob.4821"); + } + }); + + it("accepts username: null (player never set one)", () => { + const result = PlayerProfileSchema.safeParse({ ...base, username: null }); + expect(result.success).toBe(true); + }); + + it("accepts a profile without username (older API)", () => { + const result = PlayerProfileSchema.safeParse(base); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.username).toBeUndefined(); + } + }); +}); + +describe("FriendEntrySchema", () => { + const base = { + publicId: "abc123", + createdAt: "2024-01-15T12:00:00.000Z", + }; + + it("accepts an entry with an account username", () => { + const result = FriendEntrySchema.safeParse({ + ...base, + username: "bob.4821", + }); + expect(result.success).toBe(true); + }); + + it("accepts username: null (friend never set one)", () => { + const result = FriendEntrySchema.safeParse({ ...base, username: null }); + expect(result.success).toBe(true); + }); + + it("accepts an entry without username (older API)", () => { + expect(FriendEntrySchema.safeParse(base).success).toBe(true); + }); +}); + +describe("RankedLeaderboardEntrySchema accountUsername", () => { + const base = { + rank: 1, + elo: 1500, + peakElo: 1600, + wins: 10, + losses: 5, + total: 15, + public_id: "abc123", + username: "xX_Sniper_Xx", + }; + + it("keeps accountUsername verbatim alongside the session username", () => { + const result = RankedLeaderboardEntrySchema.safeParse({ + ...base, + accountUsername: "bob.4821", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.accountUsername).toBe("bob.4821"); + expect(result.data.username).toBe("xX_Sniper_Xx"); + } + }); + + it("accepts accountUsername: null (player never set one)", () => { + const result = RankedLeaderboardEntrySchema.safeParse({ + ...base, + accountUsername: null, + }); + expect(result.success).toBe(true); + }); + + it("accepts an entry without accountUsername (older API)", () => { + expect(RankedLeaderboardEntrySchema.safeParse(base).success).toBe(true); + }); }); describe("PlayerGameModeFilterSchema", () => { @@ -567,3 +655,28 @@ describe("isTemporaryUsername", () => { expect(isTemporaryUsername(undefined)).toBe(false); }); }); + +describe("isVerifiedUsername", () => { + it.each(["bob", "big_boss", "a-b_c9"])( + "treats bare (dotless) display %s as verified", + (name) => { + expect(isVerifiedUsername(name)).toBe(true); + }, + ); + + it.each(["bob.4821", "big_boss.0042"])( + "treats suffixed display %s as not verified", + (name) => { + expect(isVerifiedUsername(name)).toBe(false); + }, + ); + + it("never verifies an unset username", () => { + expect(isVerifiedUsername(null)).toBe(false); + expect(isVerifiedUsername(undefined)).toBe(false); + }); + + it("never verifies a TEMPORARY#### server rename, even though it is bare", () => { + expect(isVerifiedUsername("TEMPORARY1234")).toBe(false); + }); +}); diff --git a/tests/client/LeaderboardModal.test.ts b/tests/client/LeaderboardModal.test.ts index fd3f6362f..0c69d3266 100644 --- a/tests/client/LeaderboardModal.test.ts +++ b/tests/client/LeaderboardModal.test.ts @@ -241,6 +241,7 @@ describe("LeaderboardModal", () => { total: 10, public_id: "player-1", username: "Alpha", + accountUsername: "alpha.4821", clanTag: "[AAA]", }, { @@ -252,6 +253,7 @@ describe("LeaderboardModal", () => { total: 10, public_id: "player-2", username: "Bravo", + accountUsername: null, clanTag: null, }, ], @@ -269,7 +271,7 @@ describe("LeaderboardModal", () => { expect(playerData[0]).toEqual( expect.objectContaining({ playerId: "player-1", - username: "Alpha", + accountUsername: "alpha.4821", clanTag: "[AAA]", elo: 1200, games: 10, @@ -278,10 +280,12 @@ describe("LeaderboardModal", () => { winRate: 0.6, }), ); + // The session username ("Bravo") is deliberately ignored — display + // falls back to the playerId when no account username is set. expect(playerData[1]).toEqual( expect.objectContaining({ playerId: "player-2", - username: "Bravo", + accountUsername: null, clanTag: undefined, winRate: 0.4, }), diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts index 4207108aa..654d4be85 100644 --- a/tests/client/clan/ClanApiSchemas.test.ts +++ b/tests/client/clan/ClanApiSchemas.test.ts @@ -153,6 +153,26 @@ describe("ClanMemberSchema", () => { } }); + it("accepts an account username, null, or absence (older API)", () => { + const base = { + role: "member", + joinedAt: "2024-03-01T09:30:00.000Z", + publicId: "abc123", + }; + const named = ClanMemberSchema.safeParse({ + ...base, + username: "bob.4821", + }); + expect(named.success).toBe(true); + if (named.success) { + expect(named.data.username).toBe("bob.4821"); + } + expect( + ClanMemberSchema.safeParse({ ...base, username: null }).success, + ).toBe(true); + expect(ClanMemberSchema.safeParse(base).success).toBe(true); + }); + it("rejects stats missing a bucket", () => { const result = ClanMemberSchema.safeParse({ role: "member", @@ -183,6 +203,21 @@ describe("ClanJoinRequestSchema", () => { }); expect(result.success).toBe(false); }); + + it("accepts the requester's account username, null, or absence", () => { + const base = { + publicId: "player-xyz", + createdAt: "2024-06-10T08:00:00.000Z", + }; + expect( + ClanJoinRequestSchema.safeParse({ ...base, username: "bob.4821" }) + .success, + ).toBe(true); + expect( + ClanJoinRequestSchema.safeParse({ ...base, username: null }).success, + ).toBe(true); + expect(ClanJoinRequestSchema.safeParse(base).success).toBe(true); + }); }); describe("ClanBanSchema", () => { @@ -227,6 +262,23 @@ describe("ClanBanSchema", () => { const result = ClanBanSchema.safeParse({ ...validBan, bannedBy: null }); expect(result.success).toBe(false); }); + + it("accepts account usernames for both the banned player and the officer", () => { + const result = ClanBanSchema.safeParse({ + ...validBan, + username: null, + bannedByUsername: "bigboss", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.username).toBeNull(); + expect(result.data.bannedByUsername).toBe("bigboss"); + } + }); + + it("accepts a ban without the username fields (older API)", () => { + expect(ClanBanSchema.safeParse(validBan).success).toBe(true); + }); }); describe("ClanGameResultSchema", () => {