Repository: dm94/stiletto-web
Pull request #242: test: Added more playwright tests
Author: login: dm94
Created: 2025-05-18T21:12:40Z | Merged: 2025-05-19T22:28:52Z

## Summary by Sourcery

Add test IDs to interactive elements and introduce comprehensive Playwright end-to-end tests for clan request, clan creation, and member acceptance flows.

Enhancements:
- Add data-testid attributes to key buttons in clan list, member list, and clan configuration components to support testing

Tests:
- Implement Playwright test suite for clan member acceptance flow
- Implement Playwright test suite for clan creation flow
- Implement Playwright test suite for clan request flow


Diff:
diff --git a/playwright/e2e/clan-creation.spec.ts b/playwright/e2e/clan-creation.spec.ts
new file mode 100644
index 000000000..b7b7f46ab
--- /dev/null
+++ b/playwright/e2e/clan-creation.spec.ts
@@ -0,0 +1,165 @@
+import { test, expect } from "@playwright/test";
+import type { CreateClanRequestParams } from "../../src/types/dto/clan";
+
+const MOCK_USER_DATA = {
+  discordtag: "testUser#1234",
+  nickname: "TestNickname",
+};
+
+const MOCK_CLAN_DATA = {
+  clanname: "Test Clan",
+  clancolor: "#FF5500",
+  clandiscord: "testinvite",
+  symbol: "C1",
+  region: "EU-Official",
+  recruit: true,
+};
+
+test.describe("Clan Creation Flow", () => {
+  test.beforeEach(async ({ page }) => {
+    // Mock user authentication
+    await page.route("**/users", (route) => {
+      route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify(MOCK_USER_DATA),
+      });
+    });
+
+    // Mock clan creation API endpoint
+    await page.route("**/clans", async (route) => {
+      const request = route.request();
+      if (request.method() === "POST") {
+        await route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify({
+            message: "Clan created successfully",
+            id: 123,
+          }),
+        });
+      } else {
+        await route.continue();
+      }
+    });
+
+    // Mock clusters data for region dropdown
+    await page.route("**/clusters", async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify([
+          {
+            region: "EU",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+          {
+            region: "NA",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+        ]),
+      });
+    });
+
+    // Set authentication in localStorage
+    await page.addInitScript(() => {
+      window.localStorage.setItem("token", "mock-test-token");
+      window.localStorage.setItem("discordid", "mock-discord-id");
+    });
+
+    // Navigate to profile page
+    await page.goto("/profile");
+    await page.waitForLoadState("networkidle", { timeout: 15000 });
+  });
+
+  test("Should open clan creation modal and create a clan", async ({
+    page,
+  }) => {
+    // Verify user is logged in
+    await expect(page.getByTestId("discord-tag")).toHaveText(
+      MOCK_USER_DATA.discordtag,
+      { timeout: 15000 },
+    );
+
+    // Capture the POST request to /clans
+    await page.route("**/clans**", async (route) => {
+      const request = route.request();
+      if (request.method() === "POST") {
+        await route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify({
+            message: "Clan created successfully",
+            id: 123,
+          }),
+        });
+      } else {
+        await route.continue();
+      }
+    });
+
+    // Click on "Create a clan" button
+    await page.getByText("Create a clan").click();
+
+    // Verify clan configuration modal is visible
+    await expect(page.getByText("Clan Configuration")).toBeVisible({
+      timeout: 5000,
+    });
+
+    // Fill the clan creation form
+    await page.locator("#clan_name").fill(MOCK_CLAN_DATA.clanname);
+
+    const recruitCheckbox = page.locator("#recruitmentInput");
+    if (!(await recruitCheckbox.isChecked())) {
+      await recruitCheckbox.check();
+    }
+
+    await page.locator("#discord_invite").fill(MOCK_CLAN_DATA.clandiscord);
+
+    await page
+      .getByRole("button", { name: "Clan symbol C1", exact: true })
+      .click();
+
+    // Submit the form
+    await page.getByTestId("submit-button").click();
+
+    await expect(page.getByText("Clan Configuration")).not.toBeVisible({
+      timeout: 5000,
+    });
+  });
+
+  test("Should validate required fields in clan creation form", async ({
+    page,
+  }) => {
+    // Click on "Create a clan" button
+    await page.getByText("Create a clan").click();
+
+    // Verify clan configuration modal is visible
+    await expect(page.getByText("Clan Configuration")).toBeVisible({
+      timeout: 5000,
+    });
+
+    // Try to submit the form without filling required fields
+    await page.locator("#clan_name").fill(""); // Clear the clan name field
+    await page.locator("form#clanconfig button[type=submit]").click();
+
+    // Form should still be visible as submission should fail due to validation
+    await expect(page.getByText("Clan Configuration")).toBeVisible();
+
+    // Verify the form validation is working (HTML5 validation)
+    await expect(page.locator("#clan_name:invalid")).toBeVisible();
+
+    // Fill the required field and try again
+    await page.locator("#clan_name").fill(MOCK_CLAN_DATA.clanname);
+    await page.locator("form#clanconfig button[type=submit]").click();
+
+    // Verify modal is closed after successful submission
+    await expect(page.getByText("Clan Configuration")).not.toBeVisible({
+      timeout: 5000,
+    });
+  });
+});
diff --git a/playwright/e2e/clan-edit.spec.ts b/playwright/e2e/clan-edit.spec.ts
new file mode 100644
index 000000000..cb87fb95a
--- /dev/null
+++ b/playwright/e2e/clan-edit.spec.ts
@@ -0,0 +1,199 @@
+import { test, expect } from "@playwright/test";
+import type { ClanInfo } from "../../src/types/dto/clan";
+import type { GenericResponse } from "../../src/types/dto/generic";
+
+const MOCK_USER_DATA = {
+  discordtag: "testLeader#1234",
+  nickname: "ClanLeader",
+  discordid: "leader-discord-id",
+  clanid: 1,
+  leaderid: "leader-discord-id",
+};
+
+const MOCK_CLAN_ID = 1;
+
+const MOCK_CLAN_DATA: ClanInfo = {
+  clanid: MOCK_CLAN_ID,
+  name: "Test Clan",
+  leaderid: MOCK_USER_DATA.discordid,
+  recruitment: true,
+  region: "EU-Official",
+  discordTag: MOCK_USER_DATA.discordtag,
+  invitelink: "testinvite",
+  flagcolor: "#FF5500",
+  symbol: "C1",
+};
+
+const MOCK_UPDATED_CLAN_DATA = {
+  name: "Updated Clan Name",
+  recruitment: false,
+  invitelink: "updatedinvite",
+  flagcolor: "#00FF55",
+  symbol: "C2",
+};
+
+const MOCK_UPDATE_RESPONSE: GenericResponse = {
+  message: "Clan updated successfully",
+};
+
+test.describe("Clan Edit Flow", () => {
+  test.beforeEach(async ({ page }) => {
+    // Configure mocks for API requests
+    await page.route("**/users", (route) => {
+      route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify(MOCK_USER_DATA),
+      });
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}`, (route) => {
+      const request = route.request();
+      if (request.method() === "GET") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify(MOCK_CLAN_DATA),
+        });
+      } else if (request.method() === "PUT") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify(MOCK_UPDATE_RESPONSE),
+        });
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route("**/clusters", async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify([
+          {
+            region: "EU",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+          {
+            region: "NA",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+        ]),
+      });
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}/members`, (route) => {
+      const request = route.request();
+      if (request.method() === "GET") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify([
+            {
+              discordid: "member1-discord-id",
+              discordtag: "Member1#1234",
+              nickname: "ClanMember1",
+              leaderid: MOCK_USER_DATA.discordid,
+            },
+            {
+              discordid: MOCK_USER_DATA.discordid,
+              discordtag: MOCK_USER_DATA.discordtag,
+              nickname: MOCK_USER_DATA.nickname,
+              leaderid: MOCK_USER_DATA.discordid,
+            },
+          ]),
+        });
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}/requests`, (route) => {
+      const request = route.request();
+      if (request.method() === "GET") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify([
+            {
+              discordid: "987654321098765",
+              discordtag: "TestUser#5678",
+              message: "I would like to join the clan",
+              leaderid: MOCK_USER_DATA.discordid,
+            },
+          ]),
+        });
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route(
+      `**/clans/${MOCK_CLAN_ID}/members/*/permissions`,
+      (route) => {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify({
+            bot: true,
+            request: true,
+            kickmembers: true,
+            editclan: true,
+          }),
+        });
+      },
+    );
+
+    // Set up localStorage before navigation
+    await page.addInitScript(() => {
+      window.localStorage.setItem("token", "mock-test-token");
+      window.localStorage.setItem("discordid", "leader-discord-id");
+    });
+  });
+
+  test("Should navigate to members page and edit clan information", async ({
+    page,
+  }) => {
+    await page.goto("/members");
+
+    await page.waitForLoadState("networkidle", { timeout: 30000 });
+
+    const editButton = page.getByTestId("edit-clan-button");
+    await editButton.waitFor({ state: "visible", timeout: 30000 });
+    await editButton.click();
+
+    const clanNameInput = page.locator("#clan_name");
+    await clanNameInput.waitFor({ state: "visible", timeout: 30000 });
+
+    await clanNameInput.fill(MOCK_UPDATED_CLAN_DATA.name);
+
+    const recruitCheckbox = page.locator("#recruitmentInput");
+    await recruitCheckbox.waitFor({ state: "visible", timeout: 30000 });
+    if (await recruitCheckbox.isChecked()) {
+      await recruitCheckbox.uncheck();
+    }
+
+    const discordInput = page.locator("#discord_invite");
+    await discordInput.waitFor({ state: "visible", timeout: 30000 });
+    await discordInput.fill(MOCK_UPDATED_CLAN_DATA.invitelink);
+
+    const putRequestPromise = page.waitForRequest(
+      (request) =>
+        request.url().includes(`/clans/${MOCK_CLAN_ID}`) &&
+        request.method() === "PUT",
+    );
+
+    const submitButton = page.getByTestId("submit-button");
+    await submitButton.waitFor({ state: "visible", timeout: 30000 });
+    await submitButton.click();
+
+    await putRequestPromise;
+
+    await expect(clanNameInput).not.toBeVisible({ timeout: 30000 });
+  });
+});
diff --git a/playwright/e2e/clan-member-acceptance.spec.ts b/playwright/e2e/clan-member-acceptance.spec.ts
new file mode 100644
index 000000000..d01301e40
--- /dev/null
+++ b/playwright/e2e/clan-member-acceptance.spec.ts
@@ -0,0 +1,167 @@
+import { test, expect } from "@playwright/test";
+import type { MemberRequest } from "../../src/types/dto/members";
+import type { GenericResponse } from "../../src/types/dto/generic";
+import type { RequestAction } from "../../src/types/dto/requests";
+
+const MOCK_USER_DATA = {
+  discordtag: "testLeader#1234",
+  nickname: "ClanLeader",
+  discordid: "0000000000000000",
+};
+
+const MOCK_CLAN_ID = 1;
+
+const MOCK_REQUESTS_DATA: MemberRequest[] = [
+  {
+    discordid: "987654321098765",
+    discordtag: "TestUser#5678",
+    message: "Me gustaría unirme al clan",
+    leaderid: MOCK_USER_DATA.discordid,
+  },
+  {
+    discordid: "123456789012345",
+    discordtag: "AnotherUser#9012",
+    message: "Solicitud para unirme",
+    leaderid: MOCK_USER_DATA.discordid,
+  },
+];
+
+const MOCK_ACCEPT_RESPONSE: GenericResponse = {
+  message: "Member accepted successfully",
+};
+
+test.describe("Clan Member Acceptance Flow", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.route("**/users", (route) => {
+      route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify({
+          ...MOCK_USER_DATA,
+          clanid: MOCK_CLAN_ID,
+          leaderid: "leader-discord-id",
+          discordid: "leader-discord-id",
+        }),
+      });
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}/members`, (route) => {
+      const request = route.request();
+      if (request.method() === "GET") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify([
+            {
+              discordid: "member1-discord-id",
+              discordtag: "Member1#1234",
+              nickname: "ClanMember1",
+              leaderid: MOCK_USER_DATA.discordid,
+            },
+            {
+              discordid: "leader-discord-id",
+              discordtag: MOCK_USER_DATA.discordtag,
+              nickname: MOCK_USER_DATA.nickname,
+              leaderid: MOCK_USER_DATA.discordid,
+            },
+          ]),
+        });
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}/requests`, (route) => {
+      const request = route.request();
+      if (request.method() === "GET") {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify(MOCK_REQUESTS_DATA),
+        });
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route(`**/clans/${MOCK_CLAN_ID}/requests/*`, (route) => {
+      const request = route.request();
+      if (request.method() === "PUT") {
+        const url = new URL(request.url());
+        const action = url.searchParams.get("action") as RequestAction;
+
+        if (action === "accept") {
+          route.fulfill({
+            status: 200,
+            contentType: "application/json",
+            body: JSON.stringify(MOCK_ACCEPT_RESPONSE),
+          });
+        } else {
+          route.fulfill({
+            status: 200,
+            contentType: "application/json",
+            body: JSON.stringify({ message: "Request rejected" }),
+          });
+        }
+      } else {
+        route.continue();
+      }
+    });
+
+    await page.route(
+      `**/clans/${MOCK_CLAN_ID}/members/*/permissions`,
+      (route) => {
+        route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify({
+            bot: true,
+            request: true,
+            kickmembers: true,
+          }),
+        });
+      },
+    );
+
+    await page.addInitScript(() => {
+      window.localStorage.setItem("token", "mock-test-token");
+      window.localStorage.setItem("discordid", "leader-discord-id");
+    });
+  });
+
+  test("Should navigate to members page and accept a pending request", async ({
+    page,
+  }) => {
+    await page.goto("/members");
+
+    await page.waitForLoadState("networkidle", { timeout: 15000 });
+
+    const testUserRequest = page.getByText("TestUser#5678");
+    await expect(testUserRequest).toBeVisible();
+
+    const requestRow = page.locator("tr", {
+      has: page.getByText("TestUser#5678"),
+    });
+    const showRequestButton = requestRow.getByRole("button");
+    await expect(showRequestButton).toBeVisible();
+    await showRequestButton.click();
+
+    const requestModal = page.getByText("Me gustaría unirme al clan");
+    await expect(requestModal).toBeVisible();
+
+    const acceptButton = page.getByTestId("accept-request-button");
+    await expect(acceptButton).toBeVisible();
+
+    const putRequestPromise = page.waitForRequest(
+      (request) =>
+        request.url().includes(`/clans/${MOCK_CLAN_ID}/requests/`) &&
+        request.method() === "PUT",
+    );
+
+    await acceptButton.click();
+
+    await putRequestPromise;
+
+    await expect(testUserRequest).not.toBeVisible({ timeout: 5000 });
+  });
+});
diff --git a/playwright/e2e/clan-request.spec.ts b/playwright/e2e/clan-request.spec.ts
new file mode 100644
index 000000000..9e71c3e6d
--- /dev/null
+++ b/playwright/e2e/clan-request.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect } from "@playwright/test";
+import type { ClanInfo } from "../../src/types/dto/clan";
+
+const MOCK_USER_DATA = {
+  discordtag: "testUser#1234",
+  nickname: "TestNickname",
+};
+
+const MOCK_CLANS_DATA: ClanInfo[] = [
+  {
+    clanid: 1,
+    name: "Test Clan 1",
+    leaderid: "12345678901234",
+    recruitment: true,
+    region: "EU-Official",
+    discordTag: "Leader#1234",
+    invitelink: "testinvite1",
+    flagcolor: "#FF5500",
+    symbol: "C1",
+  },
+  {
+    clanid: 2,
+    name: "Test Clan 2",
+    leaderid: "98765432109876",
+    recruitment: true,
+    region: "NA-Official",
+    discordTag: "Leader#5678",
+    invitelink: "testinvite2",
+    flagcolor: "#00FF55",
+    symbol: "C2",
+  },
+];
+
+test.describe("Clan Request Flow", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.route("**/users", (route) => {
+      route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify(MOCK_USER_DATA),
+      });
+    });
+
+    await page.route("**/clans?**", async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify(MOCK_CLANS_DATA),
+      });
+    });
+
+    await page.route("**/clans/*/requests?**", async (route) => {
+      const request = route.request();
+      if (request.method() === "POST") {
+        await route.fulfill({
+          status: 200,
+          contentType: "application/json",
+          body: JSON.stringify({
+            message: "Request sent successfully",
+          }),
+        });
+      } else {
+        await route.continue();
+      }
+    });
+
+    await page.route("**/clusters", async (route) => {
+      await route.fulfill({
+        status: 200,
+        contentType: "application/json",
+        body: JSON.stringify([
+          {
+            region: "EU",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+          {
+            region: "NA",
+            name: "Official",
+            clan_limit: 999,
+            crossplay: true,
+          },
+        ]),
+      });
+    });
+
+    await page.addInitScript(() => {
+      window.localStorage.setItem("token", "mock-test-token");
+      window.localStorage.setItem("discordid", "mock-discord-id");
+    });
+  });
+
+  test("Should navigate to clan list and send a request to join a clan", async ({
+    page,
+  }) => {
+    await page.goto("/clanlist");
+    await expect(page.url()).toContain("/clanlist");
+    await page.waitForLoadState("networkidle", { timeout: 15000 });
+
+    await expect(page.getByText(MOCK_CLANS_DATA[0].name)).toBeVisible();
+    await expect(page.getByText(MOCK_CLANS_DATA[1].name)).toBeVisible();
+
+    await page.getByTestId("send-request-button").first().click();
+    await expect(page.getByText("Request Message")).toBeVisible();
+
+    const requestMessage = "I would like to join your clan!";
+    await page.locator("#modalTextArea").fill(requestMessage);
+
+    await page.getByTestId("submit-request-button").click();
+
+    await expect(
+      page.getByText("Application to enter the clan sent"),
+    ).toBeVisible();
+  });
+
+  test("Should not show send request button when user is not logged in", async ({
+    page,
+  }) => {
+    await page.addInitScript(() => {
+      window.localStorage.clear();
+      window.localStorage.removeItem("token");
+      window.localStorage.removeItem("discordid");
+    });
+
+    await page.goto("/clanlist");
+    await page.waitForLoadState("networkidle", { timeout: 15000 });
+    await expect(page.getByText(MOCK_CLANS_DATA[0].name)).toBeVisible();
+
+    await expect(page.getByTestId("send-request-button")).not.toBeVisible({
+      timeout: 5000,
+    });
+  });
+});
diff --git a/src/components/ClanConfig.tsx b/src/components/ClanConfig.tsx
index f95cdb81d..881463d4c 100644
--- a/src/components/ClanConfig.tsx
+++ b/src/components/ClanConfig.tsx
@@ -283,6 +283,7 @@ const ClanConfig: React.FC<ClanConfigProps> = ({
                 {t("common.cancel")}
               </button>
               <button
+                data-testid="submit-button"
                 type="submit"
                 className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
               >
diff --git a/src/components/ClanList/ClanListItem.tsx b/src/components/ClanList/ClanListItem.tsx
index e2abba052..03d3c5b43 100644
--- a/src/components/ClanList/ClanListItem.tsx
+++ b/src/components/ClanList/ClanListItem.tsx
@@ -29,6 +29,7 @@ const ClanListItem: React.FC<ClanListItemProps> = ({
             className="w-full p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
             onClick={() => onSendRequest?.(Number(clan.clanid))}
             aria-label={t("common.sendRequest")}
+            data-testid="send-request-button"
           >
             {t("common.sendRequest")}
           </button>
@@ -40,6 +41,7 @@ const ClanListItem: React.FC<ClanListItemProps> = ({
             className="w-full p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-center block"
             to="/members"
             aria-label={t("menu.members")}
+            data-testid="view-members-button"
           >
             {t("menu.members")}
           </Link>
diff --git a/src/pages/ClanList.tsx b/src/pages/ClanList.tsx
index f1e66c1a7..53edda711 100644
--- a/src/pages/ClanList.tsx
+++ b/src/pages/ClanList.tsx
@@ -204,6 +204,7 @@ const ClanList = () => {
                       type="button"
                       className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
                       onClick={() => updateClans()}
+                      data-testid="search-button"
                     >
                       {t("common.search")}
                     </button>
@@ -316,6 +317,7 @@ const ClanList = () => {
                 type="button"
                 className="p-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
                 onClick={handleSendRequest}
+                data-testid="submit-request-button"
               >
                 {t("common.request")}
               </button>
diff --git a/src/pages/MemberList.tsx b/src/pages/MemberList.tsx
index c3cfa167a..d85c86432 100644
--- a/src/pages/MemberList.tsx
+++ b/src/pages/MemberList.tsx
@@ -411,6 +411,7 @@ const MemberList = () => {
                 type="button"
                 className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-r-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
                 onClick={() => setShowClanConfig(true)}
+                data-testid="edit-clan-button"
               >
                 {t("clan.configuration")}
               </button>
@@ -521,6 +522,7 @@ const MemberList = () => {
               type="button"
               className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500"
               onClick={acceptMember}
+              data-testid="accept-request-button"
             >
               {t("common.accept")}
             </button>
@@ -528,6 +530,7 @@ const MemberList = () => {
               type="button"
               className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500"
               onClick={rejectMember}
+              data-testid="reject-request-button"
             >
               {t("common.reject")}
             </button>


Reviews:
user: login: sourcery-ai[bot]
body: Hey @dm94 - I've reviewed your changes and they look great!

<details>
<summary>Here's what I looked at during the review</summary>

- 🟢 **General issues**: all looks good
- 🟢 **Review instructions**: all looks good
- 🟡 **Testing**: 4 issues found
- 🟡 **Complexity**: 1 issue found
- 🟢 **Documentation**: all looks good
</details>

***

<details>
<summary>Sourcery is free for open source - if you like our reviews please consider sharing them ✨</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20got%20an%20instant%20code%20review%20from%20%40SourceryAI%2C%20and%20it%20was%20brilliant%21%20It%27s%20free%20for%20open%20source%20and%20has%20a%20free%20trial%20for%20private%20code.%20Check%20it%20out%20https%3A//sourcery.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https://sourcery.ai)
- [Facebook](https://www.facebook.com/sharer/sharer.php?u=https://sourcery.ai)

</details>

<sub>
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
</sub>
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T21:15:14Z

Review comments:
diff_hunk: @@ -40,6 +41,7 @@ const ClanListItem: React.FC<ClanListItemProps> = ({
             className="w-full p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 text-center block"
             to="/members"
             aria-label={t("menu.members")}
+            data-testid="view-members-button"
path: src/components/ClanList/ClanListItem.tsx
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Generic test IDs may collide across components

Prefix data-testid values with the component or feature name (e.g. clan-list-item-send-request-button) to avoid collisions and improve test readability.
created_at: 2025-05-18T21:15:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,6 +317,7 @@ const ClanList = () => {
                 type="button"
                 className="p-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500"
                 onClick={handleSendRequest}
+                data-testid="submit-request-button"
path: src/pages/ClanList.tsx
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Test IDs for search and submit buttons are too generic

Prefix the test IDs—for example, clan-list-search-button and clan-list-submit-request-button—to clarify their context and avoid conflicts.

Suggested implementation:

```typescript
data-testid="clan-list-search-button"

```

```typescript
data-testid="clan-list-submit-request-button"

```
created_at: 2025-05-18T21:15:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -521,13 +521,15 @@ const MemberList = () => {
               type="button"
               className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500"
               onClick={acceptMember}
+              data-testid="accept-request-button"
             >
               {t("common.accept")}
             </button>
             <button
               type="button"
               className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-500"
               onClick={rejectMember}
+              data-testid="reject-request-button"
path: src/pages/MemberList.tsx
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Consider more descriptive test IDs for member actions

Prefix test IDs with context (e.g. "member-list-accept-request-button") to distinguish them from other accept/reject buttons.

Suggested implementation:

```typescript
data-testid="member-list-accept-request-button"

```

```typescript
data-testid="member-list-reject-request-button"

```
created_at: 2025-05-18T21:15:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -283,6 +283,7 @@ const ClanConfig: React.FC<ClanConfigProps> = ({
                 {t("common.cancel")}
               </button>
               <button
+                data-testid="submit-button"
path: src/components/ClanConfig.tsx
user: login: sourcery-ai[bot]
body: **suggestion (testing):** Ambiguous test ID for submit button

Rename the test ID to 'clan-config-submit-button' to tie it to the clan configuration form and avoid conflicts.

```suggestion
data-testid="clan-config-submit-button"
```
created_at: 2025-05-18T21:15:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,165 @@
+import { test, expect } from "@playwright/test";
+import type { CreateClanRequestParams } from "../../src/types/dto/clan";
+
+const MOCK_USER_DATA = {
+  discordtag: "testUser#1234",
+  nickname: "TestNickname",
+};
+
+const MOCK_CLAN_DATA = {
+  clanname: "Test Clan",
+  clancolor: "#FF5500",
+  clandiscord: "testinvite",
+  symbol: "C1",
+  region: "EU-Official",
+  recruit: true,
+};
+
+test.describe("Clan Creation Flow", () => {
+  test.beforeEach(async ({ page }) => {
path: playwright/e2e/clan-creation.spec.ts
user: login: sourcery-ai[bot]
body: **issue (complexity):** Consider extracting repeated route mocks and initialization logic into a shared helper function to avoid duplication and simplify test setup.

Consider extracting all common route mocks (and the init‐script) into a small helper so you only declare them once, then override only when needed. For example:

```ts
// tests/helpers/mocks.ts
import type { Page } from '@playwright/test';
import type { CreateClanRequestParams } from '../../src/types/dto/clan';

export const MOCK_USER_DATA = {
  discordtag: 'testUser#1234',
  nickname: 'TestNickname',
};

export const MOCK_CLUSTER_DATA = [
  { region: 'EU', name: 'Official', clan_limit: 999, crossplay: true },
  { region: 'NA', name: 'Official', clan_limit: 999, crossplay: true },
];

export const DEFAULT_CLAN_RESPONSE = { message: 'Clan created successfully', id: 123 };

export async function mockAllRoutes(
  page: Page,
  opts: { clanResponse?: object } = {}
) {
  const clanResponse = opts.clanResponse ?? DEFAULT_CLAN_RESPONSE;

  await page.route('**/users', r =>
    r.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(MOCK_USER_DATA),
    })
  );

  await page.route('**/clusters', r =>
    r.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(MOCK_CLUSTER_DATA),
    })
  );

  await page.route('**/clans', async r => {
    if (r.request().method() === 'POST') {
      await r.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(clanResponse),
      });
    } else {
      await r.continue();
    }
  });

  await page.addInitScript(() => {
    window.localStorage.setItem('token', 'mock-test-token');
    window.localStorage.setItem('discordid', 'mock-discord-id');
  });
}
```

Then in your spec:

```ts
import { test, expect } from '@playwright/test';
import {
  mockAllRoutes,
  MOCK_USER_DATA,
  DEFAULT_CLAN_RESPONSE,
} from './helpers/mocks';

test.describe('Clan Creation Flow', () => {
  test.beforeEach(async ({ page }) => {
    await mockAllRoutes(page);
    await page.goto('/profile');
    await page.waitForLoadState('networkidle');
  });

  test('Should open clan creation modal and create a clan', async ({ page }) => {
    await expect(page.getByTestId('discord-tag')).toHaveText(MOCK_USER_DATA.discordtag);

    // no need to re-mock /clans – already handled
    await page.getByText('Create a clan').click();
    // ...rest of the flow
  });

  test('Should validate required fields in clan creation form', async ({ page }) => {
    await page.getByText('Create a clan').click();
    // ...validation assertions
  });
});
```

This collapses all repeated `page.route` calls into one helper, removes the second `/clans` mock inside the first test, and keeps every validation and flow intact.
created_at: 2025-05-18T21:15:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: sourcery-ai[bot]
created_at: 2025-05-18T21:12:44Z
body: <!-- Generated by sourcery-ai[bot]: start review_guide -->

## Reviewer's Guide

This PR instruments key UI elements with data-testid attributes and introduces comprehensive Playwright end-to-end tests covering clan request, creation, and member acceptance flows by mocking backend endpoints and simulating user interactions.

### File-Level Changes

| Change | Details | Files |
| ------ | ------- | ----- |
| Instrumented UI buttons and links with test IDs | <ul><li>Added data-testid to send request button</li><li>Added data-testid to view members link</li><li>Added data-testid to clan list search button</li><li>Added data-testid to request submission button</li><li>Added data-testid to accept and reject member buttons</li><li>Added data-testid to clan configuration submit button</li></ul> | `src/components/ClanList/ClanListItem.tsx`<br/>`src/pages/ClanList.tsx`<br/>`src/pages/MemberList.tsx`<br/>`src/components/ClanConfig.tsx` |
| Added Playwright E2E test suites for clan flows | <ul><li>Mocked network routes for user, clan, member, request, and cluster APIs</li><li>Injected mock auth into localStorage via init script</li><li>Wrote tests for clan member acceptance flow</li><li>Wrote tests for clan creation flow with validation checks</li><li>Wrote tests for sending and handling clan join requests</li></ul> | `playwright/e2e/clan-member-acceptance.spec.ts`<br/>`playwright/e2e/clan-creation.spec.ts`<br/>`playwright/e2e/clan-request.spec.ts` |

---

<details>
<summary>Tips and commands</summary>

#### Interacting with Sourcery

- **Trigger a new review:** Comment `@sourcery-ai review` on the pull request.
- **Continue discussions:** Reply directly to Sourcery's review comments.
- **Generate a GitHub issue from a review comment:** Ask Sourcery to create an
  issue from a review comment by replying to it. You can also reply to a
  review comment with `@sourcery-ai issue` to create an issue from it.
- **Generate a pull request title:** Write `@sourcery-ai` anywhere in the pull
  request title to generate a title at any time. You can also comment
  `@sourcery-ai title` on the pull request to (re-)generate the title at any time.
- **Generate a pull request summary:** Write `@sourcery-ai summary` anywhere in
  the pull request body to generate a PR summary at any time exactly where you
  want it. You can also comment `@sourcery-ai summary` on the pull request to
  (re-)generate the summary at any time.
- **Generate reviewer's guide:** Comment `@sourcery-ai guide` on the pull
  request to (re-)generate the reviewer's guide at any time.
- **Resolve all Sourcery comments:** Comment `@sourcery-ai resolve` on the
  pull request to resolve all Sourcery comments. Useful if you've already
  addressed all the comments and don't want to see them anymore.
- **Dismiss all Sourcery reviews:** Comment `@sourcery-ai dismiss` on the pull
  request to dismiss all existing Sourcery reviews. Especially useful if you
  want to start fresh with a new review - don't forget to comment
  `@sourcery-ai review` to trigger a new review!

#### Customizing Your Experience

Access your [dashboard](https://app.sourcery.ai) to:
- Enable or disable review features such as the Sourcery-generated pull request
  summary, the reviewer's guide, and others.
- Change the review language.
- Add, remove or edit custom review instructions.
- Adjust other review settings.

#### Getting Help

- [Contact our support team](mailto:support@sourcery.ai) for questions or feedback.
- Visit our [documentation](https://docs.sourcery.ai) for detailed guides and information.
- Keep in touch with the Sourcery team by following us on [X/Twitter](https://x.com/SourceryAI), [LinkedIn](https://www.linkedin.com/company/sourcery-ai/) or [GitHub](https://github.com/sourcery-ai).

</details>

<!-- Generated by sourcery-ai[bot]: end review_guide -->
author_association: CONTRIBUTOR
user: login: vercel[bot]
created_at: 2025-05-18T21:12:44Z
body: [vc]: #6g5eo19PuFf78vtYxDj/PoWzGPt+BqA4UB1EwrVb9c0=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJzdGlsZXR0by13ZWIiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoic3RpbGV0dG8td2ViLWdpdC10ZXN0LW0tMTMyODBiLWRhbmllbC1tYXJ0aW5zLXByb2plY3RzLWJjMmIyY2ZhLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhbmllbC1tYXJ0aW5zLXByb2plY3RzLWJjMmIyY2ZhL3N0aWxldHRvLXdlYi8zYmtoRTEyRllZYnBTWVByWHBBNjVYTXRvR2VwIiwicHJldmlld1VybCI6InN0aWxldHRvLXdlYi1naXQtdGVzdC1tLTEzMjgwYi1kYW5pZWwtbWFydGlucy1wcm9qZWN0cy1iYzJiMmNmYS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **stiletto-web** | ✅ Ready ([Inspect](https://vercel.com/daniel-martins-projects-bc2b2cfa/stiletto-web/3bkhE12FYYbpSYPrXpA65XMtoGep)) | [Visit Preview](https://stiletto-web-git-test-m-13280b-daniel-martins-projects-bc2b2cfa.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/stiletto-web-git-test-m-13280b-daniel-martins-projects-bc2b2cfa.vercel.app?via=pr-comment-feedback-link) | May 19, 2025 7:10pm |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-18T21:12:52Z
body: <h1>Dependency Review</h1>
✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.<h2>Scanned Files</h2>
None

<!-- dependency-review-pr-comment-marker -->
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T19:11:20Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=dm94_stiletto-web&pullRequest=242) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [9.7% Duplication on New Code](https://sonarcloud.io/component_measures?id=dm94_stiletto-web&pullRequest=242&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dm94_stiletto-web&pullRequest=242)


author_association: NONE