Clan System Part 2 - UI (#3625)

## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Ryan
2026-05-01 04:27:35 +01:00
committed by GitHub
parent 38bbef6ecf
commit df05d21fc2
32 changed files with 7018 additions and 102 deletions
+13 -7
View File
@@ -44,13 +44,6 @@ vi.mock("../../src/client/Api", () => {
return {
getApiBase: vi.fn(getApiBase),
getUserMe: vi.fn(async () => false),
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
@@ -71,6 +64,19 @@ vi.mock("../../src/client/Api", () => {
};
});
vi.mock("../../src/client/ClanApi", () => {
const getApiBase = () => "http://localhost:3000";
return {
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
};
});
const jsonRes = (data: any, ok = true, status = 200) => ({
ok,
status,
+166
View File
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
banClanMember,
fetchClanBans,
unbanClanMember,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("banClanMember", () => {
it("returns true on 204 success", async () => {
mockFetch(() => ({ ok: true, status: 204, json: async () => ({}) }));
const result = await banClanMember("TEST", "player-1");
expect(result).toBe(true);
});
it("sends reason in request body when provided", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve({ ok: true, status: 204, json: async () => ({}) }),
);
vi.stubGlobal("fetch", fetchSpy);
await banClanMember("TEST", "player-1", "spamming");
const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
expect(body).toEqual({ targetPublicId: "player-1", reason: "spamming" });
});
it("omits reason from request body when not provided", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve({ ok: true, status: 204, json: async () => ({}) }),
);
vi.stubGlobal("fetch", fetchSpy);
await banClanMember("TEST", "player-1");
const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
expect(body).toEqual({ targetPublicId: "player-1" });
expect(body).not.toHaveProperty("reason");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient permissions" }));
const result = await banClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await banClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("unbanClanMember", () => {
it("returns true on success", async () => {
mockFetch(() => ({ ok: true, status: 204, json: async () => ({}) }));
const result = await unbanClanMember("TEST", "player-1");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(409, { message: "Player not currently banned" }));
const result = await unbanClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await unbanClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("fetchClanBans", () => {
const bansResponse = {
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: "toxic",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(bansResponse));
const result = await fetchClanBans("TEST");
expect(result).toEqual(bansResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(bansResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanBans("TEST", 2, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("2");
expect(url.searchParams.get("limit")).toBe("10");
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(403));
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-an-array", total: 0 }));
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
approveClanRequest,
demoteMember,
denyClanRequest,
disbandClan,
joinClan,
kickMember,
leaveClan,
promoteMember,
transferLeadership,
updateClan,
withdrawClanRequest,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
headers: new Headers(),
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("joinClan", () => {
it("returns { status: 'joined' } on success", async () => {
mockFetch(() => okJson({ status: "joined" }));
const result = await joinClan("TEST");
expect(result).toEqual({ status: "joined" });
});
it("returns { status: 'requested' } for open-request clans", async () => {
mockFetch(() => okJson({ status: "requested" }));
const result = await joinClan("CLSD");
expect(result).toEqual({ status: "requested" });
});
it("returns error key on 409 (already member)", async () => {
mockFetch(() => failRes(409));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_already_member" });
});
it("returns request pending error on 409 when message contains 'request'", async () => {
mockFetch(() => failRes(409, { message: "join request already pending" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_request_pending" });
});
it("returns rate limited error on 429", async () => {
mockFetch(() => failRes(429));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_rate_limited_generic" });
});
it("returns generic error on other non-ok response", async () => {
mockFetch(() => failRes(400, { message: "clan is full" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("gone"))),
);
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
it("returns banned error with reason on 403 BANNED with reason", async () => {
mockFetch(() => failRes(403, { code: "BANNED", reason: "toxic behavior" }));
const result = await joinClan("TEST");
expect(result).toEqual({
error: "clan_modal.error_banned_reason",
reason: "toxic behavior",
});
});
it("returns banned error without reason on 403 BANNED with null reason", async () => {
mockFetch(() => failRes(403, { code: "BANNED", reason: null }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_banned" });
});
it("returns generic 403 error when code is not BANNED", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns fallback error when 403 body has no code or message", async () => {
mockFetch(() => failRes(403, {}));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
});
describe("leaveClan", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await leaveClan("TEST");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(400, { message: "not a member" }));
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns generic error when no message in failure body", async () => {
mockFetch(() => failRes(500, {}));
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("kickMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await kickMember("TEST", "player-1");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await kickMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await kickMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("promoteMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await promoteMember("TEST", "player-2");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient permissions" }));
const result = await promoteMember("TEST", "player-2");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await promoteMember("TEST", "player-2");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("demoteMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await demoteMember("TEST", "player-3");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(400, { message: "cannot demote leader" }));
const result = await demoteMember("TEST", "player-3");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await demoteMember("TEST", "player-3");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("transferLeadership", () => {
it("returns true on success and POSTs to /transfer with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await transferLeadership("TEST", "player-4");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/transfer");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "player-4" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not the leader" }));
const result = await transferLeadership("TEST", "player-4");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await transferLeadership("TEST", "player-4");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("disbandClan", () => {
it("returns true on success and uses DELETE", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await disbandClan("TEST");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string },
];
expect(url).toContain("/clans/TEST");
expect(init.method).toBe("DELETE");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not the leader" }));
const result = await disbandClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await disbandClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
it("encodes the tag in the URL path", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
await disbandClan("A B");
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toContain("/clans/A%20B");
});
});
describe("withdrawClanRequest", () => {
it("returns true on success and POSTs to /requests/withdraw", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await withdrawClanRequest("TEST");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string },
];
expect(url).toContain("/clans/TEST/requests/withdraw");
expect(init.method).toBe("POST");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(404, { message: "no pending request" }));
const result = await withdrawClanRequest("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await withdrawClanRequest("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("approveClanRequest", () => {
it("returns true on success and POSTs to /requests/approve with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/requests/approve");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "applicant-1" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient role" }));
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("denyClanRequest", () => {
it("returns true on success and POSTs to /requests/deny with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/requests/deny");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "applicant-2" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(404, { message: "no such request" }));
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("updateClan", () => {
const validClan = {
name: "Updated Clan",
tag: "TEST",
description: "New description",
isOpen: false,
createdAt: "2024-01-01T00:00:00.000Z",
memberCount: 10,
};
it("returns parsed ClanInfo on success and uses PATCH", async () => {
const fetchMock = vi.fn(() => okJson(validClan));
vi.stubGlobal("fetch", fetchMock);
const result = await updateClan("TEST", { name: "Updated Clan" });
expect(result).toEqual(validClan);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST");
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body)).toEqual({ name: "Updated Clan" });
});
it("returns error object on non-ok response", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await updateClan("TEST", { isOpen: true });
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns error object when Zod validation fails on 200 body", async () => {
mockFetch(() => okJson({ tag: 123, name: null }));
const result = await updateClan("TEST", { description: "x" });
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await updateClan("TEST", { name: "x" });
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
+396
View File
@@ -0,0 +1,396 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
fetchClanDetail,
fetchClanLeaderboard,
fetchClanMembers,
fetchClanRequests,
fetchClans,
fetchClanStats,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("fetchClanLeaderboard", () => {
const leaderboardData = {
start: "2024-01-01T00:00:00.000Z",
end: "2024-01-07T23:59:59.000Z",
clans: [],
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(leaderboardData));
const result = await fetchClanLeaderboard();
expect(result).toEqual(leaderboardData);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(500));
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("Network failure"))),
);
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ start: "bad-date", end: "bad-date", clans: [] }));
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
});
describe("fetchClanStats", () => {
const clanStats = {
clanTag: "TEST",
games: 20,
wins: 15,
losses: 5,
stats: {
total: { wins: 15, losses: 5 },
ffa: { wins: 7, losses: 3 },
team: { wins: 4, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 3, losses: 1 },
"1v1": { wins: 3, losses: 1 },
},
teamTypeWL: { ffa: { wl: [15, 5] } },
teamCountWL: { "2": { wl: [10, 3] } },
};
it("returns parsed data from json.clan on success", async () => {
mockFetch(() => okJson({ clan: clanStats }));
const result = await fetchClanStats("TEST");
expect(result).toEqual(clanStats);
});
it("returns false when json.clan is missing", async () => {
mockFetch(() => okJson({}));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(404));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
});
describe("fetchClanDetail", () => {
const clanInfo = {
name: "Test Clan",
tag: "TEST",
description: "We test things",
isOpen: false,
createdAt: "2024-01-01T00:00:00.000Z",
memberCount: 10,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(clanInfo));
const result = await fetchClanDetail("TEST");
expect(result).toEqual(clanInfo);
});
it("returns false on 404", async () => {
mockFetch(() => failRes(404));
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("timeout"))),
);
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ tag: 123, name: null, isOpen: "not-a-boolean" }));
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
});
describe("fetchClans", () => {
const browseResponse = {
results: [],
total: 0,
page: 1,
limit: 20,
};
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans(undefined, 3, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("3");
expect(url.searchParams.get("limit")).toBe("10");
});
it("passes search param when provided and long enough", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("abc", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("search")).toBe("abc");
});
it("omits search param for 2-char query (below min length of 3)", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("AB", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("search")).toBeNull();
});
it("omits search param when too short and non-alphanumeric", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("a", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.has("search")).toBe(false);
});
it("returns false on failure", async () => {
mockFetch(() => failRes(500));
const result = await fetchClans();
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-an-array", total: "bad" }));
const result = await fetchClans();
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClans();
expect(result).toBe(false);
});
});
describe("fetchClanMembers", () => {
const membersResponse = {
results: [
{
publicId: "abc123",
role: "leader",
joinedAt: "2024-01-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(membersResponse));
const result = await fetchClanMembers("TEST");
expect(result).toEqual(membersResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(membersResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanMembers("TEST", 3, 50);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("3");
expect(url.searchParams.get("limit")).toBe("50");
});
it("includes the optional pendingRequests field", async () => {
mockFetch(() => okJson({ ...membersResponse, pendingRequests: 5 }));
const result = await fetchClanMembers("TEST");
expect(result).not.toBe(false);
if (result) expect(result.pendingRequests).toBe(5);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(500));
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-array", total: "bad" }));
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("sends Authorization header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(membersResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanMembers("TEST");
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer test-token");
});
});
describe("fetchClanRequests", () => {
const requestsResponse = {
results: [
{
publicId: "player1",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(requestsResponse));
const result = await fetchClanRequests("TEST");
expect(result).toEqual(requestsResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(requestsResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanRequests("TEST", 2, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("2");
expect(url.searchParams.get("limit")).toBe("10");
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(403));
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: 42, total: "bad" }));
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("sends Authorization header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(requestsResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanRequests("TEST");
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer test-token");
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import {
ClanBanSchema,
ClanInfoSchema,
ClanJoinRequestSchema,
ClanMemberSchema,
ClanStatsSchema,
} from "../../../src/core/ClanApiSchemas";
describe("ClanInfoSchema", () => {
const base = {
name: "Test Clan",
tag: "TEST",
description: "A clan",
isOpen: true,
};
it("accepts valid data with ISO datetime createdAt", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "2024-01-15T12:00:00.000Z",
memberCount: 5,
});
expect(result.success).toBe(true);
});
it("rejects non-ISO strings for createdAt", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "January 15, 2024",
});
expect(result.success).toBe(false);
});
it("accepts data without optional createdAt", () => {
const result = ClanInfoSchema.safeParse(base);
expect(result.success).toBe(true);
});
it("accepts data without optional memberCount", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "2024-01-15T12:00:00.000Z",
});
expect(result.success).toBe(true);
});
it("accepts data with neither createdAt nor memberCount", () => {
const result = ClanInfoSchema.safeParse(base);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.createdAt).toBeUndefined();
expect(result.data.memberCount).toBeUndefined();
}
});
});
describe("ClanMemberSchema", () => {
it("accepts a valid member with ISO datetime joinedAt", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
});
expect(result.success).toBe(true);
});
it("rejects a plain string for joinedAt", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "last Tuesday",
publicId: "abc123",
});
expect(result.success).toBe(false);
});
it("rejects null publicId", () => {
const result = ClanMemberSchema.safeParse({
role: "leader",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: null,
});
expect(result.success).toBe(false);
});
it("accepts stats with total/ffa/team/ranked/1v1 win-loss breakdown", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
stats: {
total: { wins: 8, losses: 8 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 1, losses: 3 },
"1v1": { wins: 1, losses: 3 },
},
});
expect(result.success).toBe(true);
});
it("treats stats as optional for backwards compatibility", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.stats).toBeUndefined();
}
});
it("rejects stats missing a bucket", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
stats: {
ffa: { wins: 1, losses: 1 },
team: { wins: 1, losses: 1 },
},
});
expect(result.success).toBe(false);
});
});
describe("ClanJoinRequestSchema", () => {
it("accepts a valid join request with ISO datetime createdAt", () => {
const result = ClanJoinRequestSchema.safeParse({
publicId: "player-xyz",
createdAt: "2024-06-10T08:00:00.000Z",
});
expect(result.success).toBe(true);
});
it("rejects a plain string for createdAt", () => {
const result = ClanJoinRequestSchema.safeParse({
publicId: "player-xyz",
createdAt: "2024-06-10",
});
expect(result.success).toBe(false);
});
});
describe("ClanStatsSchema", () => {
const validStats = {
clanTag: "ABcd1",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
teamTypeWL: { ffa: { wl: [7, 3] } },
teamCountWL: { "2": { wl: [4, 1] } },
};
it("accepts a valid clan tag (2-5 alphanumeric chars)", () => {
for (const tag of ["AB", "abc12", "XYZAB"]) {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: tag });
expect(result.success, `tag "${tag}" should be valid`).toBe(true);
}
});
it("rejects tags that are too short", () => {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: "A" });
expect(result.success).toBe(false);
});
it("rejects tags that are too long", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "TOOLNG",
});
expect(result.success).toBe(false);
});
it("rejects tags with non-alphanumeric characters", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "AB-CD",
});
expect(result.success).toBe(false);
});
});
describe("ClanBanSchema", () => {
const validBan = {
publicId: "player-1",
bannedBy: "officer-1",
reason: "spamming",
createdAt: "2024-06-01T00:00:00.000Z",
};
it("accepts a valid ban with reason", () => {
const result = ClanBanSchema.safeParse(validBan);
expect(result.success).toBe(true);
});
it("accepts a ban with null reason", () => {
const result = ClanBanSchema.safeParse({ ...validBan, reason: null });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBeNull();
}
});
it("rejects a ban with missing reason field", () => {
const result = ClanBanSchema.safeParse({
publicId: validBan.publicId,
bannedBy: validBan.bannedBy,
createdAt: validBan.createdAt,
});
expect(result.success).toBe(false);
});
it("rejects a non-ISO string for createdAt", () => {
const result = ClanBanSchema.safeParse({
...validBan,
createdAt: "June 1 2024",
});
expect(result.success).toBe(false);
});
it("rejects null bannedBy", () => {
const result = ClanBanSchema.safeParse({ ...validBan, bannedBy: null });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,823 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
flushAsync,
getElState,
makeClan,
setElState,
setState,
stubLocalStorage,
utilsMockFactory,
virtualizerMockFactory,
waitForSubComponent,
} from "./ClanModalTestUtils";
vi.mock("@lit-labs/virtualizer/virtualize.js", () => virtualizerMockFactory());
vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
import type { ClanInfo } from "../../../src/client/ClanApi";
import { ClanModal } from "../../../src/client/ClanModal";
describe("ClanModal — handlers", () => {
let modal: ClanModal;
beforeEach(async () => {
if (!customElements.get("clan-modal")) {
customElements.define("clan-modal", ClanModal);
}
modal = document.createElement("clan-modal") as ClanModal;
// Use inline mode so no nested o-modal custom element is needed.
modal.setAttribute("inline", "");
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.clearAllMocks();
});
describe("handleApprove increments selectedClan.memberCount", () => {
it("increments memberCount by 1 after successful approveClanRequest", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: 5 });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
// Click the approve button for the pending applicant
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
expect(approveButtons.length).toBeGreaterThan(0);
approveButtons[0].click();
// Wait for the async handleApprove to complete
await flushAsync(modal);
expect(approveClanRequest).toHaveBeenCalledWith("TST", "applicant-1");
// ClanModal's selectedClan.memberCount should be incremented via request-approved event
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(6);
});
it("does not increment memberCount when approveClanRequest fails", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: 5 });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
approveButtons[0].click();
await flushAsync(modal);
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
// memberCount must remain at 5 — the failure path must not mutate it
expect(updatedClan?.memberCount).toBe(5);
});
it("treats undefined memberCount as 0 and increments to 1", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: undefined });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
approveButtons[0].click();
await flushAsync(modal);
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(1);
});
});
describe("Ban feature — manage view", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-03-01T00:00:00Z",
publicId: "target-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 5 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("renders a Ban button for non-leader members in manage view", () => {
const banButtons = Array.from(modal.querySelectorAll("button")).filter(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
expect(banButtons.length).toBeGreaterThan(0);
});
it("handleBan calls banClanMember after confirm-dialog confirm", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Step 1: Click Ban button to open confirm dialog
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Find the confirm-dialog and fire its confirm event with reason text
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: "bad behavior" } }),
);
await flushAsync(manageView);
expect(banClanMember).toHaveBeenCalledWith(
"TST",
"target-player",
"bad behavior",
);
});
it("handleBan aborts when confirm-dialog cancel is clicked", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
// Step 1: Click Ban button to open confirm dialog
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Fire cancel event
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("cancel"));
await flushAsync(manageView);
expect(banClanMember).not.toHaveBeenCalled();
});
it("handleBan sends undefined reason when confirm text is empty", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Step 1: Click Ban button
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Confirm with empty text
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: " " } }),
);
await flushAsync(manageView);
expect(banClanMember).toHaveBeenCalledWith(
"TST",
"target-player",
undefined,
);
});
it("handleBan syncs memberCount via clan-updated event on success", async () => {
const { banClanMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Server returns the post-ban member total (was 5, now 4).
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 4,
page: 1,
limit: 10,
pendingRequests: 0,
});
// Step 1: Click Ban button
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Confirm
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: "reason" } }),
);
await flushAsync(manageView, modal);
// ClanManageView's loadMembers dispatches clan-updated when memberCount differs,
// which ClanModal handles by updating selectedClan.
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(4);
});
});
describe("handleUnban", () => {
it("removes ban from list and decrements total on success", async () => {
const { unbanClanMember, fetchClanBans } = await import(
"../../../src/client/ClanApi"
);
(unbanClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: null,
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
const bansView = await waitForSubComponent(modal, "clan-bans-view");
const unbanButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.unban",
);
expect(unbanButton).toBeTruthy();
unbanButton!.click();
await flushAsync(bansView);
expect(unbanClanMember).toHaveBeenCalledWith("TST", "banned-1");
const bansTotal = getElState<number>(bansView, "bansTotal");
expect(bansTotal).toBe(0);
});
});
describe("handleKick", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-03-01T00:00:00Z",
publicId: "target-player",
},
],
total: 5,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 5 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("calls kickMember and syncs memberCount on success", async () => {
const { kickMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 4,
page: 1,
limit: 10,
pendingRequests: 0,
});
const kickButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.kick",
);
kickButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView, modal);
expect(kickMember).toHaveBeenCalledWith("TST", "target-player");
// ClanManageView's loadMembers dispatches clan-updated when total differs (5→4),
// which ClanModal handles by updating selectedClan.
expect(
(modal as unknown as { selectedClan: ClanInfo }).selectedClan
?.memberCount,
).toBe(4);
});
it("does not mutate state when kickMember fails", async () => {
const { kickMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const fetchSpy = fetchClanMembers as ReturnType<typeof vi.fn>;
fetchSpy.mockClear();
const kickButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.kick",
);
kickButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
modal
.querySelector("confirm-dialog")!
.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView);
expect(kickMember).toHaveBeenCalledWith("TST", "target-player");
// Failed call must not refresh the member page or change memberCount.
expect(fetchSpy).not.toHaveBeenCalled();
expect(
(modal as unknown as { selectedClan: ClanInfo }).selectedClan
?.memberCount,
).toBe(5);
});
});
describe("handleDisband", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 3,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 3 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(
modal,
"myClans" as keyof ClanModal,
[makeClan({ memberCount: 3 })] as never,
);
setState(
modal,
"myClanRoles" as keyof ClanModal,
new Map([["TST", "leader"]]) as never,
);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("calls disbandClan, clears selection, and returns to list on success", async () => {
const { disbandClan } = await import("../../../src/client/ClanApi");
(disbandClan as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Open the disband confirm dialog on the manage view.
setElState(manageView, "confirmAction", "disband");
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(modal);
expect(disbandClan).toHaveBeenCalledWith("TST");
const m = modal as unknown as {
selectedClan: ClanInfo | null;
myRole: string | null;
view: string;
myClans: ClanInfo[];
};
expect(m.selectedClan).toBeNull();
expect(m.myRole).toBeNull();
expect(m.view).toBe("list");
expect(m.myClans.find((c) => c.tag === "TST")).toBeUndefined();
});
it("preserves selection when disbandClan fails", async () => {
const { disbandClan } = await import("../../../src/client/ClanApi");
(disbandClan as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
setElState(manageView, "confirmAction", "disband");
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView, modal);
const m = modal as unknown as {
selectedClan: ClanInfo | null;
view: string;
};
expect(disbandClan).toHaveBeenCalledWith("TST");
// Selection and view stay intact so the user can retry.
expect(m.selectedClan?.tag).toBe("TST");
expect(m.view).toBe("manage");
});
});
describe("handleDeny", () => {
let requestsView: Element;
beforeEach(async () => {
const { fetchClanRequests } = await import("../../../src/client/ClanApi");
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
{ publicId: "applicant-2", createdAt: "2024-06-02T00:00:00Z" },
],
total: 2,
page: 1,
limit: 20,
});
setState(modal, "selectedClan" as keyof ClanModal, makeClan() as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
requestsView = await waitForSubComponent(modal, "clan-requests-view");
});
it("removes the request and decrements totals on success", async () => {
const { denyClanRequest } = await import("../../../src/client/ClanApi");
(denyClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const denyButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.includes("clan_modal.deny"),
);
denyButton!.click();
await flushAsync(requestsView);
expect(denyClanRequest).toHaveBeenCalledWith("TST", "applicant-1");
const requests = getElState<{ publicId: string }[]>(
requestsView,
"requests",
);
const requestsTotal = getElState<number>(requestsView, "requestsTotal");
expect(requests.map((r) => r.publicId)).toEqual(["applicant-2"]);
expect(requestsTotal).toBe(1);
});
it("does not mutate state when denyClanRequest fails", async () => {
const { denyClanRequest } = await import("../../../src/client/ClanApi");
(denyClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const denyButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.includes("clan_modal.deny"),
);
denyButton!.click();
await flushAsync(requestsView);
expect(denyClanRequest).toHaveBeenCalled();
const requests = getElState<{ publicId: string }[]>(
requestsView,
"requests",
);
const requestsTotal = getElState<number>(requestsView, "requestsTotal");
expect(requests).toHaveLength(2);
expect(requestsTotal).toBe(2);
});
});
describe("handleJoin", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanStats } = await import(
"../../../src/client/ClanApi"
);
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ isOpen: true, memberCount: 5 }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myClanRoles" as keyof ClanModal, new Map() as never);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
});
it("switches detail view into member mode immediately after open-clan join", async () => {
const { joinClan, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(joinClan as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
status: "joined",
});
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 6,
page: 1,
limit: 10,
pendingRequests: 0,
});
const joinButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.join_clan",
);
joinButton!.click();
await flushAsync(modal);
expect(joinClan).toHaveBeenCalledWith("TST");
expect(fetchClanMembers).toHaveBeenCalledWith(
"TST",
1,
10,
"default",
"asc",
);
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
expect(leaveButton).toBeTruthy();
const m = modal as unknown as {
myClanRoles: Map<string, string>;
};
expect(m.myClanRoles.get("TST")).toBe("member");
});
});
describe("handleLeave", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanMembers, fetchClanStats } =
await import("../../../src/client/ClanApi");
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan(),
);
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(
modal,
"myClanRoles" as keyof ClanModal,
new Map([["TST", "member"]]) as never,
);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
});
it("calls leaveClan, removes role, and returns to list on success", async () => {
const { leaveClan } = await import("../../../src/client/ClanApi");
(leaveClan as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
leaveButton!.click();
await flushAsync(modal);
expect(leaveClan).toHaveBeenCalledWith("TST");
const m = modal as unknown as {
selectedClan: ClanInfo | null;
myRole: string | null;
view: string;
myClanRoles: Map<string, string>;
};
expect(m.selectedClan).toBeNull();
expect(m.myRole).toBeNull();
expect(m.view).toBe("list");
expect(m.myClanRoles.has("TST")).toBe(false);
});
it("preserves selection when leaveClan fails", async () => {
const { leaveClan } = await import("../../../src/client/ClanApi");
(leaveClan as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
leaveButton!.click();
await flushAsync(modal);
const m = modal as unknown as {
selectedClanTag: string;
view: string;
myClanRoles: Map<string, string>;
};
expect(leaveClan).toHaveBeenCalledWith("TST");
expect(m.selectedClanTag).toBe("TST");
expect(m.view).toBe("detail");
expect(m.myClanRoles.get("TST")).toBe("member");
});
});
describe("Transfer leadership — confirm flow", () => {
let transferView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "target-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 2 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "transfer" as never);
transferView = await waitForSubComponent(modal, "clan-transfer-view");
// Set the transfer target and open confirm dialog on the transfer view
setElState(transferView, "transferTarget", "target-player");
setElState(transferView, "confirmAction", "transfer");
await (transferView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
});
it("clears confirmAction and removes the dialog after confirming", async () => {
const { transferLeadership } = await import(
"../../../src/client/ClanApi"
);
(transferLeadership as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
// Let handleTransfer's awaits settle.
await flushAsync(transferView);
expect(transferLeadership).toHaveBeenCalledWith("TST", "target-player");
expect(
getElState<string | null>(transferView, "confirmAction"),
).toBeNull();
expect(modal.querySelector("confirm-dialog")).toBeNull();
});
it("clears confirmAction when cancel is clicked, without calling the API", async () => {
const { transferLeadership } = await import(
"../../../src/client/ClanApi"
);
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("cancel"));
await (transferView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
expect(transferLeadership).not.toHaveBeenCalled();
expect(
getElState<string | null>(transferView, "confirmAction"),
).toBeNull();
expect(modal.querySelector("confirm-dialog")).toBeNull();
});
});
});
@@ -0,0 +1,458 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
getElState,
makeClan,
setState,
stubLocalStorage,
utilsMockFactory,
virtualizerMockFactory,
waitForSubComponent,
} from "./ClanModalTestUtils";
vi.mock("@lit-labs/virtualizer/virtualize.js", () => virtualizerMockFactory());
vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
import { ClanModal } from "../../../src/client/ClanModal";
describe("ClanModal — rendering", () => {
let modal: ClanModal;
beforeEach(async () => {
if (!customElements.get("clan-modal")) {
customElements.define("clan-modal", ClanModal);
}
modal = document.createElement("clan-modal") as ClanModal;
// Use inline mode so no nested o-modal custom element is needed.
modal.setAttribute("inline", "");
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.clearAllMocks();
});
// ── 1. renderClanCard: role badge vs open/invite badge ──────────────────
describe("renderClanCard — role vs open/invite badge", () => {
it("shows the role badge when a role is provided and hides open/invite badge", async () => {
// Directly invoke renderClanCard via the instance and insert the result
// into a container so we can query it. We do this by populating myClans
// and myClanRoles state so the list view renders real cards.
const { getUserMe } = await import("../../../src/client/Api");
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValue({
player: {
publicId: "test-player",
clans: [
{
tag: "TST",
name: "Test Clan",
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
},
],
clanRequests: [],
achievements: { singleplayerMap: [] },
},
user: { email: "test@test.com" },
});
// Open the modal so onOpen() → loadMyClans() runs
modal.open();
// Wait for loadMyClans async chain to complete
await new Promise((r) => setTimeout(r, 0));
await modal.updateComplete;
// The my-clans list should be rendered. Find the role badge text.
const text = modal.textContent ?? "";
// Role "leader" should appear in the badge (translateText passes key through)
expect(text).toContain("leader");
// The open/invite badge should NOT appear alongside the role badge on the
// same card. Since translateText returns the key, we check for the keys.
// "clan_modal.open" would show when no role — it must NOT appear for a
// clan where the user has a role.
expect(text).not.toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
});
it("shows 'clan_modal.open' badge when clan is open and user has no role", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "OTH", name: "Other Clan", isOpen: true })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
expect(text).not.toContain("leader");
});
it("shows 'clan_modal.invite_only' badge when clan is closed and user has no role", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "INV", name: "Invite Clan", isOpen: false })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.invite_only");
expect(text).not.toContain("clan_modal.open");
});
it("shows amber role badge class for leader", async () => {
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
setState(
modal,
"browseData" as keyof ClanModal,
{
results: [makeClan({ isOpen: true })],
total: 1,
page: 1,
limit: 20,
} as never,
);
// Force myClanRoles to include leader role for this clan's tag
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "leader"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
// Find spans that contain the translated leader role — should have amber styling
const spans = Array.from(modal.querySelectorAll("span"));
const leaderSpan = spans.find((s) =>
s.textContent?.trim().includes("role_leader"),
);
expect(leaderSpan).toBeTruthy();
expect(leaderSpan!.className).toContain("amber");
});
it("shows blue role badge class for officer/member", async () => {
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "officer"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
const spans = Array.from(modal.querySelectorAll("span"));
const officerSpan = spans.find((s) =>
s.textContent?.trim().includes("role_officer"),
);
expect(officerSpan).toBeTruthy();
expect(officerSpan!.className).toContain("blue");
});
});
// ── 2. My Clans tab passes role to renderClanCard ───────────────────────
describe("My Clans tab passes role from myClanRoles map", () => {
it("renders the user's role badge on a my-clan card", async () => {
// Set up a clan in myClans and a matching entry in myClanRoles
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "leader"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
const text = modal.textContent ?? "";
// The role badge text must appear; the open badge must NOT.
expect(text).toContain("leader");
expect(text).not.toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
});
it("does NOT show a role badge when myClanRoles has no entry for the clan", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "INV", name: "Invite Clan", isOpen: false })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).not.toContain("leader");
expect(text).not.toContain("officer");
// invite_only badge should appear since isOpen is false and no role
expect(text).toContain("clan_modal.invite_only");
});
});
// ── 3. memberCount fallback — display "0" when undefined ───────────────
describe("memberCount fallback", () => {
it("shows 0 members in the clan card when memberCount is undefined", async () => {
// translateText is mocked to return the key, so member_count key will appear.
// We verify the count passed to it is 0 by checking the rendered output
// does not contain "undefined".
setState(
modal,
"myClans" as keyof ClanModal,
[makeClan({ memberCount: undefined })] as never,
);
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
expect(modal.textContent).not.toContain("undefined");
// translateText mock swallows args and returns the key, so verify it
// was called with count: 0 (the fallback) rather than count: undefined.
const { translateText } = await import("../../../src/client/Utils");
const calls = (translateText as ReturnType<typeof vi.fn>).mock.calls;
const memberCountCall = calls.find(
(c) => c[0] === "clan_modal.member_count",
);
expect(memberCountCall).toBeTruthy();
expect(memberCountCall![1]).toEqual({ count: 0 });
});
it("shows 0 in the stats row of the detail view when memberCount is undefined", async () => {
const { fetchClanDetail, fetchClanStats } = await import(
"../../../src/client/ClanApi"
);
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ memberCount: undefined }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
clanTag: "TST",
games: 0,
wins: 0,
losses: 0,
teamTypeWL: {},
teamCountWL: {},
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
expect(modal.textContent).not.toContain("undefined");
// The stat box should contain "0" (from `clan.memberCount ?? 0`)
expect(modal.textContent).toContain("0");
});
it("shows 0 in the manage members header when memberCount is undefined", async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: undefined }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
expect(modal.textContent).not.toContain("undefined");
});
});
// ── 4. Toggle switch ARIA attributes ───────────────────────────────────
describe("Open/Closed toggle ARIA attributes in manage view", () => {
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ isOpen: true }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
});
it("toggle button has role='switch'", () => {
const toggle = modal.querySelector("[role='switch']");
expect(toggle).toBeTruthy();
});
it("toggle button has aria-checked='true' when manageIsOpen is true", () => {
const toggle = modal.querySelector("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("toggle button has aria-checked='false' when manageIsOpen is false", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
(manageView as unknown as { manageIsOpen: boolean }).manageIsOpen = false;
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const toggle = modal.querySelector("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("false");
});
it("toggle button has an aria-label", () => {
const toggle = modal.querySelector("[role='switch']");
const label = toggle?.getAttribute("aria-label");
expect(label).toBeTruthy();
expect(label!.length).toBeGreaterThan(0);
});
it("clicking the toggle flips manageIsOpen", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
const toggle = modal.querySelector<HTMLButtonElement>("[role='switch']");
expect(toggle).toBeTruthy();
const before = getElState<boolean>(manageView, "manageIsOpen");
toggle!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const after = getElState<boolean>(manageView, "manageIsOpen");
expect(after).toBe(!before);
});
it("aria-checked reflects toggled state after click", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
const toggle = modal.querySelector<HTMLButtonElement>("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("true");
toggle!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const updatedToggle = modal.querySelector("[role='switch']");
expect(updatedToggle?.getAttribute("aria-checked")).toBe("false");
});
});
// ── 5. Ban list rendering ──────────────────────────────────────────────
describe("Ban feature — bans view", () => {
it("renders Banned Players button in manage view", async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(modal, "selectedClan" as keyof ClanModal, makeClan() as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.banned_players");
});
it("renders ban list with unban button in bans view", async () => {
const { fetchClanBans } = await import("../../../src/client/ClanApi");
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: "toxic behavior",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
await waitForSubComponent(modal, "clan-bans-view");
const text = modal.textContent ?? "";
expect(text).toContain("banned-1");
expect(text).toContain("officer-1");
expect(text).toContain("clan_modal.unban");
expect(text).toContain("clan_modal.ban_reason");
});
it("renders empty state when no bans", async () => {
const { fetchClanBans } = await import("../../../src/client/ClanApi");
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
await waitForSubComponent(modal, "clan-bans-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.no_bans");
});
});
describe("Component basics", () => {
it("is registered as a custom element", () => {
expect(modal).toBeInstanceOf(ClanModal);
expect(modal.tagName.toLowerCase()).toBe("clan-modal");
});
it("renders without shadow DOM (createRenderRoot returns this)", () => {
// BaseModal.createRenderRoot returns `this`, so shadowRoot should be null
expect(modal.shadowRoot).toBeNull();
});
it("opens and closes via public API", () => {
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
modal.open();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
true,
);
modal.close();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
});
});
});
+217
View File
@@ -0,0 +1,217 @@
import { vi } from "vitest";
import type { ClanInfo } from "../../../src/client/ClanApi";
import type { ClanModal } from "../../../src/client/ClanModal";
// ─── Mock factories ─────────────────────────────────────────────────────────
// Each factory returns a fresh object of vi.fn()s. Test files pass these to
// vi.mock() so Vitest invokes them when the mocked module is first imported.
// The factory pattern keeps the mock surface DRY across test files while
// preserving per-file module isolation.
export function clanApiMockFactory() {
return {
fetchClanDetail: vi.fn(async () => ({
name: "Test Clan",
tag: "TST",
description: "A test clan",
isOpen: true,
createdAt: "2024-01-01T00:00:00Z",
memberCount: 5,
})),
fetchClanMembers: vi.fn(async () => ({
results: [
{
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
})),
fetchClanStats: vi.fn(async () => ({
clanTag: "TST",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
teamTypeWL: {},
teamCountWL: {},
})),
fetchClans: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
joinClan: vi.fn(),
leaveClan: vi.fn(),
updateClan: vi.fn(),
disbandClan: vi.fn(),
kickMember: vi.fn(),
promoteMember: vi.fn(),
demoteMember: vi.fn(),
transferLeadership: vi.fn(),
fetchClanRequests: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
approveClanRequest: vi.fn(async () => true),
denyClanRequest: vi.fn(),
withdrawClanRequest: vi.fn(),
fetchClanLeaderboard: vi.fn(),
banClanMember: vi.fn(async () => true),
unbanClanMember: vi.fn(async () => true),
fetchClanBans: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
};
}
export function apiMockFactory() {
return {
getUserMe: vi.fn(async () => ({
player: {
publicId: "test-player",
clans: [
{
tag: "TST",
name: "Test Clan",
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
memberCount: 5,
},
],
clanRequests: [],
achievements: { singleplayerMap: [] },
},
user: { email: "test@test.com" },
})),
invalidateUserMe: vi.fn(),
};
}
export function utilsMockFactory() {
return {
translateText: vi.fn((key: string) => key),
showToast: vi.fn(),
};
}
export function authMockFactory() {
return {
getAuthHeader: vi.fn(async () => "Bearer test-token"),
userAuth: vi.fn(async () => ({ jwt: "test-token", claims: {} })),
};
}
export function configLoaderMockFactory() {
return {
getRuntimeClientServerConfig: vi.fn(() => ({})),
};
}
export function crazyGamesSdkMockFactory() {
return {
crazyGamesSDK: { isAvailable: false },
};
}
export async function virtualizerMockFactory() {
const { html } = await import("lit");
return {
virtualize: vi.fn(() => html``),
};
}
export function stubLocalStorage() {
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
}
// ─── Test helpers ───────────────────────────────────────────────────────────
/**
* Drain pending microtasks and Lit's update scheduler.
* Replaces bare `await new Promise(r => setTimeout(r, 0))` which only drains
* a single microtask tick and can miss batched Lit updates.
*/
export async function flushAsync(
...els: (Element | null | undefined)[]
): Promise<void> {
// Two ticks to drain chained microtasks (e.g. async handler → state update → re-render).
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
for (const el of els) {
if (el && "updateComplete" in el) {
await (el as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
}
}
}
/** Force-set a Lit @state property and trigger re-render. */
export function setState<K extends keyof ClanModal>(
modal: ClanModal,
key: K,
value: ClanModal[K],
) {
(modal as unknown as Record<string, unknown>)[key] = value;
}
/** Force-set a property on any element (sub-components etc.). */
export function setElState(el: Element, key: string, value: unknown) {
(el as unknown as Record<string, unknown>)[key] = value;
}
/** Get a property from any element. */
export function getElState<T = unknown>(el: Element, key: string): T {
return (el as unknown as Record<string, unknown>)[key] as T;
}
/**
* Wait for a sub-component to mount and finish its initial async load.
* Call after setting ClanModal state that causes the sub-component to render.
*/
export async function waitForSubComponent(
modal: ClanModal,
selector: string,
): Promise<Element> {
await flushAsync(modal);
const el = modal.querySelector(selector)!;
if (el && "updateComplete" in el) {
await (el as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
}
return el;
}
export function makeClan(overrides: Partial<ClanInfo> = {}): ClanInfo {
return {
name: "Test Clan",
tag: "TST",
description: "A test clan",
isOpen: true,
createdAt: "2024-01-01T00:00:00Z",
memberCount: 5,
...overrides,
};
}
+143
View File
@@ -0,0 +1,143 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberStats,
} from "../../../src/client/ClanApi";
import {
filterMembersBySearch,
filterRequestsBySearch,
renderMemberStats,
} from "../../../src/client/components/clan/ClanShared";
const members: ClanMember[] = [
{ publicId: "Alice123", role: "leader", joinedAt: "2024-01-01T00:00:00Z" },
{ publicId: "Bob456", role: "officer", joinedAt: "2024-02-01T00:00:00Z" },
{ publicId: "Charlie789", role: "member", joinedAt: "2024-03-01T00:00:00Z" },
];
const requests: ClanJoinRequest[] = [
{ publicId: "Dave111", createdAt: "2024-04-01T00:00:00Z" },
{ publicId: "Eve222", createdAt: "2024-05-01T00:00:00Z" },
];
describe("filterMembersBySearch", () => {
it("returns all members when search is empty", () => {
expect(filterMembersBySearch(members, "")).toEqual(members);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterMembersBySearch(members, "alice");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Alice123");
});
it("matches by role", () => {
const result = filterMembersBySearch(members, "officer");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("matches partial publicId", () => {
const result = filterMembersBySearch(members, "456");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("returns empty array when nothing matches", () => {
expect(filterMembersBySearch(members, "zzz")).toEqual([]);
});
it("matches 'member' role without matching 'leader' or 'officer'", () => {
const result = filterMembersBySearch(members, "member");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Charlie789");
});
});
describe("renderMemberStats", () => {
const stats: ClanMemberStats = {
total: { wins: 7, losses: 5 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 0, losses: 0 },
"1v1": { wins: 0, losses: 0 },
};
function renderTo(result: ReturnType<typeof renderMemberStats>): HTMLElement {
const host = document.createElement("div");
render(result, host);
return host;
}
it("renders nothing when stats is undefined", () => {
const host = renderTo(renderMemberStats(undefined));
expect(host.textContent?.trim()).toBe("");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
// Each bucket with games shows `{wins}W` and `{losses}L` inside segments
expect(text).toContain("2W");
expect(text).toContain("4L");
expect(text).toContain("5W");
expect(text).toContain("1L");
// Win-rate, and em-dash placeholder for empty bucket
expect(text).toContain("33%");
expect(text).toContain("83%");
expect(text).toContain("—");
});
it("renders a proportional win-loss bar when there are games", () => {
const host = renderTo(renderMemberStats(stats));
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
// and 1v1 have 0 games → no segments.
expect(bars.length).toBe(6);
const widths = Array.from(bars).map((b) =>
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
);
// total: 7/12 ≈ 58.3% wins, 41.7% losses
expect(widths[0]).toContain("width:58.33");
expect(widths[1]).toContain("width:41.66");
// ffa: 2/6 ≈ 33.3% wins, 66.7% losses
expect(widths[2]).toContain("width:33.33");
expect(widths[3]).toContain("width:66.66");
});
it("includes all six translated bucket labels", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).toContain("clan_modal.stats_ffa");
expect(text).toContain("clan_modal.stats_team");
expect(text).toContain("clan_modal.stats_hvn");
expect(text).toContain("clan_modal.stats_ranked");
expect(text).toContain("clan_modal.stats_1v1");
});
});
describe("filterRequestsBySearch", () => {
it("returns all requests when search is empty", () => {
expect(filterRequestsBySearch(requests, "")).toEqual(requests);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterRequestsBySearch(requests, "dave");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Dave111");
});
it("matches partial publicId", () => {
const result = filterRequestsBySearch(requests, "222");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Eve222");
});
it("returns empty array when nothing matches", () => {
expect(filterRequestsBySearch(requests, "zzz")).toEqual([]);
});
});