mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user