diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 5efd78826..6b3b0e8e8 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,49 +1,24 @@ -import type { Page } from "@playwright/test"; -import { ORG_ADMIN_TEST_ID } from "~/db/seed/constants"; +import { addHours } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { tournamentTeamPage } from "~/utils/urls"; -import { expect, impersonate, navigate, test } from "./helpers/playwright"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import type { Factories } from "./helpers/factories"; +import { expect, impersonate, test } from "./helpers/playwright"; +import { ApiPage } from "./pages/api/api-page"; +import { TournamentTeamPage } from "./pages/tournament/tournament-team-page"; -const ITZ_TOURNAMENT_ID = 2; -const ITZ_TEAM_ID = 101; -const USER_NOT_ON_ITZ_TEAM = 100; +const ROSTER_SIZE = 4; +const TOKEN_LENGTH = 20; -async function generateReadToken(page: Page): Promise { - await navigate({ page, url: "/api" }); - - await page.locator("form").first().getByRole("button").click(); - await page.waitForURL("/api"); - - await page - .getByRole("button", { name: /reveal/i }) - .first() - .click(); - const token = await page.locator("input[readonly]").inputValue(); - - return token; -} - -async function generateWriteToken(page: Page): Promise { - await navigate({ page, url: "/api" }); - - // Click the second form (write token) - await page.locator("form").nth(1).getByRole("button").click(); - await page.waitForURL("/api"); - - // Reveal and get the write token - // After generating only the write token, there's just one reveal button (for write) - await page.getByRole("button", { name: /reveal/i }).click(); - const token = await page.locator("input[readonly]").inputValue(); - - return token; -} +const authorized = (token: string) => ({ + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", +}); test.describe("Public API", () => { test("OPTIONS preflight request returns 204 with CORS headers", async ({ page, }) => { - // await seed(page); - const response = await page.request.fetch("/api/tournament/1", { method: "OPTIONS", }); @@ -57,8 +32,6 @@ test.describe("Public API", () => { }); test("GET request includes CORS headers in response", async ({ page }) => { - // await seed(page); - const response = await page.request.fetch("/api/tournament/1"); expect(response.headers()["access-control-allow-origin"]).toBe("*"); @@ -67,8 +40,6 @@ test.describe("Public API", () => { test("GET user IDs endpoint works without authentication", async ({ page, }) => { - // await seed(page); - const response = await page.request.fetch(`/api/user/${ADMIN_ID}/ids`); expect(response.status()).toBe(200); @@ -77,28 +48,22 @@ test.describe("Public API", () => { expect(data.discordId).toBeTruthy(); }); - test("creates read API token and calls public endpoint", async ({ page }) => { - // await seed(page); + test("creates read API token and calls public endpoint", async ({ + page, + factories, + }) => { + await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] }); + await impersonate(page); - await navigate({ page, url: "/api" }); + const api = new ApiPage(page); + await api.goto(); - await page.locator("form").first().getByRole("button").click(); - await page.waitForURL("/api"); - - await page - .getByRole("button", { name: /reveal/i }) - .first() - .click(); - - const token = await page.locator("input[readonly]").inputValue(); - expect(token).toBeTruthy(); - expect(token.length).toBe(20); + const token = await api.generateToken("read"); + expect(token).toHaveLength(TOKEN_LENGTH); const response = await page.request.fetch(`/api/user/${ADMIN_ID}`, { - headers: { - Authorization: `Bearer ${token}`, - }, + headers: authorized(token), }); expect(response.status()).toBe(200); @@ -107,24 +72,25 @@ test.describe("Public API", () => { expect(data.name).toBe("Sendou"); }); - test("returns active SendouQ match for user", async ({ page }) => { - // await seed(page, "IN_SQ_MATCH"); + test("returns active SendouQ match for user", async ({ page, factories }) => { + const alpha = await factories.UserFactory.createMany(FULL_GROUP_SIZE); + const bravo = await factories.UserFactory.createMany(FULL_GROUP_SIZE); + const match = await factories.SQMatchFactory.create({ + alphaUserIds: alpha.map((user) => user.id), + bravoUserIds: bravo.map((user) => user.id), + }); + const token = await readToken(factories, ADMIN_ID); + await impersonate(page); - const token = await generateReadToken(page); - const response = await page.request.fetch( - `/api/user/${ADMIN_ID}/active-match`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, + `/api/user/${alpha[0].id}/active-match`, + { headers: authorized(token) }, ); expect(response.status()).toBe(200); const data = await response.json(); - expect(data.matchId).toEqual(expect.any(Number)); + expect(data.matchId).toBe(match.id); expect(data.lobby).toBe("sendouq"); expect(data.tournamentId).toBeNull(); expect(data.bracketIdx).toBeNull(); @@ -132,100 +98,83 @@ test.describe("Public API", () => { }); test.describe("Public API - Write endpoints", () => { - test("adds member to tournament team via API", async ({ page }) => { - // await seed(page); + test("adds member to tournament team via API", async ({ + page, + factories, + }) => { + const { tournamentId, teamId, token } = + await organizedTournament(factories); + const newMember = await factories.UserFactory.create(); + await impersonate(page, ADMIN_ID); - const token = await generateWriteToken(page); - const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/add-member`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized(token), + data: { userId: newMember.id }, }, ); expect(response.status()).toBe(200); - // Verify in UI that member was added - await navigate({ - page, - url: tournamentTeamPage({ - tournamentId: ITZ_TOURNAMENT_ID, - tournamentTeamId: ITZ_TEAM_ID, - }), - }); + const teamPage = new TournamentTeamPage(page); + await teamPage.goto(tournamentId, teamId); - // User 100 should be visible on the team page - await expect(page.getByTestId("team-member-name")).toHaveCount(5); + await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1); }); - test("removes member from tournament team via API", async ({ page }) => { - // await seed(page); + test("removes member from tournament team via API", async ({ + page, + factories, + }) => { + const { tournamentId, teamId, token } = + await organizedTournament(factories); + const newMember = await factories.UserFactory.create(); + await impersonate(page, ADMIN_ID); - const token = await generateWriteToken(page); - - // First add the member await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/add-member`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized(token), + data: { userId: newMember.id }, }, ); - // Verify member was added - await navigate({ - page, - url: tournamentTeamPage({ - tournamentId: ITZ_TOURNAMENT_ID, - tournamentTeamId: ITZ_TEAM_ID, - }), - }); - await expect(page.getByTestId("team-member-name")).toHaveCount(5); + const teamPage = new TournamentTeamPage(page); + await teamPage.goto(tournamentId, teamId); + await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE + 1); - // Remove the member via API const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/remove-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/remove-member`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized(token), + data: { userId: newMember.id }, }, ); expect(response.status()).toBe(200); - // Verify in UI that member was removed - await page.reload(); - await expect(page.getByTestId("team-member-name")).toHaveCount(4); + await teamPage.goto(tournamentId, teamId); + await expect(teamPage.locators.memberNames).toHaveCount(ROSTER_SIZE); }); - test("returns 401 for invalid token", async ({ page }) => { - // await seed(page); + test("returns 401 for invalid token", async ({ page, factories }) => { + const { tournamentId, teamId } = await organizedTournament(factories); + const newMember = await factories.UserFactory.create(); + + await impersonate(page, ADMIN_ID); const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/add-member`, { method: "POST", - headers: { - Authorization: "Bearer invalid-token-12345", - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized("invalid-token-12345"), + data: { userId: newMember.id }, }, ); @@ -236,35 +185,20 @@ test.describe("Public API - Write endpoints", () => { test("returns 403 when using read token for write endpoint", async ({ page, + factories, }) => { - // await seed(page); + const { tournamentId, teamId } = await organizedTournament(factories); + const newMember = await factories.UserFactory.create(); + const token = await readToken(factories, ADMIN_ID); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: "/api" }); - - // Click the first form (read token) - await page.locator("form").first().getByRole("button").click(); - await page.waitForURL("/api"); - - // Reveal and get the read token - await page - .getByRole("button", { name: /reveal/i }) - .first() - .click(); - const readToken = await page - .locator("input[readonly]") - .first() - .inputValue(); - const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/add-member`, { method: "POST", - headers: { - Authorization: `Bearer ${readToken}`, - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized(token), + data: { userId: newMember.id }, }, ); @@ -273,31 +207,28 @@ test.describe("Public API - Write endpoints", () => { expect(data.error).toBe("Write token required"); }); - test("updates tournament seeds via API", async ({ page }) => { - // await seed(page); + test("updates tournament seeds via API", async ({ page, factories }) => { + const { tournamentId, token } = await organizedTournament(factories, { + teamCount: 3, + }); + await impersonate(page, ADMIN_ID); - const token = await generateWriteToken(page); - const teamsResponse = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams`, - { - headers: { Authorization: `Bearer ${token}` }, - }, + `/api/tournament/${tournamentId}/teams`, + { headers: authorized(token) }, ); expect(teamsResponse.status()).toBe(200); const teams = await teamsResponse.json(); - const tournamentTeamIds = teams.map((t: { id: number }) => t.id); - const reversedSeeds = [...tournamentTeamIds].reverse(); + const reversedSeeds = teams + .map((team: { id: number }) => team.id) + .reverse(); const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/seeds`, + `/api/tournament/${tournamentId}/seeds`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, + headers: authorized(token), data: { tournamentTeamIds: reversedSeeds }, }, ); @@ -305,48 +236,36 @@ test.describe("Public API - Write endpoints", () => { expect(response.status()).toBe(200); const updatedTeamsResponse = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams`, - { - headers: { Authorization: `Bearer ${token}` }, - }, + `/api/tournament/${tournamentId}/teams`, + { headers: authorized(token) }, ); const updatedTeams = await updatedTeamsResponse.json(); - for (let i = 0; i < reversedSeeds.length; i++) { + for (const [index, tournamentTeamId] of reversedSeeds.entries()) { const team = updatedTeams.find( - (t: { id: number }) => t.id === reversedSeeds[i], + (candidate: { id: number }) => candidate.id === tournamentTeamId, ); - expect(team.seed).toBe(i + 1); + expect(team.seed).toBe(index + 1); } }); - test("updates tournament starting brackets via API", async ({ page }) => { - // await seed(page); + test("updates tournament starting brackets via API", async ({ + page, + factories, + }) => { + const { tournamentId, teamId, token } = + await organizedTournament(factories); + await impersonate(page, ADMIN_ID); - const token = await generateWriteToken(page); - - const teamsResponse = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams`, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ); - expect(teamsResponse.status()).toBe(200); - const teams = await teamsResponse.json(); - const firstTeamId = teams[0].id; - const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/starting-brackets`, + `/api/tournament/${tournamentId}/starting-brackets`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, + headers: authorized(token), data: { startingBrackets: [ - { tournamentTeamId: firstTeamId, startingBracketIdx: 0 }, + { tournamentTeamId: teamId, startingBracketIdx: 0 }, ], }, }, @@ -355,21 +274,18 @@ test.describe("Public API - Write endpoints", () => { expect(response.status()).toBe(200); }); - test("updates member IGN via API", async ({ page }) => { - // await seed(page); + test("updates member IGN via API", async ({ page, factories }) => { + const { tournamentId, teamId, memberUserIds, token } = + await organizedTournament(factories); + await impersonate(page, ADMIN_ID); - const token = await generateWriteToken(page); - const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/update-member-ign`, + `/api/tournament/${tournamentId}/teams/${teamId}/update-member-ign`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - data: { userId: ADMIN_ID, inGameName: "NewName#9999" }, + headers: authorized(token), + data: { userId: memberUserIds[0], inGameName: "NewName#9999" }, }, ); @@ -378,21 +294,26 @@ test.describe("Public API - Write endpoints", () => { test("returns 400 when user is not the organizer of this tournament", async ({ page, + factories, }) => { - // await seed(page); - await impersonate(page, ORG_ADMIN_TEST_ID); + const { tournamentId, teamId } = await organizedTournament(factories); + const outsider = await factories.UserFactory.create(null, { + roles: ["API_ACCESSER"], + }); + const newMember = await factories.UserFactory.create(); + const { token } = await factories.ApiTokenFactory.create({ + userId: outsider.id, + type: "write", + }); - const token = await generateWriteToken(page); + await impersonate(page, outsider.id); const response = await page.request.fetch( - `/api/tournament/${ITZ_TOURNAMENT_ID}/teams/${ITZ_TEAM_ID}/add-member`, + `/api/tournament/${tournamentId}/teams/${teamId}/add-member`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - data: { userId: USER_NOT_ON_ITZ_TEAM }, + headers: authorized(token), + data: { userId: newMember.id }, }, ); @@ -401,3 +322,50 @@ test.describe("Public API - Write endpoints", () => { expect(data.error).toBe("Unauthorized"); }); }); + +/** A tournament the admin organizes, with teams registered and a write token to manage it with. */ +async function organizedTournament( + factories: Factories, + { teamCount = 1 }: { teamCount?: number } = {}, +) { + await factories.UserFactory.grant(ADMIN_ID, { roles: ["API_ACCESSER"] }); + + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); + + const teams = []; + for (let teamNth = 0; teamNth < teamCount; teamNth++) { + const roster = await factories.UserFactory.createMany(ROSTER_SIZE); + teams.push( + await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: roster.map((user) => user.id), + }), + ); + } + + const { token } = await factories.ApiTokenFactory.create({ + userId: ADMIN_ID, + type: "write", + }); + + return { + tournamentId: tournament.id, + teamId: teams[0].id, + memberUserIds: teams[0].memberUserIds, + token, + }; +} + +async function readToken(factories: Factories, userId: number) { + await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] }); + + const { token } = await factories.ApiTokenFactory.create({ + userId, + type: "read", + }); + + return token; +} diff --git a/e2e/ban.spec.ts b/e2e/ban.spec.ts index 630fba3db..a296c7bb8 100644 --- a/e2e/ban.spec.ts +++ b/e2e/ban.spec.ts @@ -1,135 +1,68 @@ -import type { Page } from "@playwright/test"; +import { addDays, setHours, startOfHour } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { ADMIN_PAGE, SUSPENDED_PAGE } from "~/utils/urls"; -import { - expect, - impersonate, - navigate, - test, - waitForPOSTResponse, -} from "./helpers/playwright"; - -async function banUser( - page: Page, - options: { duration?: string; reason?: string }, -) { - const banForm = page - .locator("form") - .filter({ has: page.locator("h2", { hasText: /^Ban user$/ }) }); - - const comboboxButton = banForm.getByLabel("User"); - await expect(comboboxButton).not.toBeDisabled(); - await comboboxButton.click(); - - const searchInput = page.getByTestId("user-search-input"); - await searchInput.fill("N-ZAP"); - - const option = page.getByTestId("user-search-item").first(); - await option.click(); - - if (options.duration) { - await banForm.locator('input[name="duration"]').fill(options.duration); - } - if (options.reason) { - await banForm.locator('input[name="reason"]').fill(options.reason); - } - - await waitForPOSTResponse(page, () => - banForm.getByRole("button", { name: "Save" }).click(), - ); -} - -async function unbanUser(page: Page) { - const unbanForm = page - .locator("form") - .filter({ has: page.locator("h2", { hasText: /^Unban user$/ }) }); - - const comboboxButton = unbanForm.getByLabel("User"); - await expect(comboboxButton).not.toBeDisabled(); - await comboboxButton.click(); - - const searchInput = page.getByTestId("user-search-input"); - await searchInput.fill("N-ZAP"); - - const option = page.getByTestId("user-search-item").first(); - await option.click(); - - await waitForPOSTResponse(page, () => - unbanForm.getByRole("button", { name: "Save" }).click(), - ); -} +import { SUSPENDED_PAGE } from "~/utils/urls"; +import { expect, impersonate, navigate, test } from "./helpers/playwright"; +import { AdminBanPage } from "./pages/ban/admin-ban-page"; +import { SuspendedPage } from "./pages/ban/suspended-page"; test.describe("User banning", () => { test("banned user is redirected to suspended page and cannot access site", async ({ page, }) => { - // await seed(page); - - // 1. As admin, ban NZAP user await impersonate(page, ADMIN_ID); - await navigate({ page, url: ADMIN_PAGE }); - await banUser(page, { reason: "Test ban reason" }); - // 2. As the banned user, try to access the site + const adminBan = new AdminBanPage(page); + await adminBan.goto(); + await adminBan.banUser("N-ZAP", { reason: "Test ban reason" }); + await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - // Should be redirected to suspended page + const suspended = new SuspendedPage(page); await expect(page).toHaveURL(SUSPENDED_PAGE); - await expect(page.getByText("Account suspended")).toBeVisible(); - await expect(page.getByText("Reason: Test ban reason")).toBeVisible(); - await expect(page.getByText("no end time set")).toBeVisible(); + await expect(suspended.locators.heading).toBeVisible(); + await expect(suspended.reason("Test ban reason")).toBeVisible(); + await expect(suspended.locators.noEndTime).toBeVisible(); - // 3. Verify user cannot navigate to other pages await navigate({ page, url: "/builds" }); await expect(page).toHaveURL(SUSPENDED_PAGE); await navigate({ page, url: "/calendar" }); await expect(page).toHaveURL(SUSPENDED_PAGE); - // 4. As admin, unban the user await impersonate(page, ADMIN_ID); - await navigate({ page, url: ADMIN_PAGE }); - await unbanUser(page); + await adminBan.goto(); + await adminBan.unbanUser("N-ZAP"); - // 5. As the unbanned user, verify they can access the site await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - // Should not be redirected to suspended page await expect(page).not.toHaveURL(SUSPENDED_PAGE); }); test("timed ban shows expiration date on suspended page", async ({ page, }) => { - // await seed(page); - - // 1. As admin, ban NZAP user with a future duration await impersonate(page, ADMIN_ID); - await navigate({ page, url: ADMIN_PAGE }); - // Set ban to expire tomorrow (well in the future) - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000); - const year = tomorrow.getFullYear(); - const month = String(tomorrow.getMonth() + 1).padStart(2, "0"); - const day = String(tomorrow.getDate()).padStart(2, "0"); - const formattedDate = `${year}-${month}-${day}T12:00`; - await banUser(page, { duration: formattedDate, reason: "Temporary ban" }); + const adminBan = new AdminBanPage(page); + await adminBan.goto(); + await adminBan.banUser("N-ZAP", { + until: startOfHour(setHours(addDays(new Date(), 1), 12)), + reason: "Temporary ban", + }); - // 2. As the banned user, verify redirected to suspended page await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); + const suspended = new SuspendedPage(page); await expect(page).toHaveURL(SUSPENDED_PAGE); - await expect(page.getByText("Account suspended")).toBeVisible(); - await expect(page.getByText("Reason: Temporary ban")).toBeVisible(); - // Should show expiration time, not "no end time set" - await expect(page.getByText("no end time set")).not.toBeVisible(); - await expect(page.getByText("Ends:")).toBeVisible(); + await expect(suspended.locators.heading).toBeVisible(); + await expect(suspended.reason("Temporary ban")).toBeVisible(); + await expect(suspended.locators.noEndTime).not.toBeVisible(); + await expect(suspended.locators.endsAt).toBeVisible(); - // Note: The actual time-based expiration logic is tested in unit tests - // (see app/features/ban/core/banned.test.ts) + // the time based expiration logic itself is covered by app/features/ban/core/banned.test.ts }); }); diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 5d4c85d1c..995b065f4 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -31,6 +31,7 @@ export async function loadFactories(parallelIndex: number) { return { backdate: (await import("~/db/seed/core/backdate")).backdate, + ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"), ArtFactory: await import("~/db/seed/factories/ArtFactory"), AssociationFactory: await import("~/db/seed/factories/AssociationFactory"), BadgeFactory: await import("~/db/seed/factories/BadgeFactory"), diff --git a/e2e/lfg.spec.ts b/e2e/lfg.spec.ts index 918151314..9b4318b52 100644 --- a/e2e/lfg.spec.ts +++ b/e2e/lfg.spec.ts @@ -1,132 +1,83 @@ -import { LFG_PAGE } from "~/utils/urls"; -import { - expect, - impersonate, - navigate, - submit, - test, -} from "./helpers/playwright"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { expect, impersonate, navigate, test } from "./helpers/playwright"; +import { AnythingAdder } from "./pages/layout/anything-adder"; +import { LFGPage } from "./pages/lfg/lfg-page"; +import { NewLFGPostPage } from "./pages/lfg/new-lfg-post-page"; test.describe("LFG", () => { test("adds a new lfg post", async ({ page }) => { - // await seed(page); - await impersonate(page); - await navigate({ - page, - url: LFG_PAGE, - }); + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: "/" }); - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-lfgPost").click(); + await new AnythingAdder(page).add("lfgPost"); - await page.getByLabel("Text").fill("looking for a cool team"); + const newPost = new NewLFGPostPage(page); + await newPost.form.fill("postText", "looking for a cool team"); + await newPost.save(); - await submit(page); - - // got redirected - await expect(page.getByTestId("add-filter-button")).toBeVisible(); - await expect(page.getByText("looking for a cool team")).toBeVisible(); + const lfg = new LFGPage(page); + await expect(lfg.locators.addFilterButton).toBeVisible(); + await expect(lfg.post("looking for a cool team")).toBeVisible(); }); test("creates post with custom languages", async ({ page }) => { - // await seed(page); - await impersonate(page); - await navigate({ - page, - url: LFG_PAGE, - }); + await impersonate(page, NZAP_TEST_ID); - // create post with Japanese and Korean - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-lfgPost").click(); + const newPost = new NewLFGPostPage(page); + await newPost.goto(); - await page.getByLabel("Text").fill("looking for Japanese/Korean team"); + await newPost.form.fill("postText", "looking for Japanese/Korean team"); + await newPost.checkLanguage("日本語"); + await newPost.checkLanguage("한국어"); + await newPost.save(); - await page.getByLabel("日本語").check(); - await page.getByLabel("한국어").check(); - - await submit(page); - - // verify languages are displayed - await expect(page.getByText("JA / KO")).toBeVisible(); + await expect(new LFGPage(page).languagePill("JA / KO")).toBeVisible(); }); test("edits post languages", async ({ page }) => { - // await seed(page); - await impersonate(page); - await navigate({ - page, - url: LFG_PAGE, - }); + await impersonate(page, NZAP_TEST_ID); - // create post with Dansk - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-lfgPost").click(); + const newPost = new NewLFGPostPage(page); + await newPost.goto(); - await page.getByLabel("Text").fill("test post for language editing"); + await newPost.form.fill("postText", "test post for language editing"); + await newPost.checkLanguage("Dansk"); + await newPost.save(); - await page.getByLabel("Dansk").check(); + const lfg = new LFGPage(page); + await expect(lfg.languagePill("DA")).toBeVisible(); + await expect(lfg.post("test post for language editing")).toBeVisible(); - await submit(page); + const editPost = await lfg.editFirstPost(); + await editPost.uncheckLanguage("Dansk"); + await editPost.checkLanguage("Español"); + await editPost.save(); - // wait for redirect to LFG page & verify the language is displayed - await expect(page.getByText("EN / DA", { exact: true })).toBeVisible(); - await expect(page.getByTestId("add-filter-button")).toBeVisible(); - await expect( - page.getByText("test post for language editing"), - ).toBeVisible(); - - // remove Dansk and add Spanish - await page.getByRole("link", { name: "Edit" }).first().click(); - await page.getByLabel("Dansk").uncheck(); - await page.getByLabel("Español").check(); - - await submit(page); - - // wait for redirect to LFG page - await expect(page.getByTestId("add-filter-button")).toBeVisible(); - await expect( - page.getByText("test post for language editing"), - ).toBeVisible(); - - // verify updated language is displayed - await expect(page.getByText("EN / ES", { exact: true })).toBeVisible(); + await expect(lfg.locators.addFilterButton).toBeVisible(); + await expect(lfg.post("test post for language editing")).toBeVisible(); + await expect(lfg.languagePill("ES")).toBeVisible(); }); test("filters posts by language", async ({ page }) => { - // await seed(page); - await impersonate(page); - await navigate({ - page, - url: LFG_PAGE, - }); + await impersonate(page, NZAP_TEST_ID); - // create post with Japanese - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-lfgPost").click(); + const newPost = new NewLFGPostPage(page); + await newPost.goto(); - await page.getByLabel("Text").fill("Japanese speaking team"); + await newPost.form.fill("postText", "Japanese speaking team"); + await newPost.checkLanguage("日本語"); + await newPost.save(); - await page.getByLabel("日本語").check(); + const lfg = new LFGPage(page); + await expect(lfg.post("Japanese speaking team")).toBeVisible(); - await submit(page); + await lfg.addLanguageFilter(); + await lfg.selectFilterLanguage("ja"); - // wait for redirect to LFG page - await expect(page.getByTestId("add-filter-button")).toBeVisible(); - await expect(page.getByText("Japanese speaking team")).toBeVisible(); + await expect(lfg.post("Japanese speaking team")).toBeVisible(); - // filter by Japanese - await page.getByTestId("add-filter-button").click(); - await page.getByText("Spoken language").click(); - await page.getByLabel("Spoken language").selectOption("ja"); + await lfg.selectFilterLanguage("es"); - // verify Japanese post is visible - await expect(page.getByText("Japanese speaking team")).toBeVisible(); - - // change filter to Spanish - await page.getByLabel("Spoken language").selectOption("es"); - - // verify Japanese post is not visible - await expect(page.getByText("Japanese speaking team")).not.toBeVisible(); + await expect(lfg.post("Japanese speaking team")).not.toBeVisible(); }); }); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 594d51250..b93461f73 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -1,177 +1,103 @@ +import { NZAP_TEST_ID } from "~/db/seed/constants"; import { expect, impersonate, navigate, test } from "./helpers/playwright"; +import { MobileNav } from "./pages/layout/mobile-nav"; +import { SideNav } from "./pages/layout/side-nav"; +import { TopNavMenus } from "./pages/layout/top-nav-menus"; test.describe("Navigation", () => { test("desktop navigation", async ({ page }) => { - // await seed(page); - await impersonate(page); + await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - // SideNav visible with section headings - await expect(page.getByRole("heading", { name: "Events" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible(); + const sideNav = new SideNav(page); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); + await expect(sideNav.sectionHeading("Friends")).toBeVisible(); + await expect(sideNav.sectionHeading("Streams")).toBeVisible(); + await expect(sideNav.locators.viewAllLinks.first()).toBeVisible(); - // View all links present - const viewAllLinks = page.getByRole("link", { name: /View all/ }); - await expect(viewAllLinks.first()).toBeVisible(); + await sideNav.toggleCollapse(); + await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); - // SideNav collapse/uncollapse - const collapseButton = page.getByTestId("sidenav-collapse-button"); - await collapseButton.click(); - await expect( - page.getByRole("heading", { name: "Events" }), - ).not.toBeVisible(); + await sideNav.toggleCollapse(); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); - await collapseButton.click(); - await expect(page.getByRole("heading", { name: "Events" })).toBeVisible(); + const topNav = new TopNavMenus(page); + await topNav.open("Play"); + await expect(topNav.link("SendouQ")).toBeVisible(); + await expect(topNav.link("Scrims")).toBeVisible(); - // TopNavMenus — Play - await page.getByRole("button", { name: "Play" }).click(); - const playMenu = page.locator("[class*='menuContent']"); - await expect(playMenu.getByRole("link", { name: "SendouQ" })).toBeVisible(); - await expect(playMenu.getByRole("link", { name: "Scrims" })).toBeVisible(); - await playMenu.getByRole("link", { name: "SendouQ" }).click(); - await expect( - playMenu.getByRole("link", { name: "SendouQ" }), - ).not.toBeVisible(); + await topNav.link("SendouQ").click(); + await expect(topNav.link("SendouQ")).not.toBeVisible(); await navigate({ page, url: "/" }); - // TopNavMenus — Tools - await page.getByRole("button", { name: "Tools" }).click(); - const toolsMenu = page.locator("[class*='menuContent']"); - await expect( - toolsMenu.getByRole("link", { name: "Analyzer" }), - ).toBeVisible(); - await page.keyboard.press("Escape"); + await topNav.open("Tools"); + await expect(topNav.link("Analyzer")).toBeVisible(); + await topNav.close(); - // TopNavMenus — Community - await page.getByRole("button", { name: "Community" }).click(); - const communityMenu = page.locator("[class*='menuContent']"); - await expect( - communityMenu.getByRole("link", { name: "Builds" }), - ).toBeVisible(); - await page.keyboard.press("Escape"); + await topNav.open("Community"); + await expect(topNav.link("Builds")).toBeVisible(); + await topNav.close(); - // SideNav footer — user info - await expect(page.getByTestId("notifications-button")).toBeVisible(); + await expect(sideNav.locators.notificationsButton).toBeVisible(); }); test("mobile navigation", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); - // await seed(page); - await impersonate(page); + await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - // Tab bar visible - const menuTab = page.getByRole("button", { name: "Menu" }); - const friendsTab = page.getByRole("button", { name: "Friends" }); - const calendarTab = page.getByRole("button", { name: "Events" }); - const chatTab = page.getByRole("button", { name: "Chat" }); - const youTab = page.getByRole("button", { name: "You" }); + const mobileNav = new MobileNav(page); + await expect(mobileNav.tab("menu")).toBeVisible(); + await expect(mobileNav.tab("friends")).toBeVisible(); + await expect(mobileNav.tab("tourneys")).toBeVisible(); + await expect(mobileNav.tab("chat")).toBeVisible(); + await expect(mobileNav.tab("you")).toBeVisible(); - await expect(menuTab).toBeVisible(); - await expect(friendsTab).toBeVisible(); - await expect(calendarTab).toBeVisible(); - await expect(chatTab).toBeVisible(); - await expect(youTab).toBeVisible(); + await mobileNav.openPanel("menu"); + await expect(mobileNav.menuLink("SendouQ")).toBeVisible(); + await expect(mobileNav.menuLink("Analyzer")).toBeVisible(); + await expect(mobileNav.menuLink("Builds")).toBeVisible(); + await expect(mobileNav.locators.streamsHeading).toBeVisible(); - // Menu panel — open and verify contents - await menuTab.click(); - const mobileMenu = page.getByLabel("Menu", { exact: true }); - await expect( - mobileMenu.getByRole("link", { name: "SendouQ" }), - ).toBeVisible(); - await expect( - mobileMenu.getByRole("link", { name: "Analyzer" }), - ).toBeVisible(); - await expect( - mobileMenu.getByRole("link", { name: "Builds" }), - ).toBeVisible(); - await expect( - page.locator("h3").filter({ hasText: "Streams" }), - ).toBeVisible(); + await mobileNav.switchPanel("friends"); + await expect(mobileNav.menuLink("SendouQ")).not.toBeVisible(); + await expect(mobileNav.locators.viewAllLink).toBeVisible(); - // Switch from menu to friends panel via ghost tab - // Ghost tabs are invisible overlays; use dispatchEvent to trigger them - // nth(1) = "friends" ghost tab (0=menu, 1=friends, 2=tourneys, 3=chat, 4=you) - await page - .locator("[class*='ghostTab']:not([class*='ghostTabBar'])") - .nth(1) - .dispatchEvent("click"); - await expect( - mobileMenu.getByRole("link", { name: "SendouQ" }), - ).not.toBeVisible(); - const friendsViewAll = page.getByRole("link", { - name: "View all", - exact: true, - }); - await expect(friendsViewAll).toBeVisible(); + await mobileNav.switchPanel("you"); + await expect(mobileNav.locators.youPanelUsername).toBeVisible(); - // Switch to You panel via ghost tab (nth(4) = "you") - await page - .locator("[class*='ghostTab']:not([class*='ghostTabBar'])") - .nth(4) - .dispatchEvent("click"); - // You panel shows user info (username link) - await expect(page.locator("[class*='youPanelUsername']")).toBeVisible(); + await mobileNav.switchPanel("tourneys"); + await expect(mobileNav.locators.viewAllLink).toBeVisible(); - // Switch to calendar panel via ghost tab (nth(2) = "tourneys") - await page - .locator("[class*='ghostTab']:not([class*='ghostTabBar'])") - .nth(2) - .dispatchEvent("click"); - const tourneysViewAll = page.getByRole("link", { - name: "View all", - exact: true, - }); - await expect(tourneysViewAll).toBeVisible(); - - // Close panel via X button - await page.locator("button:has(svg.lucide-x)").first().click(); - await expect(tourneysViewAll).not.toBeVisible(); + await mobileNav.closePanel(); + await expect(mobileNav.locators.viewAllLink).not.toBeVisible(); }); test("tablet navigation", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); - // await seed(page); - await impersonate(page); + await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: "/" }); - // SideNav not visible as permanent sidebar - await expect( - page.getByRole("heading", { name: "Events" }), - ).not.toBeVisible(); - await expect( - page.getByRole("heading", { name: "Friends" }), - ).not.toBeVisible(); + const sideNav = new SideNav(page); + await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); + await expect(sideNav.sectionHeading("Friends")).not.toBeVisible(); - // Hamburger opens SideNav modal - const modalTrigger = page.getByTestId("sidenav-modal-trigger"); - await modalTrigger.click(); + await sideNav.openModal(); - await expect(page.getByRole("heading", { name: "Events" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Friends" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Streams" })).toBeVisible(); - await expect( - page.getByRole("link", { name: /View all/ }).first(), - ).toBeVisible(); + await expect(sideNav.sectionHeading("Events")).toBeVisible(); + await expect(sideNav.sectionHeading("Friends")).toBeVisible(); + await expect(sideNav.sectionHeading("Streams")).toBeVisible(); + await expect(sideNav.locators.viewAllLinks.first()).toBeVisible(); - // Close modal by pressing Escape - await page.keyboard.press("Escape"); - await expect( - page.getByRole("heading", { name: "Events" }), - ).not.toBeVisible(); + await sideNav.closeModal(); + await expect(sideNav.sectionHeading("Events")).not.toBeVisible(); - // TopNavMenus still work - await page.getByRole("button", { name: "Play" }).click(); - const tabletPlayMenu = page.locator("[class*='menuContent']"); - await expect( - tabletPlayMenu.getByRole("link", { name: "SendouQ" }), - ).toBeVisible(); - await page.keyboard.press("Escape"); + const topNav = new TopNavMenus(page); + await topNav.open("Play"); + await expect(topNav.link("SendouQ")).toBeVisible(); + await topNav.close(); - // MobileNav hidden - await expect(page.getByRole("button", { name: "Menu" })).not.toBeVisible(); + await expect(new MobileNav(page).tab("menu")).not.toBeVisible(); }); }); diff --git a/e2e/pages/api/api-page.ts b/e2e/pages/api/api-page.ts new file mode 100644 index 000000000..016c89467 --- /dev/null +++ b/e2e/pages/api/api-page.ts @@ -0,0 +1,52 @@ +import type { Page } from "@playwright/test"; +import type { ApiTokenType } from "~/features/api/api-types"; +import { API_PAGE } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; + +const HEADINGS: Record = { + read: "Read Token", + write: "Write Token", +}; + +/** Where a user with API access generates their tokens. */ +export class ApiPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + noAccessMessage: page.getByText("You do not have access to the API"), + // the revealed token is shown in a popover, rendered outside its section + tokenInput: page.locator("input[readonly]"), + }; + } + + async goto() { + await navigate({ page: this.page, url: API_PAGE }); + } + + async generateToken(type: ApiTokenType) { + await this.section(type) + .getByRole("button", { name: "Generate Token" }) + .click(); + await this.page.waitForURL(API_PAGE); + + return this.revealToken(type); + } + + async revealToken(type: ApiTokenType) { + await this.section(type) + .getByRole("button", { name: /reveal/i }) + .click(); + + return this.locators.tokenInput.inputValue(); + } + + /** Both token sections look the same, only their heading tells them apart. */ + private section(type: ApiTokenType) { + return this.page + .locator("div:has(> div > h2)") + .filter({ hasText: HEADINGS[type] }); + } +} diff --git a/e2e/pages/ban/admin-ban-page.ts b/e2e/pages/ban/admin-ban-page.ts new file mode 100644 index 000000000..deb394728 --- /dev/null +++ b/e2e/pages/ban/admin-ban-page.ts @@ -0,0 +1,74 @@ +import type { Locator, Page } from "@playwright/test"; +import { format } from "date-fns"; +import { ADMIN_PAGE } from "~/utils/urls"; +import { + navigate, + selectUser, + waitForPOSTResponse, +} from "../../helpers/playwright"; + +/** The ban & unban forms of the admin page. */ +export class AdminBanPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + banForm: page + .locator("form") + .filter({ has: page.locator("h2", { hasText: /^Ban user$/ }) }), + unbanForm: page + .locator("form") + .filter({ has: page.locator("h2", { hasText: /^Unban user$/ }) }), + }; + } + + async goto() { + await navigate({ page: this.page, url: ADMIN_PAGE }); + } + + async banUser( + userName: string, + options: { until?: Date; reason?: string } = {}, + ) { + const form = this.locators.banForm; + + await selectUser({ + page: this.page, + userName, + labelName: "User", + within: form, + }); + + if (options.until) { + await form + .locator('input[name="duration"]') + .fill(format(options.until, "yyyy-MM-dd'T'HH:mm")); + } + if (options.reason) { + await form.locator('input[name="reason"]').fill(options.reason); + } + + await this.save(form); + } + + async unbanUser(userName: string) { + const form = this.locators.unbanForm; + + await selectUser({ + page: this.page, + userName, + labelName: "User", + within: form, + }); + + await this.save(form); + } + + private async save(form: Locator) { + await waitForPOSTResponse(this.page, () => + form.getByRole("button", { name: "Save" }).click(), + ); + } +} diff --git a/e2e/pages/ban/suspended-page.ts b/e2e/pages/ban/suspended-page.ts new file mode 100644 index 000000000..298babe77 --- /dev/null +++ b/e2e/pages/ban/suspended-page.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; + +/** Where a banned user ends up no matter which page they try to visit. */ +export class SuspendedPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + heading: page.getByText("Account suspended"), + endsAt: page.getByText("Ends:"), + noEndTime: page.getByText("no end time set"), + }; + } + + reason(reason: string) { + return this.page.getByText(`Reason: ${reason}`); + } +} diff --git a/e2e/pages/layout/mobile-nav.ts b/e2e/pages/layout/mobile-nav.ts new file mode 100644 index 000000000..8de416a8e --- /dev/null +++ b/e2e/pages/layout/mobile-nav.ts @@ -0,0 +1,56 @@ +import type { Page } from "@playwright/test"; + +type Panel = "menu" | "friends" | "tourneys" | "chat" | "you"; + +const TAB_NAMES: Record = { + menu: "Menu", + friends: "Friends", + tourneys: "Events", + chat: "Chat", + you: "You", +}; + +const PANELS: Panel[] = ["menu", "friends", "tourneys", "chat", "you"]; + +/** The bottom tab bar and its panels, rendered in place of the side nav on mobile. */ +export class MobileNav { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + menuPanel: page.getByLabel("Menu", { exact: true }), + streamsHeading: page.locator("h3").filter({ hasText: "Streams" }), + viewAllLink: page.getByRole("link", { name: "View all", exact: true }), + youPanelUsername: page.locator("[class*='youPanelUsername']"), + }; + } + + tab(panel: Panel) { + return this.page.getByRole("button", { name: TAB_NAMES[panel] }); + } + + async openPanel(panel: Panel) { + await this.tab(panel).click(); + } + + /** + * Switches between panels the way the tab bar does while a panel is open. Its + * tabs are then covered by invisible overlays, which only a dispatched event reaches. + */ + async switchPanel(panel: Panel) { + await this.page + .locator("[class*='ghostTab']:not([class*='ghostTabBar'])") + .nth(PANELS.indexOf(panel)) + .dispatchEvent("click"); + } + + async closePanel() { + await this.page.locator("button:has(svg.lucide-x)").first().click(); + } + + menuLink(name: string) { + return this.locators.menuPanel.getByRole("link", { name }); + } +} diff --git a/e2e/pages/layout/side-nav.ts b/e2e/pages/layout/side-nav.ts new file mode 100644 index 000000000..201134575 --- /dev/null +++ b/e2e/pages/layout/side-nav.ts @@ -0,0 +1,35 @@ +import type { Page } from "@playwright/test"; + +type Section = "Events" | "Friends" | "Streams"; + +/** The sidebar of the site layout, a modal behind a hamburger on narrower viewports. */ +export class SideNav { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + collapseButton: page.getByTestId("sidenav-collapse-button"), + modalTrigger: page.getByTestId("sidenav-modal-trigger"), + notificationsButton: page.getByTestId("notifications-button"), + viewAllLinks: page.getByRole("link", { name: /View all/ }), + }; + } + + sectionHeading(section: Section) { + return this.page.getByRole("heading", { name: section }); + } + + async toggleCollapse() { + await this.locators.collapseButton.click(); + } + + async openModal() { + await this.locators.modalTrigger.click(); + } + + async closeModal() { + await this.page.keyboard.press("Escape"); + } +} diff --git a/e2e/pages/layout/top-nav-menus.ts b/e2e/pages/layout/top-nav-menus.ts new file mode 100644 index 000000000..ef7319114 --- /dev/null +++ b/e2e/pages/layout/top-nav-menus.ts @@ -0,0 +1,30 @@ +import type { Page } from "@playwright/test"; + +type Category = "Play" | "Tools" | "Community"; + +/** The category menus (Play, Tools, Community) of the site layout's header. */ +export class TopNavMenus { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + // a closed menu renders the same links as an icon-only preview, so + // anything asserted about an open menu has to be scoped to it + openMenu: page.locator("[class*='menuContent']"), + }; + } + + async open(category: Category) { + await this.page.getByRole("button", { name: category }).click(); + } + + async close() { + await this.page.keyboard.press("Escape"); + } + + link(name: string) { + return this.locators.openMenu.getByRole("link", { name }); + } +} diff --git a/e2e/pages/lfg/lfg-page.ts b/e2e/pages/lfg/lfg-page.ts new file mode 100644 index 000000000..7227146bb --- /dev/null +++ b/e2e/pages/lfg/lfg-page.ts @@ -0,0 +1,45 @@ +import type { Page } from "@playwright/test"; +import type { UnifiedLanguageCode } from "~/modules/i18n/config"; +import { LFG_PAGE } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { NewLFGPostPage } from "./new-lfg-post-page"; + +export class LFGPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + addFilterButton: page.getByTestId("add-filter-button"), + languageFilterSelect: page.getByLabel("Spoken language"), + }; + } + + async goto() { + await navigate({ page: this.page, url: LFG_PAGE }); + } + + post(text: string) { + return this.page.getByText(text); + } + + /** The languages of a post, rendered as one pill e.g. "JA / KO". */ + languagePill(languages: string) { + return this.page.getByText(languages, { exact: true }); + } + + async editFirstPost() { + await this.page.getByRole("link", { name: "Edit" }).first().click(); + return new NewLFGPostPage(this.page); + } + + async addLanguageFilter() { + await this.locators.addFilterButton.click(); + await this.page.getByRole("menuitem", { name: "Spoken language" }).click(); + } + + async selectFilterLanguage(code: UnifiedLanguageCode) { + await this.locators.languageFilterSelect.selectOption(code); + } +} diff --git a/e2e/pages/lfg/new-lfg-post-page.ts b/e2e/pages/lfg/new-lfg-post-page.ts new file mode 100644 index 000000000..a584d3657 --- /dev/null +++ b/e2e/pages/lfg/new-lfg-post-page.ts @@ -0,0 +1,32 @@ +import type { Page } from "@playwright/test"; +import { lfgNewSchema } from "~/features/lfg/lfg-schemas"; +import { lfgNewPostPage } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class NewLFGPostPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, lfgNewSchema); + } + + async goto() { + await navigate({ page: this.page, url: lfgNewPostPage() }); + } + + /** The language checkboxes are labeled by their own name e.g. "日本語". */ + checkLanguage(name: string) { + return this.page.getByLabel(name).check(); + } + + uncheckLanguage(name: string) { + return this.page.getByLabel(name).uncheck(); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/scrims/new-scrim-post-page.ts b/e2e/pages/scrims/new-scrim-post-page.ts new file mode 100644 index 000000000..454f77ba0 --- /dev/null +++ b/e2e/pages/scrims/new-scrim-post-page.ts @@ -0,0 +1,53 @@ +import type { Page } from "@playwright/test"; +import { scrimsNewFormSchema } from "~/features/scrims/scrims-schemas"; +import { newScrimPostPage } from "~/utils/urls"; +import { + navigate, + selectTournament, + selectUser, + submit, +} from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class NewScrimPostPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, scrimsNewFormSchema); + } + + async goto() { + await navigate({ page: this.page, url: newScrimPostPage() }); + } + + /** Posts as a pick-up instead of as a team, the author being its first user. */ + async selectPickupUsers(userNames: string[]) { + await this.page.getByLabel("With").selectOption("PICKUP"); + + for (const [index, userName] of userNames.entries()) { + await selectUser({ + page: this.page, + labelName: `User ${index + 2}`, + userName, + }); + } + } + + /** Limits who sees the post to one of the author's associations. */ + async selectVisibility(associationName: string) { + await this.page + .getByLabel("Visibility") + .selectOption({ label: associationName }); + } + + async selectTournamentMaps(tournamentName: string) { + await this.form.select("maps", "TOURNAMENT"); + await selectTournament({ page: this.page, query: tournamentName }); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/scrims/scrim-page.ts b/e2e/pages/scrims/scrim-page.ts new file mode 100644 index 000000000..6e49d5b05 --- /dev/null +++ b/e2e/pages/scrims/scrim-page.ts @@ -0,0 +1,142 @@ +import type { Page } from "@playwright/test"; +import { + cancelScrimFormSchema, + submitMapListFormSchema, +} from "~/features/scrims/scrims-schemas"; +import { scrimPage } from "~/utils/urls"; +import { + expect, + navigate, + submit, + waitForPOSTResponse, +} from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +type Side = "ALPHA" | "BRAVO"; +type Tab = "rosters" | "action" | "result" | "stats"; + +const TAB_NAMES: Record = { + rosters: "Rosters", + action: "Action", + result: "Result", + stats: "Stats", +}; + +const WINNER_RADIO_TEST_IDS: Record = { + ALPHA: "winner-radio-1", + BRAVO: "winner-radio-2", +}; + +/** A booked scrim: its rosters, the map by map reporting and the resulting stats. */ +export class ScrimPage { + private readonly page: Page; + readonly locators; + readonly mapListForm; + readonly cancelForm; + + constructor(page: Page) { + this.page = page; + this.mapListForm = createFormHelpers(page, submitMapListFormSchema, { + submitTestId: "submit-map-list-button", + }); + this.cancelForm = createFormHelpers(page, cancelScrimFormSchema, { + submitTestId: "cancel-scrim-submit", + }); + this.locators = { + subtitle: page.getByText("Scheduled scrim"), + mapListForm: page.getByTestId("scrim-map-list-form"), + manageMapListsButton: page.getByRole("button", { + name: /Manage map lists/i, + }), + reportScoreButton: page.getByTestId("report-score-button"), + undoMapButton: page.getByTestId("undo-map-button"), + replayMapButton: page.getByTestId("replay-map-button"), + statsRoot: page.getByTestId("scrim-stats-root"), + selectedWinner: page.locator( + '[data-testid^="winner-radio-"][data-selected="true"]', + ), + }; + } + + async goto(scrimId: number) { + await navigate({ page: this.page, url: scrimPage(scrimId) }); + } + + async openTab(tab: Tab) { + await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click(); + } + + mapListRow(side: Side) { + return this.page.getByTestId(`map-list-row-${side}`); + } + + /** Submits the map list with the source the form defaults to. */ + async submitMapList() { + await waitForPOSTResponse(this.page, () => this.mapListForm.submit()); + } + + async submitPoolMapList(serializedPool: string) { + await this.page.getByLabel("Pool URL").click(); + await this.mapListForm.fill("serializedPool", serializedPool); + await this.submitMapList(); + } + + /** The map lists of both sides are behind a collapsed section while a map is being played. */ + async openMapListManager() { + await this.locators.manageMapListsButton.click(); + } + + async removeOwnMapList(side: Side) { + await this.mapListRow(side) + .getByLabel(/Remove list/i) + .click(); + await waitForPOSTResponse(this.page, () => + submit(this.page, "confirm-button"), + ); + } + + async reportMapWinner(winner: Side) { + // the report of the previous map leaves the radios selected until the + // loader revalidation swaps the next map in + await expect(this.locators.selectedWinner).toHaveCount(0); + + await this.page.getByTestId(WINNER_RADIO_TEST_IDS[winner]).click(); + await submit(this.page, "report-score-button"); + } + + async undoMap() { + await submit(this.page, "undo-map-button"); + } + + /** Replaces the map waiting to be played with a copy of the last reported one. */ + async replayMap() { + await submit(this.page, "replay-map-button"); + } + + async cancel(reason: string) { + await this.page.getByRole("button", { name: "Cancel" }).click(); + await this.cancelForm.fill("reason", reason); + await submit(this.page, "cancel-scrim-submit"); + } + + /** Groups the stats by mode, counting maps outside the viewer's own pool too. */ + async showModeStatsOfAllMaps() { + await this.locators.statsRoot.getByText("Mode", { exact: true }).click(); + await this.page.getByRole("switch").click({ force: true }); + } + + /** How many maps the stats table accounts for, from the viewer's point of view. */ + async reportedMapCount() { + const wins = await this.locators.statsRoot + .locator("tbody tr td:nth-child(2)") + .allInnerTexts(); + const losses = await this.locators.statsRoot + .locator("tbody tr td:nth-child(3)") + .allInnerTexts(); + + return [...wins, ...losses].reduce( + (total, cell) => total + Number(cell), + 0, + ); + } +} diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts index 262086a43..4125938be 100644 --- a/e2e/pages/scrims/scrims-page.ts +++ b/e2e/pages/scrims/scrims-page.ts @@ -1,7 +1,23 @@ import type { Page } from "@playwright/test"; +import { scrimRequestFormSchema } from "~/features/scrims/scrims-schemas"; import { scrimsPage } from "~/utils/urls"; -import { navigate } from "../../helpers/playwright"; +import { + modalClickConfirmButton, + navigate, + selectUser, + submit, +} from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; import { AssociationsPage } from "../associations/associations-page"; +import { ScrimPage } from "./scrim-page"; + +type Tab = "available" | "owned" | "booked"; + +const TAB_NAMES: Record = { + available: "Available", + owned: "Owned", + booked: "Booked", +}; export class ScrimsPage { private readonly page: Page; @@ -11,6 +27,17 @@ export class ScrimsPage { this.page = page; this.locators = { associationsLink: page.getByRole("link", { name: "Associations" }), + requestButtons: page.getByTestId("request-scrim-button"), + viewRequestButtons: page.getByTestId("view-request-button"), + acceptRequestButtons: page.getByTestId("confirm-modal-trigger-button"), + togglePendingRequestsButton: page.getByTestId( + "toggle-pending-requests-button", + ), + deleteButtons: page.getByRole("button", { name: "Delete" }), + contactLinks: page.getByRole("link", { name: "Contact" }), + limitedVisibilityPopover: page.getByTestId("limited-visibility-popover"), + tournamentPopover: page.getByTestId("tournament-popover-trigger"), + canceledLabel: page.getByText("Canceled"), }; } @@ -18,8 +45,79 @@ export class ScrimsPage { await navigate({ page: this.page, url: scrimsPage() }); } + post(text: string) { + return this.page.getByText(text); + } + + async openTab(tab: Tab) { + await this.page.getByRole("tab", { name: TAB_NAMES[tab] }).click(); + } + async openAssociations() { await this.locators.associationsLink.click(); return new AssociationsPage(this.page); } + + async requestFirst() { + await this.locators.requestButtons.first().click(); + return new ScrimRequestModal(this.page); + } + + /** Posts of the available tab whose request is pending are hidden until asked for. */ + async showPendingRequests() { + await this.locators.togglePendingRequestsButton.first().click(); + } + + async cancelPendingRequest() { + await this.locators.viewRequestButtons.first().click(); + await this.page.getByRole("button", { name: "Cancel" }).click(); + } + + async acceptFirstRequest() { + await this.locators.acceptRequestButtons.first().click(); + await modalClickConfirmButton(this.page); + } + + async deleteFirstPost() { + await this.locators.deleteButtons.first().click(); + await modalClickConfirmButton(this.page); + } + + async openFirstBookedScrim() { + await this.locators.contactLinks.first().click(); + return new ScrimPage(this.page); + } +} + +/** The dialog for requesting a scrim of somebody else's post. */ +class ScrimRequestModal { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, scrimRequestFormSchema); + } + + /** The requester themselves is the first of the pick-up, the rest are chosen here. */ + async selectPickupUsers(userNames: string[]) { + for (const [index, userName] of userNames.entries()) { + await selectUser({ + page: this.page, + labelName: `User ${index + 2}`, + userName, + }); + } + } + + /** Only posts with a flexible start time offer times to pick from. */ + async selectStartTime(nth: number) { + await this.page + .getByLabel(this.form.getLabel("at")) + .selectOption({ index: nth }); + } + + async send() { + await submit(this.page); + } } diff --git a/e2e/pages/tier-list-maker/tier-list-maker-page.ts b/e2e/pages/tier-list-maker/tier-list-maker-page.ts new file mode 100644 index 000000000..2ba5387cb --- /dev/null +++ b/e2e/pages/tier-list-maker/tier-list-maker-page.ts @@ -0,0 +1,106 @@ +import type { Page } from "@playwright/test"; +import invariant from "~/utils/invariant"; +import { TIER_LIST_MAKER_URL } from "~/utils/urls"; +import { expect, expectIsHydrated, navigate } from "../../helpers/playwright"; + +type ItemType = + | "main-weapon" + | "sub-weapon" + | "special-weapon" + | "stage" + | "mode" + | "stage-mode" + | "ability"; + +type Toggle = + | "noDuplicates" + | "showTierHeaders" + | "hideAltKits" + | "hideAltSkins"; + +const TAB_NAMES: Record = { + "main-weapon": "Main Weapons", + "sub-weapon": "Sub Weapons", + "special-weapon": "Special Weapons", + stage: "Stages", + mode: "Modes", + "stage-mode": "Stage + Modes", + ability: "Abilities", +}; + +const TOGGLE_NAMES: Record = { + noDuplicates: "No duplicates", + showTierHeaders: "Show tier headers", + hideAltKits: "Hide alt kits", + hideAltSkins: "Hide alt skins", +}; + +export class TierListMakerPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + emptyTiersDragMode: page.getByText("Drop items here"), + emptyTiersClickMode: page.getByText("Click items to add here"), + }; + } + + async goto() { + await navigate({ page: this.page, url: TIER_LIST_MAKER_URL }); + } + + /** The tier list state lives in the url, so a reload restores it. */ + async reload() { + await this.page.reload(); + await expectIsHydrated(this.page); + } + + poolItems(type: ItemType) { + return this.page + .getByRole("tabpanel") + .locator(`[data-item-id^="${type}:"]`); + } + + async openTab(type: ItemType) { + await this.page.getByRole("tab", { name: TAB_NAMES[type] }).click(); + } + + async setPlacementMode(mode: "drag" | "click") { + await this.page + .getByText(mode === "drag" ? "Drag & drop" : "Click to place") + .click(); + } + + async toggle(name: Toggle) { + await this.page.getByText(TOGGLE_NAMES[name]).click(); + } + + async dragFirstItemToLastEmptyTier(type: ItemType) { + const emptyTiers = this.locators.emptyTiersDragMode; + const emptyCountBefore = await emptyTiers.count(); + + await this.poolItems(type).first().hover(); + await this.page.mouse.down(); + + const tierBox = await emptyTiers.last().boundingBox(); + invariant(tierBox, "The tier dropped on has no bounding box"); + await this.page.mouse.move( + tierBox.x + tierBox.width / 2, + tierBox.y + tierBox.height / 2, + { steps: 10 }, + ); + await this.page.mouse.up(); + + await expect(emptyTiers).toHaveCount(emptyCountBefore - 1); + } + + async clickFirstItem(type: ItemType) { + await this.poolItems(type).first().click(); + } + + async selectFirstEmptyTier() { + await this.locators.emptyTiersClickMode.first().click(); + } +} diff --git a/e2e/pages/tournament/tournament-team-page.ts b/e2e/pages/tournament/tournament-team-page.ts new file mode 100644 index 000000000..095e42bbb --- /dev/null +++ b/e2e/pages/tournament/tournament-team-page.ts @@ -0,0 +1,22 @@ +import type { Page } from "@playwright/test"; +import { tournamentTeamPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; + +export class TournamentTeamPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + memberNames: page.getByTestId("team-member-name"), + }; + } + + async goto(tournamentId: number, tournamentTeamId: number) { + await navigate({ + page: this.page, + url: tournamentTeamPage({ tournamentId, tournamentTeamId }), + }); + } +} diff --git a/e2e/scrims.spec.ts b/e2e/scrims.spec.ts index 5649b01ee..909c09e5d 100644 --- a/e2e/scrims.spec.ts +++ b/e2e/scrims.spec.ts @@ -1,418 +1,388 @@ -import type { Page } from "@playwright/test"; +import { addDays, addHours, setHours, setMinutes, startOfHour } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { - scrimsNewFormSchema, - submitMapListFormSchema, -} from "~/features/scrims/scrims-schemas"; -import { newScrimPostPage, scrimPage, scrimsPage } from "~/utils/urls"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { toDBBoolean } from "~/utils/sql"; +import type { Factories } from "./helpers/factories"; import { expect, impersonate, + isNotVisible, navigate, - selectUser, - submit, test, - waitForPOSTResponse, } from "./helpers/playwright"; -import { createFormHelpers } from "./helpers/playwright-form"; +import { AnythingAdder } from "./pages/layout/anything-adder"; +import { NewScrimPostPage } from "./pages/scrims/new-scrim-post-page"; +import { ScrimPage } from "./pages/scrims/scrim-page"; +import { ScrimsPage } from "./pages/scrims/scrims-page"; const TEST_POOL_SERIALIZED = "sz:3a14000;tc:2c98000"; +const TOURNAMENT_NAME = "Swim or Sink"; +const ASSOCIATION_NAME = "Inkling Alliance"; +const PICKUP_NAMES = ["Pickup One", "Pickup Two", "Pickup Three"]; +const GROUP_SIZE = 4; +const TOURNAMENT_MAP_POOL: Array<{ mode: ModeShort; stageId: StageId }> = [ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 2 }, + { mode: "RM", stageId: 3 }, + { mode: "CB", stageId: 4 }, +]; test.describe("Scrims", () => { - test("creates a new scrim & deletes it", async ({ page }) => { - // await seed(page); - await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: "/", + test("creates a new scrim & deletes it", async ({ page, factories }) => { + await createNamedUsers(factories, PICKUP_NAMES); + await factories.AssociationFactory.create({ + userId: NZAP_TEST_ID, + name: ASSOCIATION_NAME, }); - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-scrimPost").click(); + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: "/" }); - const form = createFormHelpers(page, scrimsNewFormSchema); + await new AnythingAdder(page).add("scrimPost"); - await page.getByLabel("With").selectOption("PICKUP"); - await selectUser({ - labelName: "User 2", - page, - userName: "N-ZAP", - }); - await selectUser({ - labelName: "User 3", - page, - userName: "ab", - }); - await selectUser({ - labelName: "User 4", - page, - userName: "de", - }); - await page.getByLabel("Visibility").selectOption("2"); + const newPost = new NewScrimPostPage(page); + await newPost.selectPickupUsers(PICKUP_NAMES); + await newPost.selectVisibility(ASSOCIATION_NAME); + await newPost.form.fill("postText", "Test scrim"); + await newPost.save(); - await form.fill("postText", "Test scrim"); + const scrims = new ScrimsPage(page); + await expect(scrims.locators.limitedVisibilityPopover).toBeVisible(); - await submit(page); + await scrims.deleteFirstPost(); - await expect(page.getByTestId("limited-visibility-popover")).toBeVisible(); - - await page.getByRole("button", { name: "Delete" }).first().click(); - await submit(page, "confirm-button"); - - await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1); + await expect(scrims.locators.deleteButtons).toHaveCount(0); }); test("requests an existing scrim post & cancels the request", async ({ page, + factories, }) => { - // await seed(page); - await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: scrimsPage(), - }); + for (let postNth = 0; postNth < 2; postNth++) { + await factories.ScrimPostFactory.create({ + users: await createGroup(factories), + }); + } + await createTeamFor(factories, NZAP_TEST_ID); - const requestScrimButtonLocator = page.getByTestId("request-scrim-button"); + await impersonate(page, NZAP_TEST_ID); - await page.getByTestId("available-scrims-tab").click(); + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); - await expect(requestScrimButtonLocator.first()).toBeVisible(); + await expect(scrims.locators.requestButtons).toHaveCount(2); - const initialCount = await requestScrimButtonLocator.count(); + const request = await scrims.requestFirst(); + await request.send(); - await requestScrimButtonLocator.first().click(); + await expect(scrims.locators.requestButtons).toHaveCount(1); - await submit(page); + await scrims.showPendingRequests(); + await scrims.cancelPendingRequest(); - await expect(requestScrimButtonLocator).toHaveCount(initialCount - 1); - - const togglePendingRequestsButton = page.getByTestId( - "toggle-pending-requests-button", - ); - - await togglePendingRequestsButton.first().click(); - - await page.getByTestId("view-request-button").first().click(); - - const cancelButton = page.getByRole("button", { - name: "Cancel", - }); - await cancelButton.click(); - - await expect(requestScrimButtonLocator).toHaveCount(initialCount); + await expect(scrims.locators.requestButtons).toHaveCount(2); }); - test("accepts a request", async ({ page }) => { - // await seed(page); + test("accepts a request", async ({ page, factories }) => { + await createPostWithRequest(factories, { ownerUserId: ADMIN_ID }); + await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: scrimsPage(), - }); - await page.getByTestId("confirm-modal-trigger-button").first().click(); - await submit(page, "confirm-button"); + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.acceptFirstRequest(); - await page.getByTestId("booked-scrims-tab").click(); + await scrims.openTab("booked"); + await expect(scrims.locators.contactLinks).toHaveCount(1); - const contactButtonLocator = page.getByRole("link", { name: "Contact" }); + const scrim = await scrims.openFirstBookedScrim(); - await expect(contactButtonLocator).toHaveCount(2); - - await page.getByRole("link", { name: "Contact" }).first().click(); - - await expect(page.getByText("Scheduled scrim")).toBeVisible(); + await expect(scrim.locators.subtitle).toBeVisible(); }); test("auto-cancels overlapping pending scrims when a scrim is booked", async ({ page, + factories, }) => { - // await seed(page, "NO_SCRIMS"); - await impersonate(page); + await createTeamFor(factories, ADMIN_ID); + await createTeamFor(factories, NZAP_TEST_ID); - const bookedAt = new Date(); - bookedAt.setDate(bookedAt.getDate() + 1); - bookedAt.setHours(18, 0, 0, 0); + const bookedAt = startOfHour(setHours(addDays(new Date(), 1), 18)); + // within ±1h of the booked time + const overlappingAt = setMinutes(bookedAt, 30); + // outside the ±1h window + const farAwayAt = setHours(bookedAt, 22); - const overlappingAt = new Date(bookedAt); - overlappingAt.setMinutes(30); // within ±1h of the booked time + await impersonate(page, ADMIN_ID); - const farAwayAt = new Date(bookedAt); - farAwayAt.setHours(22, 0, 0, 0); // outside the ±1h window + const newPost = new NewScrimPostPage(page); + for (const [at, text] of [ + [bookedAt, "Booked post"], + [overlappingAt, "Overlapping post"], + [farAwayAt, "Far away post"], + ] as const) { + await newPost.goto(); + await newPost.form.setDateTime("at", at); + await newPost.form.fill("postText", text); + await newPost.save(); + } - await createScrimPost(page, bookedAt, "Booked post"); - await createScrimPost(page, overlappingAt, "Overlapping post"); - await createScrimPost(page, farAwayAt, "Far away post"); - - // NZAP requests the earliest (soon-to-be-booked) post + // N-ZAP requests the earliest (soon-to-be-booked) post await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: scrimsPage() }); - await page.getByTestId("available-scrims-tab").click(); - await page.getByTestId("request-scrim-button").first().click(); - await selectUser({ labelName: "User 2", page, userName: "5" }); - await selectUser({ labelName: "User 3", page, userName: "6" }); - await selectUser({ labelName: "User 4", page, userName: "7" }); - await submit(page); - // Author accepts the request, booking the scrim - await impersonate(page); - await navigate({ page, url: scrimsPage() }); - await page.getByTestId("confirm-modal-trigger-button").first().click(); - await submit(page, "confirm-button"); + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); - // The overlapping pending post is auto-removed, the far away one survives - await page.getByRole("tab", { name: /Owned/ }).click(); - await expect(page.getByText("Far away post")).toBeVisible(); - await expect(page.getByText("Overlapping post")).toHaveCount(0); + const request = await scrims.requestFirst(); + await request.send(); + + // the author accepts the request, booking the scrim + await impersonate(page, ADMIN_ID); + await scrims.goto(); + await scrims.acceptFirstRequest(); + + await scrims.openTab("owned"); + + await expect(scrims.post("Far away post")).toBeVisible(); + await isNotVisible(scrims.post("Overlapping post")); }); - test("cancels a scrim and shows canceled status", async ({ page }) => { - // await seed(page); + test("cancels a scrim and shows canceled status", async ({ + page, + factories, + }) => { + await createPostWithRequest(factories, { + ownerUserId: ADMIN_ID, + at: addHours(new Date(), 4), + }); + await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: scrimsPage(), - }); - // Accept the first available scrim request to make it possible to access the scrim details page - await page.getByTestId("confirm-modal-trigger-button").first().click(); - await submit(page, "confirm-button"); + const scrims = new ScrimsPage(page); + await scrims.goto(); - await page.getByTestId("booked-scrims-tab").click(); + // accepting is what makes the scrim's own page accessible + await scrims.acceptFirstRequest(); + await scrims.openTab("booked"); - await page.getByRole("link", { name: "Contact" }).first().click(); + const scrim = await scrims.openFirstBookedScrim(); + await scrim.cancel("Oops something came up"); - // Cancel the scrim - await page.getByRole("button", { name: "Cancel" }).click(); - await page.getByLabel("Reason").fill("Oops something came up"); - await submit(page, "cancel-scrim-submit"); + await scrims.goto(); - // Go back to the scrims page and check if the scrim is marked as canceled - await navigate({ - page, - url: scrimsPage(), - }); - await expect(page.getByText("Canceled")).toBeVisible(); + await expect(scrims.locators.canceledLabel).toBeVisible(); }); test("creates scrim with start time and tournament maps, accepts with time and message", async ({ page, + factories, }) => { - // await seed(page, "NO_SCRIMS"); - await impersonate(page); - await navigate({ - page, - url: newScrimPostPage(), - }); + await createTeamFor(factories, ADMIN_ID); + await createNamedUsers(factories, PICKUP_NAMES); + await createTournament(factories); - const form = createFormHelpers(page, scrimsNewFormSchema); - - const tomorrowDate = new Date(); - tomorrowDate.setDate(tomorrowDate.getDate() + 1); - tomorrowDate.setHours(18, 0, 0, 0); - - await form.setDateTime("at", tomorrowDate); - - await form.select("rangeEnd", "+2hours"); - - await form.select("maps", "TOURNAMENT"); - - const tournamentSearchInput = page.getByTestId("tournament-search-input"); - const tournamentSearchItem = page.getByTestId("tournament-search-item"); - - await page.getByRole("button", { name: /Tournament search/i }).click(); - await tournamentSearchInput.fill("Swim or Sink"); - await expect(tournamentSearchItem.first()).toBeVisible(); - await tournamentSearchItem.first().click(); - - await submit(page); - - // Log in as NZAP user and request the scrim - await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url: scrimsPage(), - }); - - await page.getByTestId("available-scrims-tab").click(); - - // Find and click the request button for the scrim we just created - await page.getByTestId("request-scrim-button").first().click(); - - await selectUser({ - labelName: "User 2", - page, - userName: "5", - }); - await selectUser({ - labelName: "User 3", - page, - userName: "6", - }); - await selectUser({ - labelName: "User 4", - page, - userName: "7", - }); - - await page.getByLabel("Start time").selectOption({ index: 1 }); - - await page.getByLabel("Message").fill("Ready to scrim! Let's do this."); - - await submit(page); - - // Log back in as the author (admin) and verify the scrim and request details await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: scrimsPage(), - }); - await expect(page.getByText("+2h")).toBeVisible(); - await expect(page.getByTestId("tournament-popover-trigger")).toBeVisible(); + const newPost = new NewScrimPostPage(page); + await newPost.goto(); + await newPost.form.setDateTime( + "at", + startOfHour(setHours(addDays(new Date(), 1), 18)), + ); + await newPost.form.select("rangeEnd", "+2hours"); + await newPost.selectTournamentMaps(TOURNAMENT_NAME); + await newPost.save(); - await expect( - page.getByText("Ready to scrim! Let's do this."), - ).toBeVisible(); + await impersonate(page, NZAP_TEST_ID); - await page.getByTestId("confirm-modal-trigger-button").click(); - await submit(page, "confirm-button"); + const scrims = new ScrimsPage(page); + await scrims.goto(); + await scrims.openTab("available"); - await page.getByTestId("booked-scrims-tab").click(); - await page.getByRole("link", { name: "Contact" }).click(); + const request = await scrims.requestFirst(); + await request.selectPickupUsers(PICKUP_NAMES); + await request.selectStartTime(1); + await request.form.fill("message", "Ready to scrim! Let's do this."); + await request.send(); - await page.getByRole("tab", { name: "Action" }).click(); - await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); + // back as the author, who sees the post and the request details + await impersonate(page, ADMIN_ID); + await scrims.goto(); + + await expect(scrims.post("+2h")).toBeVisible(); + await expect(scrims.locators.tournamentPopover).toBeVisible(); + await expect(scrims.post("Ready to scrim! Let's do this.")).toBeVisible(); + + await scrims.acceptFirstRequest(); + await scrims.openTab("booked"); + + const scrim = await scrims.openFirstBookedScrim(); + await scrim.openTab("action"); + + await expect(scrim.locators.mapListForm).toBeVisible(); }); test("map-by-map: lists, report, undo, replay, change list, stats", async ({ page, + factories, }) => { - // await seed(page); - - const scrimUrl = scrimPage(1); - - const mapListForm = createFormHelpers(page, submitMapListFormSchema, { - submitTestId: "submit-map-list-button", + const tournament = await createTournament(factories); + const post = await createPostWithRequest(factories, { + ownerUserId: ADMIN_ID, + requesterUserId: NZAP_TEST_ID, + mapsTournamentId: tournament.id, + isAccepted: true, }); - // ADMIN opens the Action tab — the map list form is shown immediately + // the admin opens the Action tab — the map list form is shown immediately await impersonate(page, ADMIN_ID); - await navigate({ page, url: scrimUrl }); - await page.getByRole("tab", { name: "Action" }).click(); - await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); - // ADMIN submits a map list — the post's tournament (Swim or Sink) is the - // default source for the scrim author's team, so they can submit without - // running the tournament search. A first map is generated immediately - // so the page transitions to the report UI with the map-list manager - // collapsed. - await waitForPOSTResponse(page, () => mapListForm.submit()); - await expect(page.getByTestId("report-score-button")).toBeVisible(); - await page.getByRole("button", { name: /Manage map lists/i }).click(); - await expect(page.getByTestId("map-list-row-ALPHA")).toContainText( - "Swim or Sink", - ); + const scrim = new ScrimPage(page); + await scrim.goto(post.id); + await scrim.openTab("action"); - // NZAP submits a pool-URL-based map list. They have no list yet so the + await expect(scrim.locators.mapListForm).toBeVisible(); + + // the post's tournament is the default source for the author's side, so they + // can submit without running the tournament search. A first map is generated + // immediately, so the page moves on to the report UI with the map-list + // manager collapsed. + await scrim.submitMapList(); + + await expect(scrim.locators.reportScoreButton).toBeVisible(); + + await scrim.openMapListManager(); + + await expect(scrim.mapListRow("ALPHA")).toContainText(TOURNAMENT_NAME); + + // N-ZAP submits a pool-URL-based map list. They have no list yet, so the // map-list manager is already expanded on mount. await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: scrimUrl }); - await page.getByRole("tab", { name: "Action" }).click(); - await page.getByLabel("Pool URL").click(); - await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED); - await waitForPOSTResponse(page, () => mapListForm.submit()); - await expect(page.getByTestId("report-score-button")).toBeVisible(); - await expect(page.getByTestId("map-list-row-BRAVO")).toContainText("Pool"); + await scrim.goto(post.id); + await scrim.openTab("action"); + await scrim.submitPoolMapList(TEST_POOL_SERIALIZED); - // Map 1: ALPHA wins → next map auto-generated - await reportScrimMapWinner(page, "ALPHA"); - await expect(page.getByTestId("report-score-button")).toBeVisible(); + await expect(scrim.locators.reportScoreButton).toBeVisible(); + await expect(scrim.mapListRow("BRAVO")).toContainText("Pool"); - // Map 2: BRAVO wins → next map auto-generated - await reportScrimMapWinner(page, "BRAVO"); - await expect(page.getByTestId("report-score-button")).toBeVisible(); + // map 1: ALPHA wins → next map auto-generated + await scrim.reportMapWinner("ALPHA"); + await expect(scrim.locators.reportScoreButton).toBeVisible(); - // Map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4) - await reportScrimMapWinner(page, "ALPHA"); - await expect(page.getByTestId("undo-map-button")).toBeVisible(); - await submit(page, "undo-map-button"); - await expect(page.getByTestId("report-score-button")).toBeVisible(); + // map 2: BRAVO wins → next map auto-generated + await scrim.reportMapWinner("BRAVO"); + await expect(scrim.locators.reportScoreButton).toBeVisible(); - // Re-report map 3 as BRAVO wins → next map auto-generated - await reportScrimMapWinner(page, "BRAVO"); + // map 3: ALPHA wins → undo (un-reports map 3, deletes auto-gen map 4) + await scrim.reportMapWinner("ALPHA"); + await expect(scrim.locators.undoMapButton).toBeVisible(); + await scrim.undoMap(); + await expect(scrim.locators.reportScoreButton).toBeVisible(); - // Replay last map: replaces the current generated map with a copy of - // the previous reported one, then report ALPHA wins - await expect(page.getByTestId("replay-map-button")).toBeVisible(); - await submit(page, "replay-map-button"); - await reportScrimMapWinner(page, "ALPHA"); + // re-report map 3 as BRAVO wins → next map auto-generated + await scrim.reportMapWinner("BRAVO"); - // Switch back to ADMIN to change their list + // replaying replaces the generated map with a copy of the previous one + await expect(scrim.locators.replayMapButton).toBeVisible(); + await scrim.replayMap(); + await scrim.reportMapWinner("ALPHA"); + + // back as the admin to change their list await impersonate(page, ADMIN_ID); - await navigate({ page, url: scrimUrl }); - await page.getByRole("tab", { name: "Action" }).click(); - await page.getByRole("button", { name: /Manage map lists/i }).click(); + await scrim.goto(post.id); + await scrim.openTab("action"); + await scrim.openMapListManager(); - // Remove ALPHA's tournament list (trash icon opens a confirm dialog) - await page - .getByTestId("map-list-row-ALPHA") - .getByLabel(/Remove list/i) - .click(); - await waitForPOSTResponse(page, () => submit(page, "confirm-button")); - await expect(page.getByTestId("scrim-map-list-form")).toBeVisible(); + await scrim.removeOwnMapList("ALPHA"); - // Re-submit ALPHA's list, this time as a pool URL - await page.getByLabel("Pool URL").click(); - await mapListForm.fill("serializedPool", TEST_POOL_SERIALIZED); - await waitForPOSTResponse(page, () => mapListForm.submit()); - await expect(page.getByTestId("map-list-row-ALPHA")).toContainText("Pool"); + await expect(scrim.locators.mapListForm).toBeVisible(); - // Verify stats tab reflects the played maps - await page.getByRole("tab", { name: "Stats" }).click(); - await expect(page.getByTestId("scrim-stats-root")).toBeVisible(); + await scrim.submitPoolMapList(TEST_POOL_SERIALIZED); - // Four reported maps total (Alpha 2 / Bravo 2 from ADMIN's POV). - // Switch to "Mode" view so each row groups by mode, and disable the - // pool restriction so maps outside ADMIN's resubmitted pool still count. - // Sum of wins+losses across rows should equal 4. - await page - .getByTestId("scrim-stats-root") - .getByText("Mode", { exact: true }) - .click(); - await page.getByRole("switch").click({ force: true }); + await expect(scrim.mapListRow("ALPHA")).toContainText("Pool"); - const statsRoot = page.getByTestId("scrim-stats-root"); - const winCells = await statsRoot - .locator("tbody tr td:nth-child(2)") - .allInnerTexts(); - const lossCells = await statsRoot - .locator("tbody tr td:nth-child(3)") - .allInnerTexts(); - const total = - winCells.reduce((acc, v) => acc + Number(v), 0) + - lossCells.reduce((acc, v) => acc + Number(v), 0); - expect(total).toBe(4); + await scrim.openTab("stats"); + + await expect(scrim.locators.statsRoot).toBeVisible(); + + // four reported maps in total (2 wins & 2 losses from the admin's point of view) + await scrim.showModeStatsOfAllMaps(); + + expect(await scrim.reportedMapCount()).toBe(4); }); }); -async function createScrimPost(page: Page, at: Date, text: string) { - await navigate({ page, url: newScrimPostPage() }); - - const form = createFormHelpers(page, scrimsNewFormSchema); - - await form.setDateTime("at", at); - await form.fill("postText", text); - - await submit(page); +/** A tournament with a map pool, which a scrim can play its maps off. */ +function createTournament(factories: Factories) { + return factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: TOURNAMENT_NAME, + startTimes: [dateToDatabaseTimestamp(addDays(new Date(), 1))], + mapPoolMaps: TOURNAMENT_MAP_POOL, + }); } -async function reportScrimMapWinner(page: Page, winner: "ALPHA" | "BRAVO") { - const testId = winner === "ALPHA" ? "winner-radio-1" : "winner-radio-2"; - await expect( - page.locator('[data-testid^="winner-radio-"][data-selected="true"]'), - ).toHaveCount(0); - await page.getByTestId(testId).click(); - await submit(page, "report-score-button"); +/** Users a user search finds by the name given to them. */ +function createNamedUsers(factories: Factories, names: string[]) { + return factories.UserFactory.createMany(names.length, (index) => ({ + discordName: names[index], + })); +} + +async function createTeamFor(factories: Factories, userId: number) { + const teammates = await factories.UserFactory.createMany(GROUP_SIZE - 1); + + return factories.TeamFactory.create({ + memberUserIds: [userId, ...teammates.map((user) => user.id)], + }); +} + +/** A pick-up sized group of users, `userId` its owner if one is given. */ +async function createGroup(factories: Factories, userId?: number) { + const others = await factories.UserFactory.createMany( + userId ? GROUP_SIZE - 1 : GROUP_SIZE, + ); + const userIds = userId + ? [userId, ...others.map((user) => user.id)] + : others.map((user) => user.id); + + return userIds.map((id, index) => ({ + userId: id, + isOwner: toDBBoolean(index === 0), + })); +} + +/** A scrim post with one request made to it, booked when `isAccepted`. */ +async function createPostWithRequest( + factories: Factories, + { + ownerUserId, + requesterUserId, + at, + mapsTournamentId, + isAccepted, + }: { + ownerUserId: number; + requesterUserId?: number; + at?: Date; + mapsTournamentId?: number; + isAccepted?: boolean; + }, +) { + const users = await createGroup(factories, ownerUserId); + const requesters = await createGroup(factories, requesterUserId); + + return factories.ScrimPostFactory.create( + { + users, + startsAt: dateToDatabaseTimestamp(at ?? new Date()), + isScheduledForFuture: Boolean(at), + mapsTournamentId: mapsTournamentId ?? null, + }, + { requests: [{ users: requesters, isAccepted }] }, + ); } diff --git a/e2e/tier-list-maker.spec.ts b/e2e/tier-list-maker.spec.ts index 6c2a2a0d2..51ffd82b5 100644 --- a/e2e/tier-list-maker.spec.ts +++ b/e2e/tier-list-maker.spec.ts @@ -1,86 +1,53 @@ -import type { Locator, Page } from "@playwright/test"; -import { TIER_LIST_MAKER_URL } from "~/utils/urls"; -import { expect, navigate, test } from "./helpers/playwright"; +import { expect, test } from "./helpers/playwright"; +import { TierListMakerPage } from "./pages/tier-list-maker/tier-list-maker-page"; test.describe("Tier List Maker", () => { test("toggles work, items can be dragged, and state persists after reload", async ({ page, }) => { - await navigate({ page, url: TIER_LIST_MAKER_URL }); + const tierList = new TierListMakerPage(page); + await tierList.goto(); - await page.getByText("Drag & drop").click(); + await tierList.setPlacementMode("drag"); - const emptyTiers = page.getByText("Drop items here"); + await expect(tierList.locators.emptyTiersDragMode).toHaveCount(5); - await expect(emptyTiers).toHaveCount(5); + await tierList.toggle("noDuplicates"); + await tierList.toggle("showTierHeaders"); + await tierList.toggle("hideAltKits"); + await tierList.toggle("hideAltSkins"); - // Test that toggles are clickable (just verify they work) - await page.getByText("No duplicates").click(); - await page.getByText("Show tier headers").click(); - await page.getByText("Hide alt kits").click(); - await page.getByText("Hide alt skins").click(); + await tierList.dragFirstItemToLastEmptyTier("main-weapon"); + await tierList.dragFirstItemToLastEmptyTier("main-weapon"); - // Drag first weapon to first tier - const firstWeapon = page.locator('[data-item-id^="main-weapon:"]').first(); - await dragItemToTier(page, firstWeapon); + await tierList.openTab("stage"); + await expect(tierList.poolItems("stage").first()).toBeVisible(); + await tierList.dragFirstItemToLastEmptyTier("stage"); - // Drag second weapon to second tier - const secondWeapon = page.locator('[data-item-id^="main-weapon:"]').first(); - await dragItemToTier(page, secondWeapon); + await tierList.reload(); - // Switch to Stages tab and drag a stage - await page.getByText("Stages").click(); + // placement mode is not persisted, unlike the tiers themselves + await tierList.setPlacementMode("drag"); - const firstStage = page.locator('[data-item-id^="stage:"]').first(); - await expect(firstStage).toBeVisible(); - await dragItemToTier(page, firstStage); - - // Wait for state to settle - await page.waitForTimeout(1000); - - // Reload the page - await page.reload(); - - // Placement mode is not persisted, so re-enter drag & drop mode - await page.getByText("Drag & drop").click(); - - // Verify items persisted in the tier list (not in the pool) - // Should have fewer than 5 "Drop items here" (meaning at least 1 tier is filled) - await expect(emptyTiers).toHaveCount(3); + // each of the three dragged items landed in a tier of its own + await expect(tierList.locators.emptyTiersDragMode).toHaveCount(2); }); test("click to place mode adds items to the selected tier", async ({ page, }) => { - await navigate({ page, url: TIER_LIST_MAKER_URL }); + const tierList = new TierListMakerPage(page); + await tierList.goto(); - // Click to place is the default mode - const emptyTiers = page.getByText("Click items to add here"); - await expect(emptyTiers).toHaveCount(5); + // click to place is the default mode + await expect(tierList.locators.emptyTiersClickMode).toHaveCount(5); - // The first tier is selected by default, clicking a weapon places it there - await page.locator('button[data-item-id^="main-weapon:"]').first().click(); - await expect(emptyTiers).toHaveCount(4); + // the first tier is selected by default + await tierList.clickFirstItem("main-weapon"); + await expect(tierList.locators.emptyTiersClickMode).toHaveCount(4); - // Selecting another tier and clicking a weapon places it there - await emptyTiers.first().click(); - await page.locator('button[data-item-id^="main-weapon:"]').first().click(); - await expect(emptyTiers).toHaveCount(3); + await tierList.selectFirstEmptyTier(); + await tierList.clickFirstItem("main-weapon"); + await expect(tierList.locators.emptyTiersClickMode).toHaveCount(3); }); }); - -async function dragItemToTier(page: Page, item: Locator) { - await item.hover(); - await page.mouse.down(); - const tier = page.getByText("Drop items here").last(); - const tierBox = await tier.boundingBox(); - if (tierBox) { - await page.mouse.move( - tierBox.x + tierBox.width / 2, - tierBox.y + tierBox.height / 2, - { steps: 10 }, - ); - } - await page.mouse.up(); - await page.waitForTimeout(200); -}