diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts
index b151114fc..0246e94d0 100644
--- a/src/core/ClanApiSchemas.ts
+++ b/src/core/ClanApiSchemas.ts
@@ -36,12 +36,45 @@ export const ClanInfoSchema = z.object({
name: z.string().max(35),
tag: RequiredClanTagSchema,
description: z.string().max(200),
+ // Discord invite URL set by the clan leader; null when unset. Optional
+ // because not every ClanInfo source includes it (e.g. browse results).
+ discordUrl: z.string().max(255).nullable().optional(),
isOpen: z.boolean(),
createdAt: z.iso.datetime().optional(),
memberCount: z.number().optional(),
});
export type ClanInfo = z.infer;
+// Client-assembled view model for the clan Discord card. `valid` is false only
+// on a definitive Discord 404 (invite revoked); other failures degrade to the
+// plain link with valid: true.
+export type ClanDiscord = {
+ url: string;
+ valid: boolean;
+ serverName?: string;
+ iconUrl?: string | null;
+ bannerUrl?: string | null;
+ description?: string | null;
+ onlineCount?: number | null;
+ memberCount?: number | null;
+};
+
+// Subset of Discord's public GET /invites/{code}?with_counts=true response,
+// parsed client-side into ClanDiscord. snake_case mirrors Discord's wire format.
+export const DiscordInviteResponseSchema = z.object({
+ guild: z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ icon: z.string().nullable().optional(),
+ banner: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ })
+ .optional(),
+ approximate_member_count: z.number().optional(),
+ approximate_presence_count: z.number().optional(),
+});
+
export const ClanBrowseResponseSchema = z.object({
results: ClanInfoSchema.array(),
total: z.number(),
diff --git a/tests/client/clan/ClanApiMutations.test.ts b/tests/client/clan/ClanApiMutations.test.ts
index 267420acf..d92a1630f 100644
--- a/tests/client/clan/ClanApiMutations.test.ts
+++ b/tests/client/clan/ClanApiMutations.test.ts
@@ -13,6 +13,7 @@ import {
demoteMember,
denyClanRequest,
disbandClan,
+ fetchDiscordInvite,
joinClan,
kickMember,
leaveClan,
@@ -420,4 +421,133 @@ describe("updateClan", () => {
const result = await updateClan("TEST", { name: "x" });
expect(result).toEqual({ error: "clan_modal.error_network" });
});
+
+ it("maps 400 DISCORD_INVALID to the discord invalid error", async () => {
+ mockFetch(() => failRes(400, { code: "DISCORD_INVALID" }));
+ const result = await updateClan("TEST", { discordUrl: "not-a-link" });
+ expect(result).toEqual({ error: "clan_modal.discord_invalid" });
+ });
+
+ it("maps 400 DISCORD_EXPIRES to the discord expires error", async () => {
+ mockFetch(() => failRes(400, { code: "DISCORD_EXPIRES" }));
+ const result = await updateClan("TEST", {
+ discordUrl: "https://discord.gg/temp",
+ });
+ expect(result).toEqual({ error: "clan_modal.discord_expires" });
+ });
+
+ it("returns generic error on 400 with an unrecognised code", async () => {
+ mockFetch(() => failRes(400, { code: "SOMETHING_ELSE" }));
+ const result = await updateClan("TEST", { name: "x" });
+ expect(result).toEqual({ error: "clan_modal.error_failed" });
+ });
+
+ it("maps 429 to the discord rate limited error", async () => {
+ mockFetch(() => failRes(429));
+ const result = await updateClan("TEST", {
+ discordUrl: "https://discord.gg/abc",
+ });
+ expect(result).toEqual({ error: "clan_modal.discord_rate_limited" });
+ });
+});
+
+describe("fetchDiscordInvite", () => {
+ const inviteBody = {
+ guild: {
+ id: "123",
+ name: "Test Server",
+ icon: "abc",
+ banner: "a_def",
+ description: "A server",
+ },
+ approximate_member_count: 100,
+ approximate_presence_count: 42,
+ };
+
+ it("returns metadata with CDN asset URLs on success", async () => {
+ mockFetch(() => okJson(inviteBody));
+ const result = await fetchDiscordInvite("https://discord.gg/abc123");
+ expect(result).toMatchObject({
+ url: "https://discord.gg/abc123",
+ valid: true,
+ serverName: "Test Server",
+ description: "A server",
+ onlineCount: 42,
+ memberCount: 100,
+ });
+ expect(result.iconUrl).toBe("https://cdn.discordapp.com/icons/123/abc.png");
+ // Animated banner (a_ prefix) is served as .gif.
+ expect(result.bannerUrl).toBe(
+ "https://cdn.discordapp.com/banners/123/a_def.gif?size=1024",
+ );
+ });
+
+ it("parses the code from the stored discord.gg/{code} URL", async () => {
+ const fetchMock = vi.fn(() => okJson(inviteBody));
+ vi.stubGlobal("fetch", fetchMock);
+ await fetchDiscordInvite("https://discord.gg/xyz789");
+ const [requestUrl] = fetchMock.mock.calls[0] as unknown as [string];
+ expect(requestUrl).toContain("/invites/xyz789");
+ });
+
+ it("marks the invite invalid on a Discord 404", async () => {
+ mockFetch(() => failRes(404));
+ const result = await fetchDiscordInvite("https://discord.gg/gone");
+ expect(result).toEqual({ url: "https://discord.gg/gone", valid: false });
+ });
+
+ it("degrades to the plain link when Discord is unreachable", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.reject(new Error("network"))),
+ );
+ const result = await fetchDiscordInvite("https://discord.gg/x");
+ expect(result).toEqual({ url: "https://discord.gg/x", valid: true });
+ });
+
+ it("degrades to the plain link on a non-404 error status", async () => {
+ mockFetch(() => failRes(500));
+ const result = await fetchDiscordInvite("https://discord.gg/x");
+ expect(result).toEqual({ url: "https://discord.gg/x", valid: true });
+ });
+
+ it("returns valid with no metadata when the response lacks a guild", async () => {
+ mockFetch(() => okJson({ approximate_member_count: 5 }));
+ const result = await fetchDiscordInvite("https://discord.gg/x");
+ expect(result).toEqual({ url: "https://discord.gg/x", valid: true });
+ });
+
+ it("returns the plain link for an unparseable URL without fetching", async () => {
+ const fetchMock = vi.fn(() => okJson(inviteBody));
+ vi.stubGlobal("fetch", fetchMock);
+ const result = await fetchDiscordInvite("not a url");
+ expect(result).toEqual({ url: "not a url", valid: true });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("passes an AbortSignal to fetch so the request can time out", async () => {
+ const fetchMock = vi.fn(() => okJson(inviteBody));
+ vi.stubGlobal("fetch", fetchMock);
+ await fetchDiscordInvite("https://discord.gg/abc123");
+ const [, init] = fetchMock.mock.calls[0] as unknown as [
+ string,
+ { signal: AbortSignal },
+ ];
+ // Pins the AbortSignal.timeout(5000) guard; without it the card could hang
+ // indefinitely on a stalled connection.
+ expect(init.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ it("degrades to the plain link when the request times out", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() =>
+ Promise.reject(
+ new DOMException("The operation timed out", "TimeoutError"),
+ ),
+ ),
+ );
+ const result = await fetchDiscordInvite("https://discord.gg/slow");
+ expect(result).toEqual({ url: "https://discord.gg/slow", valid: true });
+ });
});
diff --git a/tests/client/clan/ClanApiSchemas.test.ts b/tests/client/clan/ClanApiSchemas.test.ts
index c94edc39f..d66e13ea1 100644
--- a/tests/client/clan/ClanApiSchemas.test.ts
+++ b/tests/client/clan/ClanApiSchemas.test.ts
@@ -57,6 +57,34 @@ describe("ClanInfoSchema", () => {
expect(result.data.memberCount).toBeUndefined();
}
});
+
+ it("accepts a string discordUrl", () => {
+ const result = ClanInfoSchema.safeParse({
+ ...base,
+ discordUrl: "https://discord.gg/abc123",
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("accepts a null discordUrl (link unset)", () => {
+ const result = ClanInfoSchema.safeParse({ ...base, discordUrl: null });
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data.discordUrl).toBeNull();
+ });
+
+ it("accepts data without discordUrl (omitted by browse results)", () => {
+ const result = ClanInfoSchema.safeParse(base);
+ expect(result.success).toBe(true);
+ if (result.success) expect(result.data.discordUrl).toBeUndefined();
+ });
+
+ it("rejects a discordUrl longer than 255 characters", () => {
+ const result = ClanInfoSchema.safeParse({
+ ...base,
+ discordUrl: "https://discord.gg/" + "a".repeat(255),
+ });
+ expect(result.success).toBe(false);
+ });
});
describe("ClanMemberSchema", () => {
diff --git a/tests/client/clan/ClanModalTestUtils.ts b/tests/client/clan/ClanModalTestUtils.ts
index 05c9f040e..95a5dbef9 100644
--- a/tests/client/clan/ClanModalTestUtils.ts
+++ b/tests/client/clan/ClanModalTestUtils.ts
@@ -67,6 +67,9 @@ export function clanApiMockFactory() {
results: [],
nextCursor: null,
})),
+ // ClanDetailView calls this when a clan has a discordUrl; mock the degraded
+ // plain-link result so view tests never reach the real Discord network.
+ fetchDiscordInvite: vi.fn(async (url: string) => ({ url, valid: true })),
};
}