diff --git a/app/db/seed/core/pinUserId.ts b/app/db/seed/core/pinUserId.ts index 56aad9194..b838b7f22 100644 --- a/app/db/seed/core/pinUserId.ts +++ b/app/db/seed/core/pinUserId.ts @@ -29,5 +29,11 @@ export async function pinUserId(userId: number, pinnedId: number) { db, ); + // the search index is kept in sync by triggers that don't watch `id`, so its + // entry would keep pointing at the id the user just moved off of + await sql`insert into "UserSearch"("UserSearch") values ('rebuild')`.execute( + db, + ); + return pinnedId; } diff --git a/e2e/associations.spec.ts b/e2e/associations.spec.ts index affa4da37..7a26dd642 100644 --- a/e2e/associations.spec.ts +++ b/e2e/associations.spec.ts @@ -1,76 +1,66 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { associationsPage, scrimsPage } from "~/utils/urls"; import { expect, impersonate, isNotVisible, navigate, - submit, test, } from "./helpers/playwright"; +import { AssociationsPage } from "./pages/associations/associations-page"; +import { NewAssociationPage } from "./pages/associations/new-association-page"; +import { AnythingAdder } from "./pages/layout/anything-adder"; +import { ScrimsPage } from "./pages/scrims/scrims-page"; test.describe("Associations", () => { test("creates a new association", async ({ page }) => { - // await seed(page); await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url: "/", - }); + await navigate({ page, url: "/" }); - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-association").click(); + await new AnythingAdder(page).add("association"); - await page.getByLabel("Name").fill("My Association"); - await submit(page); + const newAssociation = new NewAssociationPage(page); + await newAssociation.form.fill("name", "My Association"); + await newAssociation.save(); - await expect( - page.getByRole("heading").filter({ hasText: "My Association" }), - ).toBeVisible(); + const associations = new AssociationsPage(page); + await expect(associations.heading("My Association")).toBeVisible(); }); - test("deletes an association", async ({ page }) => { - // await seed(page); + test("deletes an association", async ({ page, factories }) => { + await factories.AssociationFactory.create({ userId: ADMIN_ID }); + await factories.AssociationFactory.create({ userId: ADMIN_ID }); + await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: scrimsPage(), - }); - await page.getByRole("link", { name: "Associations" }).click(); - await expect(page.getByTestId("delete-association")).toHaveCount(2); + const scrims = new ScrimsPage(page); + await scrims.goto(); - await page.getByTestId("delete-association").first().click(); - await submit(page, "confirm-button"); + const associations = await scrims.openAssociations(); - await expect(page.getByTestId("delete-association")).toHaveCount(1); + await expect(associations.locators.deleteButtons).toHaveCount(2); + + await associations.deleteFirst(); + + await expect(associations.locators.deleteButtons).toHaveCount(1); }); - test("joins and leaves an association", async ({ page }) => { - // await seed(page); - await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: associationsPage(), - }); + test("joins and leaves an association", async ({ page, factories }) => { + await factories.AssociationFactory.create({ userId: ADMIN_ID }); - const inviteLink = await page - .getByLabel("Share link to add members") - .first() - .inputValue(); + await impersonate(page, ADMIN_ID); + + const associations = new AssociationsPage(page); + await associations.goto(); + + const inviteCode = await associations.inviteCode(); await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url: inviteLink.replace("https://sendou.ink", "http://localhost:6173"), - }); + await associations.gotoInvite(inviteCode); + await associations.join(); - await submit(page); + await associations.leave(); - await page.getByTestId("leave-team-button").click(); - await submit(page, "confirm-button"); - - await isNotVisible(page.getByTestId("leave-team-button")); + await isNotVisible(associations.locators.leaveButton); }); }); diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 02187f102..bc83cb14e 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -31,11 +31,16 @@ export async function loadFactories(parallelIndex: number) { return { backdate: (await import("~/db/seed/core/backdate")).backdate, + AssociationFactory: await import("~/db/seed/factories/AssociationFactory"), BuildFactory: await import("~/db/seed/factories/BuildFactory"), FriendshipFactory: await import("~/db/seed/factories/FriendshipFactory"), SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"), SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"), + TeamFactory: await import("~/db/seed/factories/TeamFactory"), TournamentFactory: await import("~/db/seed/factories/TournamentFactory"), + TournamentOrganizationFactory: await import( + "~/db/seed/factories/TournamentOrganizationFactory" + ), TournamentTeamFactory: await import( "~/db/seed/factories/TournamentTeamFactory" ), diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index 504c49fe3..c6b180882 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -114,13 +114,16 @@ export async function selectUser({ userName, labelName, exact = false, + within, }: { page: Page; userName: string; labelName: string; exact?: boolean; + /** Scopes the combobox lookup, for pages carrying the label on more than one element. */ + within?: Locator; }) { - const comboboxButton = page.getByLabel(labelName, { exact }); + const comboboxButton = (within ?? page).getByLabel(labelName, { exact }); const searchInput = page.getByTestId("user-search-input"); const option = page.getByTestId("user-search-item").first(); diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 6a8bcbd1d..3389e97b0 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -1,253 +1,208 @@ -import { NZAP_TEST_ID, ORG_ADMIN_TEST_ID } from "~/db/seed/constants"; +import { addHours } from "date-fns"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_ID } from "~/features/admin/admin-constants"; -import { - banUserActionSchema, - newOrganizationSchema, - updateIsEstablishedSchema, -} from "~/features/tournament-organization/tournament-organization-schemas"; -import { - TOURNAMENT_NEW_PAGE, - tournamentOrganizationPage, - tournamentPage, -} from "~/utils/urls"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import { expect, impersonate, isNotVisible, navigate, - submit, test, waitForPOSTResponse, } from "./helpers/playwright"; -import { createFormHelpers } from "./helpers/playwright-form"; +import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page"; +import { AnythingAdder } from "./pages/layout/anything-adder"; +import { NewOrganizationPage } from "./pages/org/new-organization-page"; +import { OrganizationPage } from "./pages/org/organization-page"; +import { TournamentPage } from "./pages/tournament/tournament-page"; -const url = tournamentOrganizationPage({ - organizationSlug: "sendouink", -}); +const ORGANIZATION_NAME = "Sendou.ink"; +const TOURNAMENT_NAME = "PICNIC #2"; +const PLAYER_NAME = "Banned Bob"; test.describe("Tournament Organization", () => { - test("can create a new organization", async ({ page }) => { - // await seed(page); - await impersonate(page); + test("can create a new organization", async ({ page, factories }) => { + const organizer = await factories.UserFactory.create(null, { + roles: ["TOURNAMENT_ORGANIZER"], + }); + + await impersonate(page, organizer.id); await navigate({ page, url: "/" }); - await page.getByTestId("anything-adder-menu-button").first().click(); - await page.getByTestId("menu-item-organization").click(); + await new AnythingAdder(page).add("organization"); - const form = createFormHelpers(page, newOrganizationSchema); - await form.fill("name", "Test Organization"); - await submit(page); + const newOrganization = new NewOrganizationPage(page); + await newOrganization.form.fill("name", "Test Organization"); + await newOrganization.save(); - await expect(page.getByTestId("edit-org-button")).toBeVisible(); + const organization = new OrganizationPage(page); + await expect(organization.locators.editButton).toBeVisible(); }); test("user can be promoted to admin gaining org controls and can edit tournaments", async ({ page, + factories, }) => { - // await seed(page); + const org = await factories.TournamentOrganizationFactory.create( + { ownerId: ADMIN_ID, name: ORGANIZATION_NAME }, + { members: [{ userId: NZAP_TEST_ID, role: "MEMBER" }] }, + ); + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: TOURNAMENT_NAME, + organizationId: org.id, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); - const editButtonLocator = page.getByTestId("edit-org-button"); + const organization = new OrganizationPage(page); // 1. As a regular user, verify edit controls are not visible await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url, - }); - await isNotVisible(editButtonLocator); + await organization.goto(org.slug); + await isNotVisible(organization.locators.editButton); // 2. As admin, promote user to admin await impersonate(page, ADMIN_ID); - await navigate({ page, url }); - await editButtonLocator.click(); - // Add member as admin - find the specific member fieldset containing N-ZAP - // The array field creates numbered fieldsets (#1, #2, #3) for each member - // Find the role select that belongs to the same fieldset as N-ZAP user select - const nzapFieldset = page.locator('fieldset:has(button:has-text("N-ZAP"))'); - await nzapFieldset - .getByLabel("Role", { exact: true }) - .selectOption("ADMIN"); - await submit(page); + await organization.goto(org.slug); + const organizationEdit = await organization.openEdit(); + await organizationEdit.setMemberRole("N-ZAP", "ADMIN"); + await organizationEdit.save(); // Establish the organization so its admins can edit tournament event info - await navigate({ page, url }); - await page.getByRole("tab", { name: "Admin" }).click(); - const isEstablishedForm = createFormHelpers( - page, - updateIsEstablishedSchema, - ); - await waitForPOSTResponse(page, () => - isEstablishedForm.check("isEstablished"), - ); + await organization.goto(org.slug); + await organization.establish(); // 3. As the promoted user, verify edit controls are visible and page can be accessed await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url, - }); - await editButtonLocator.click(); - await expect( - page.getByText("Editing tournament organization"), - ).toBeVisible(); + await organization.goto(org.slug); + await organization.openEdit(); + await expect(organizationEdit.locators.title).toBeVisible(); // 4. As the promoted user, verify they can edit tournaments - await navigate({ - page, - url: tournamentPage(1), - }); + const tournamentPage = new TournamentPage(page); + await tournamentPage.goto(tournament.id); - await page.getByTestId("admin-tab").click(); - await page.getByTestId("edit-event-info-button").click(); - await expect(page.getByLabel(/^Name *\*?$/)).toHaveValue("PICNIC #2"); + const tournamentAdmin = await tournamentPage.nav.openAdmin(); + const calendarEvent = await tournamentAdmin.editEventInfo(); + await expect(calendarEvent.locators.nameInput).toHaveValue(TOURNAMENT_NAME); }); test("banned player cannot join a tournament of that organization", async ({ page, + factories, }) => { - // await seed(page, "REG_OPEN"); + const player = await factories.UserFactory.create({ + discordName: PLAYER_NAME, + }); + const org = await factories.TournamentOrganizationFactory.create({ + ownerId: ADMIN_ID, + name: ORGANIZATION_NAME, + }); + const tournament = await factories.TournamentFactory.create({ + authorId: ADMIN_ID, + name: TOURNAMENT_NAME, + organizationId: org.id, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); - // 1. As admin, ban NZAP user from the organization + const organization = new OrganizationPage(page); + const tournamentPage = new TournamentPage(page); + + // 1. As admin, ban the player from the organization await impersonate(page, ADMIN_ID); - await navigate({ page, url }); + await organization.goto(org.slug); + await organization.openBannedUsers(); - const bannedUsersTab = page.getByTestId("banned-users-tab"); + const banModal = await organization.openBanModal(); + await expect(banModal.locators.dialog).toBeVisible(); + await banModal.selectUser(PLAYER_NAME); + await banModal.form.fill("privateNote", "Test reason"); + // a future expiration date to avoid validation issues + await banModal.form.setDateTime("expiresAt", addHours(new Date(), 24)); + await banModal.save(); - // Go to banned users section and add NZAP to ban list - await bannedUsersTab.click(); - await page.getByText("New ban", { exact: true }).click(); - // Wait for the dialog to be visible - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - // Find and click the UserSearch combobox (React Aria Select renders as combobox) - const userSearchCombobox = dialog.getByRole("button").filter({ - has: page.locator('[class*="selectValue"]'), - }); - await userSearchCombobox.click(); - // Wait for the popover to open and fill search - await expect(page.getByTestId("user-search-input")).toBeVisible(); - await page.getByTestId("user-search-input").fill("N-ZAP"); - await expect(page.getByTestId("user-search-item").first()).toBeVisible(); - await page.keyboard.press("Enter"); - // Fill the note and set expiration date - const banForm = createFormHelpers(page, banUserActionSchema); - await banForm.fill("privateNote", "Test reason"); - // Set a future expiration date to avoid validation issues - const futureDate = new Date(); - futureDate.setMonth(futureDate.getMonth() + 1); - await banForm.setDateTime("expiresAt", futureDate); - await submit(page); - // The added ban should be visible in the table - await expect(page.getByRole("table")).toContainText("Test reason"); - - // 2. As the banned user, try to join a tournament - await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url: tournamentPage(1), - }); - - // Try to create a team - await page.getByTestId("register-cta").click(); - - // Fill in team details - await page.getByLabel("Pick-up name").fill("Banned Team"); - await waitForPOSTResponse(page, () => - page.getByTestId("save-team-button").click(), + await expect(organization.locators.bannedUsersTable).toContainText( + "Test reason", ); - // Verify the team was not created (Fill roster only appears after successful registration) - await expect(page.getByText("Fill roster")).not.toBeVisible(); + // 2. As the banned user, try to join a tournament + await impersonate(page, player.id); + await tournamentPage.goto(tournament.id); + + const register = await tournamentPage.register(); + await register.form.fill("pickUpName", "Banned Team"); + await waitForPOSTResponse(page, () => register.form.submit()); + + // "Fill roster" only appears after a successful registration + await expect(register.locators.fillRosterHeading).not.toBeVisible(); // 3. As admin, remove the ban await impersonate(page, ADMIN_ID); - await navigate({ page, url }); - await bannedUsersTab.click(); - await page.getByRole("button", { name: "Unban" }).click(); - await submit(page, "confirm-button"); + await organization.goto(org.slug); + await organization.openBannedUsers(); + await organization.unban(); // 4. As the unbanned user, verify they can now join a tournament - await impersonate(page, NZAP_TEST_ID); - await navigate({ - page, - url: tournamentPage(1), - }); - await page.getByTestId("register-cta").click(); + await impersonate(page, player.id); + await tournamentPage.goto(tournament.id); + const registerAgain = await tournamentPage.register(); - // Try to create a team again - await expect(page.getByText(/Teams \(\d+\)/)).toBeVisible(); + const teamsTab = tournamentPage.nav.locators.teamsTab; + await expect(teamsTab).toContainText("Teams (0)"); - const teamCountBefore = await page.getByText(/Teams \(\d+\)/).textContent(); + await registerAgain.form.fill("pickUpName", "Unbanned Team"); + await registerAgain.form.submit(); - await page.getByLabel("Pick-up name").fill("Unbanned Team"); - await page.getByTestId("save-team-button").click(); - - const countBefore = Number(teamCountBefore?.match(/\d+/)?.[0] ?? 0); - await expect(page.getByText(`Teams (${countBefore + 1})`)).toBeVisible(); + await expect(teamsTab).toContainText("Teams (1)"); // 5. As admin, ban user again but with permanent ban this time await impersonate(page, ADMIN_ID); - await navigate({ page, url }); - await bannedUsersTab.click(); - await page.getByText("New ban", { exact: true }).click(); - const dialog2 = page.getByRole("dialog"); - await expect(dialog2).toBeVisible(); - const userSearchCombobox2 = dialog2.getByRole("button").filter({ - has: page.locator('[class*="selectValue"]'), - }); - await userSearchCombobox2.click(); - await expect(page.getByTestId("user-search-input")).toBeVisible(); - await page.getByTestId("user-search-input").fill("N-ZAP"); - await expect(page.getByTestId("user-search-item").first()).toBeVisible(); - await page.keyboard.press("Enter"); - const banForm2 = createFormHelpers(page, banUserActionSchema); - await banForm2.fill("privateNote", "Does not expire"); - // Don't set expiresAt - leave empty for permanent ban - await submit(page); - // Verify "Permanent" appears in the table - await expect(page.getByRole("table")).toContainText("Permanent"); + await organization.goto(org.slug); + await organization.openBannedUsers(); + + const permanentBanModal = await organization.openBanModal(); + await expect(permanentBanModal.locators.dialog).toBeVisible(); + await permanentBanModal.selectUser(PLAYER_NAME); + await permanentBanModal.form.fill("privateNote", "Does not expire"); + // expiresAt left empty makes the ban permanent + await permanentBanModal.save(); + + await expect(organization.locators.bannedUsersTable).toContainText( + "Permanent", + ); }); test("allows member of established org to create tournament", async ({ page, + factories, }) => { - // await seed(page); + const orgAdmin = await factories.UserFactory.create(); + const org = await factories.TournamentOrganizationFactory.create( + { ownerId: ADMIN_ID, name: ORGANIZATION_NAME }, + { members: [{ userId: orgAdmin.id, role: "ADMIN" }] }, + ); + + const newTournament = new CalendarNewEventPage(page); + + await impersonate(page, orgAdmin.id); + await newTournament.gotoNewTournament(); - await impersonate(page, ORG_ADMIN_TEST_ID); - await navigate({ - page, - url: TOURNAMENT_NEW_PAGE, - }); await expect( - page.getByText("No permissions to add tournaments"), + newTournament.locators.noTournamentPermissionsAlert, ).toBeVisible(); - await expect(page.getByText("New tournament")).not.toBeVisible(); + await expect(newTournament.locators.newTournamentHeading).not.toBeVisible(); await impersonate(page, ADMIN_ID); - await navigate({ - page, - url: "/org/sendouink", - }); + const organization = new OrganizationPage(page); + await organization.goto(org.slug); + await organization.establish(); - await page.getByRole("tab", { name: "Admin" }).click(); - - const isEstablishedForm = createFormHelpers( - page, - updateIsEstablishedSchema, - ); - await waitForPOSTResponse(page, () => - isEstablishedForm.check("isEstablished"), - ); - - await impersonate(page, ORG_ADMIN_TEST_ID); - await navigate({ - page, - url: TOURNAMENT_NEW_PAGE, - }); + await impersonate(page, orgAdmin.id); + await newTournament.gotoNewTournament(); await expect( - page.getByText("No permissions to add tournaments"), + newTournament.locators.noTournamentPermissionsAlert, ).not.toBeVisible(); - await expect(page.getByText("New tournament")).toBeVisible(); + await expect(newTournament.locators.newTournamentHeading).toBeVisible(); }); }); diff --git a/e2e/pages/associations/associations-page.ts b/e2e/pages/associations/associations-page.ts new file mode 100644 index 000000000..72f101eeb --- /dev/null +++ b/e2e/pages/associations/associations-page.ts @@ -0,0 +1,59 @@ +import type { Page } from "@playwright/test"; +import invariant from "~/utils/invariant"; +import { associationsPage } from "~/utils/urls"; +import { + modalClickConfirmButton, + navigate, + submit, +} from "../../helpers/playwright"; + +export class AssociationsPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + deleteButtons: page.getByTestId("delete-association"), + leaveButton: page.getByTestId("leave-team-button"), + inviteLinkInputs: page.getByLabel("Share link to add members"), + }; + } + + async goto() { + await navigate({ page: this.page, url: associationsPage() }); + } + + async gotoInvite(inviteCode: string) { + await navigate({ page: this.page, url: associationsPage(inviteCode) }); + } + + heading(name: string) { + return this.page.getByRole("heading").filter({ hasText: name }); + } + + /** The share link is shown as the whole production url, only its code is of use here. */ + async inviteCode() { + const inviteLink = await this.locators.inviteLinkInputs + .first() + .inputValue(); + const inviteCode = new URL(inviteLink).searchParams.get("inviteCode"); + invariant(inviteCode, `No invite code in the share link: ${inviteLink}`); + + return inviteCode; + } + + async join() { + await submit(this.page); + } + + async deleteFirst() { + await this.locators.deleteButtons.first().click(); + await modalClickConfirmButton(this.page); + } + + async leave() { + await this.locators.leaveButton.click(); + await modalClickConfirmButton(this.page); + } +} diff --git a/e2e/pages/associations/new-association-page.ts b/e2e/pages/associations/new-association-page.ts new file mode 100644 index 000000000..b637eff6c --- /dev/null +++ b/e2e/pages/associations/new-association-page.ts @@ -0,0 +1,23 @@ +import type { Page } from "@playwright/test"; +import { createNewAssociationSchema } from "~/features/associations/associations-schemas"; +import { newAssociationsPage } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class NewAssociationPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, createNewAssociationSchema); + } + + async goto() { + await navigate({ page: this.page, url: newAssociationsPage() }); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/calendar/calendar-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts new file mode 100644 index 000000000..28660ab28 --- /dev/null +++ b/e2e/pages/calendar/calendar-new-event-page.ts @@ -0,0 +1,32 @@ +import type { Page } from "@playwright/test"; +import { calendarNewBaseSchema } from "~/features/calendar/calendar-new-schemas"; +import { CALENDAR_NEW_PAGE, TOURNAMENT_NEW_PAGE } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +/** `/calendar/new`, also used for adding tournaments and editing existing events. */ +export class CalendarNewEventPage { + private readonly page: Page; + readonly form; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, calendarNewBaseSchema); + this.locators = { + nameInput: page.getByLabel(/^Name *\*?$/), + newTournamentHeading: page.getByText("New tournament"), + noTournamentPermissionsAlert: page.getByText( + "No permissions to add tournaments", + ), + }; + } + + async goto() { + await navigate({ page: this.page, url: CALENDAR_NEW_PAGE }); + } + + async gotoNewTournament() { + await navigate({ page: this.page, url: TOURNAMENT_NEW_PAGE }); + } +} diff --git a/e2e/pages/layout/anything-adder.ts b/e2e/pages/layout/anything-adder.ts new file mode 100644 index 000000000..8a81e1131 --- /dev/null +++ b/e2e/pages/layout/anything-adder.ts @@ -0,0 +1,32 @@ +import type { Page } from "@playwright/test"; + +type AddableItem = + | "art" + | "association" + | "builds" + | "calendarEvent" + | "lfgPost" + | "organization" + | "plus" + | "scrimPost" + | "team" + | "tournament" + | "vods"; + +/** The "Add new…" menu of the site layout, available on every page. */ +export class AnythingAdder { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + menuButton: page.getByTestId("anything-adder-menu-button").first(), + }; + } + + async add(item: AddableItem) { + await this.locators.menuButton.click(); + await this.page.getByTestId(`menu-item-${item}`).click(); + } +} diff --git a/e2e/pages/org/new-organization-page.ts b/e2e/pages/org/new-organization-page.ts new file mode 100644 index 000000000..09716f8bc --- /dev/null +++ b/e2e/pages/org/new-organization-page.ts @@ -0,0 +1,23 @@ +import type { Page } from "@playwright/test"; +import { newOrganizationSchema } from "~/features/tournament-organization/tournament-organization-schemas"; +import { ORGANIZATION_NEW_PAGE } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class NewOrganizationPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, newOrganizationSchema); + } + + async goto() { + await navigate({ page: this.page, url: ORGANIZATION_NEW_PAGE }); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/org/organization-edit-page.ts b/e2e/pages/org/organization-edit-page.ts new file mode 100644 index 000000000..58ec00e81 --- /dev/null +++ b/e2e/pages/org/organization-edit-page.ts @@ -0,0 +1,38 @@ +import type { Page } from "@playwright/test"; +import type { TournamentOrganizationRole } from "~/features/tournament-organization/tournament-organization-constants"; +import { tournamentOrganizationEditPage } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; + +export class OrganizationEditPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + title: page.getByText("Editing tournament organization"), + }; + } + + async goto(organizationSlug: string) { + await navigate({ + page: this.page, + url: tournamentOrganizationEditPage(organizationSlug), + }); + } + + /** The members array field renders one fieldset per member, identified by the selected user. */ + memberFieldset(username: string) { + return this.page.locator(`fieldset:has(button:has-text("${username}"))`); + } + + async setMemberRole(username: string, role: TournamentOrganizationRole) { + await this.memberFieldset(username) + .getByLabel("Role", { exact: true }) + .selectOption(role); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/org/organization-page.ts b/e2e/pages/org/organization-page.ts new file mode 100644 index 000000000..35d4904d8 --- /dev/null +++ b/e2e/pages/org/organization-page.ts @@ -0,0 +1,96 @@ +import type { Page } from "@playwright/test"; +import { + banUserActionSchema, + updateIsEstablishedSchema, +} from "~/features/tournament-organization/tournament-organization-schemas"; +import { tournamentOrganizationPage } from "~/utils/urls"; +import { + modalClickConfirmButton, + navigate, + selectUser, + submit, + waitForPOSTResponse, +} from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; +import { OrganizationEditPage } from "./organization-edit-page"; + +export class OrganizationPage { + private readonly page: Page; + private readonly isEstablishedForm; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.isEstablishedForm = createFormHelpers(page, updateIsEstablishedSchema); + this.locators = { + editButton: page.getByTestId("edit-org-button"), + bannedUsersTab: page.getByTestId("banned-users-tab"), + adminTab: page.getByRole("tab", { name: "Admin" }), + newBanButton: page.getByRole("button", { name: "New ban" }), + unbanButton: page.getByRole("button", { name: "Unban" }), + bannedUsersTable: page.getByRole("table"), + }; + } + + async goto(organizationSlug: string) { + await navigate({ + page: this.page, + url: tournamentOrganizationPage({ organizationSlug }), + }); + } + + async openEdit() { + await this.locators.editButton.click(); + return new OrganizationEditPage(this.page); + } + + /** Established organizations can add tournaments and their admins can edit them. */ + async establish() { + await this.locators.adminTab.click(); + await waitForPOSTResponse(this.page, () => + this.isEstablishedForm.check("isEstablished"), + ); + } + + async openBannedUsers() { + await this.locators.bannedUsersTab.click(); + } + + async openBanModal() { + await this.locators.newBanButton.click(); + return new BanUserDialog(this.page); + } + + async unban() { + await this.locators.unbanButton.click(); + await modalClickConfirmButton(this.page); + } +} + +class BanUserDialog { + private readonly page: Page; + readonly form; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, banUserActionSchema); + this.locators = { + dialog: page.getByRole("dialog"), + }; + } + + /** Scoped to the dialog: the tab panel behind it carries the label too. */ + async selectUser(userName: string) { + await selectUser({ + page: this.page, + userName, + labelName: "Player", + within: this.locators.dialog, + }); + } + + async save() { + await submit(this.page); + } +} diff --git a/e2e/pages/scrims/scrims-page.ts b/e2e/pages/scrims/scrims-page.ts new file mode 100644 index 000000000..262086a43 --- /dev/null +++ b/e2e/pages/scrims/scrims-page.ts @@ -0,0 +1,25 @@ +import type { Page } from "@playwright/test"; +import { scrimsPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { AssociationsPage } from "../associations/associations-page"; + +export class ScrimsPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + associationsLink: page.getByRole("link", { name: "Associations" }), + }; + } + + async goto() { + await navigate({ page: this.page, url: scrimsPage() }); + } + + async openAssociations() { + await this.locators.associationsLink.click(); + return new AssociationsPage(this.page); + } +} diff --git a/e2e/pages/team/join-team-page.ts b/e2e/pages/team/join-team-page.ts new file mode 100644 index 000000000..f485d10bc --- /dev/null +++ b/e2e/pages/team/join-team-page.ts @@ -0,0 +1,21 @@ +import type { Page } from "@playwright/test"; +import { navigate, submit } from "../../helpers/playwright"; +import { TeamPage } from "./team-page"; + +export class JoinTeamPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** Takes the whole invite link as shown on the roster page. */ + async goto(inviteLink: string) { + await navigate({ page: this.page, url: inviteLink }); + } + + async join() { + await submit(this.page); + return new TeamPage(this.page); + } +} diff --git a/e2e/pages/team/new-team-page.ts b/e2e/pages/team/new-team-page.ts new file mode 100644 index 000000000..a8e9cb832 --- /dev/null +++ b/e2e/pages/team/new-team-page.ts @@ -0,0 +1,19 @@ +import type { Page } from "@playwright/test"; +import { createTeamSchema } from "~/features/team/team-schemas"; +import { NEW_TEAM_PAGE } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class NewTeamPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, createTeamSchema); + } + + async goto() { + await navigate({ page: this.page, url: NEW_TEAM_PAGE }); + } +} diff --git a/e2e/pages/team/team-edit-page.ts b/e2e/pages/team/team-edit-page.ts new file mode 100644 index 000000000..b723b124b --- /dev/null +++ b/e2e/pages/team/team-edit-page.ts @@ -0,0 +1,21 @@ +import type { Page } from "@playwright/test"; +import { editTeamFormSchema } from "~/features/team/team-schemas"; +import { editTeamPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { createFormHelpers } from "../../helpers/playwright-form"; + +export class TeamEditPage { + private readonly page: Page; + readonly form; + + constructor(page: Page) { + this.page = page; + this.form = createFormHelpers(page, editTeamFormSchema, { + submitTestId: "edit-team-submit-button", + }); + } + + async goto(customUrl: string) { + await navigate({ page: this.page, url: editTeamPage(customUrl) }); + } +} diff --git a/e2e/pages/team/team-page.ts b/e2e/pages/team/team-page.ts new file mode 100644 index 000000000..a18200856 --- /dev/null +++ b/e2e/pages/team/team-page.ts @@ -0,0 +1,92 @@ +import type { Page } from "@playwright/test"; +import { teamPage } from "~/utils/urls"; +import { + modalClickConfirmButton, + navigate, + waitForPOSTResponse, +} from "../../helpers/playwright"; +import { TeamEditPage } from "./team-edit-page"; +import { TeamRosterPage } from "./team-roster-page"; + +export class TeamPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + bio: page.getByTestId("team-bio"), + bskyLink: page.getByTestId("bsky-link").first(), + manageRosterButton: page.getByTestId("manage-roster-button"), + editTeamButton: page.getByTestId("edit-team-button"), + actionsMenuButton: page.getByTestId("team-actions-menu-button"), + mainTeamIndicator: page.getByTestId("main-team-indicator"), + makeMainTeamButton: page.getByTestId("make-main-team-button"), + leaveTeamButton: page.getByTestId("leave-team-button"), + deleteTeamButton: page.getByTestId("delete-team-button"), + otherRolesTab: page.getByRole("tab", { name: /Other/ }), + confirmDialog: page.getByRole("dialog"), + }; + } + + async goto(customUrl: string) { + await navigate({ page: this.page, url: teamPage(customUrl) }); + } + + memberRole(nth: number) { + return this.page.getByTestId(`member-row-role-${nth}`); + } + + ownerBadge(userId: number) { + return this.page.getByTestId(`member-owner-${userId}`); + } + + customRole(name: string) { + return this.page.getByText(name).first(); + } + + async openManageRoster() { + await this.locators.manageRosterButton.click(); + return new TeamRosterPage(this.page); + } + + async openEdit() { + await this.locators.editTeamButton.click(); + return new TeamEditPage(this.page); + } + + async openActionsMenu() { + await this.locators.actionsMenuButton.click(); + } + + /** Requires the actions menu to be open. */ + async makeMainTeam() { + await waitForPOSTResponse(this.page, () => + this.locators.makeMainTeamButton.click(), + ); + } + + /** + * Opens the leave confirmation dialog, which names the new owner if the + * leaver owns the team. Requires the actions menu to be open. + */ + async startLeaving() { + await this.locators.leaveTeamButton.click(); + } + + async confirmLeaving() { + await modalClickConfirmButton(this.page); + } + + /** Requires the actions menu to be open. */ + async leave() { + await this.startLeaving(); + await this.confirmLeaving(); + } + + /** Requires the actions menu to be open. */ + async delete() { + await this.locators.deleteTeamButton.click(); + await modalClickConfirmButton(this.page); + } +} diff --git a/e2e/pages/team/team-roster-page.ts b/e2e/pages/team/team-roster-page.ts new file mode 100644 index 000000000..ad65ff794 --- /dev/null +++ b/e2e/pages/team/team-roster-page.ts @@ -0,0 +1,88 @@ +import type { Page } from "@playwright/test"; +import type { + MemberRole, + MemberRoleType, +} from "~/features/team/team-constants"; +import type { CUSTOM_ROLE_VALUE } from "~/features/team/team-schemas"; +import { manageTeamRosterPage } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; + +export class TeamRosterPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + inviteLink: page.getByTestId("invite-link"), + editorToggles: page.getByLabel("Editor"), + }; + } + + async goto(customUrl: string) { + await navigate({ page: this.page, url: manageTeamRosterPage(customUrl) }); + } + + memberRow(nth: number) { + return new MemberRow(this.page, nth); + } + + inviteLink() { + return this.locators.inviteLink.innerText(); + } + + async resetInviteLink() { + await submit(this.page, "reset-invite-link-button"); + } + + /** The owner and the user themselves can't be made editors, so they have no toggle. */ + async makeEditor(nth: number) { + await this.locators.editorToggles.nth(nth).click({ force: true }); + } + + async save() { + await submit(this.page); + } +} + +class MemberRow { + private readonly row; + /** The array field's wrapper, holding the reorder & remove buttons. */ + private readonly fieldset; + readonly locators; + + constructor(page: Page, index: number) { + this.row = page.getByTestId(`member-row-${index}`); + this.fieldset = page.locator( + `fieldset:has([data-testid='member-row-${index}'])`, + ); + this.locators = { + row: this.row, + username: page.getByTestId(`member-row-username-${index}`), + roleSelect: this.row.locator("select").first(), + customRoleInput: this.row.getByRole("textbox"), + roleTypeSelect: this.row.locator("select").nth(1), + removeButton: this.fieldset.getByRole("button", { name: "Remove item" }), + moveUpButton: this.fieldset.getByRole("button", { name: "Move up" }), + moveDownButton: this.fieldset.getByRole("button", { name: "Move down" }), + }; + } + + async selectRole(role: MemberRole | typeof CUSTOM_ROLE_VALUE) { + await this.locators.roleSelect.selectOption(role); + } + + async setCustomRole(name: string, roleType: MemberRoleType) { + await this.selectRole("CUSTOM"); + await this.locators.customRoleInput.fill(name); + await this.locators.roleTypeSelect.selectOption(roleType); + } + + async remove() { + await this.locators.removeButton.click(); + } + + async moveDown() { + await this.locators.moveDownButton.click(); + } +} diff --git a/e2e/pages/tournament/tournament-admin-page.ts b/e2e/pages/tournament/tournament-admin-page.ts new file mode 100644 index 000000000..3512ae104 --- /dev/null +++ b/e2e/pages/tournament/tournament-admin-page.ts @@ -0,0 +1,25 @@ +import type { Page } from "@playwright/test"; +import { tournamentAdminPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { CalendarNewEventPage } from "../calendar/calendar-new-event-page"; + +export class TournamentAdminPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + editEventInfoButton: page.getByTestId("edit-event-info-button"), + }; + } + + async goto(tournamentId: number) { + await navigate({ page: this.page, url: tournamentAdminPage(tournamentId) }); + } + + async editEventInfo() { + await this.locators.editEventInfoButton.click(); + return new CalendarNewEventPage(this.page); + } +} diff --git a/e2e/pages/tournament/tournament-nav.ts b/e2e/pages/tournament/tournament-nav.ts new file mode 100644 index 000000000..868a7852c --- /dev/null +++ b/e2e/pages/tournament/tournament-nav.ts @@ -0,0 +1,27 @@ +import type { Page } from "@playwright/test"; +import { clickNavTab } from "../../helpers/playwright"; +import { TournamentAdminPage } from "./tournament-admin-page"; +import { TournamentBracketsPage } from "./tournament-brackets-page"; + +/** Tabs shared by every tournament page, some of which collapse into a "More" menu. */ +export class TournamentNav { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + teamsTab: page.locator('[data-testid="teams-tab"]:visible'), + }; + } + + async openBrackets() { + await clickNavTab(this.page, "brackets-tab"); + return new TournamentBracketsPage(this.page); + } + + async openAdmin() { + await clickNavTab(this.page, "admin-tab"); + return new TournamentAdminPage(this.page); + } +} diff --git a/e2e/pages/tournament/tournament-page.ts b/e2e/pages/tournament/tournament-page.ts index c382156ab..4ef0dca30 100644 --- a/e2e/pages/tournament/tournament-page.ts +++ b/e2e/pages/tournament/tournament-page.ts @@ -1,14 +1,17 @@ import type { Page } from "@playwright/test"; import { tournamentPage } from "~/utils/urls"; import { navigate } from "../../helpers/playwright"; +import { TournamentNav } from "./tournament-nav"; import { TournamentRegisterPage } from "./tournament-register-page"; export class TournamentPage { private readonly page: Page; + readonly nav; readonly locators; constructor(page: Page) { this.page = page; + this.nav = new TournamentNav(page); this.locators = { registerCta: page.getByTestId("register-cta"), }; diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 846dd5402..04a9b3283 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -5,22 +5,28 @@ import { registerTeamFormSchema } from "~/features/tournament/tournament-registe import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { tournamentRegisterPage } from "~/utils/urls"; -import { clickNavTab, navigate, submit } from "../../helpers/playwright"; +import { navigate, submit } from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; -import { TournamentBracketsPage } from "./tournament-brackets-page"; +import { TournamentNav } from "./tournament-nav"; /** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */ const FIRST_COUNTERPICK_STAGE_ID = 5; export class TournamentRegisterPage { private readonly page: Page; + readonly nav; readonly form; + readonly locators; constructor(page: Page) { this.page = page; + this.nav = new TournamentNav(page); this.form = createFormHelpers(page, registerTeamFormSchema, { submitTestId: "save-team-button", }); + this.locators = { + fillRosterHeading: page.getByText("Fill roster"), + }; } async goto(tournamentId: number) { @@ -70,8 +76,7 @@ export class TournamentRegisterPage { return submit(this.page, "check-in-button"); } - async openBrackets() { - await clickNavTab(this.page, "brackets-tab"); - return new TournamentBracketsPage(this.page); + openBrackets() { + return this.nav.openBrackets(); } } diff --git a/e2e/pages/user/user-page.ts b/e2e/pages/user/user-page.ts new file mode 100644 index 000000000..d0eb00ff4 --- /dev/null +++ b/e2e/pages/user/user-page.ts @@ -0,0 +1,26 @@ +import type { Page } from "@playwright/test"; +import { userPage } from "~/utils/urls"; +import { navigate } from "../../helpers/playwright"; +import { TeamPage } from "../team/team-page"; + +export class UserPage { + private readonly page: Page; + readonly locators; + + constructor(page: Page) { + this.page = page; + this.locators = { + mainTeamLink: page.getByTestId("main-team-link"), + secondaryTeamsTrigger: page.getByTestId("secondary-team-trigger"), + }; + } + + async goto(discordId: string) { + await navigate({ page: this.page, url: userPage({ discordId }) }); + } + + async openMainTeam() { + await this.locators.mainTeamLink.click(); + return new TeamPage(this.page); + } +} diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 05ef37c4f..60a996441 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -1,274 +1,301 @@ import { NZAP_TEST_ID } from "~/db/seed/constants"; import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants"; -import { - createTeamSchema, - editTeamFormSchema, -} from "~/features/team/team-schemas"; -import { editTeamPage, teamPage, userPage } from "~/utils/urls"; +import type { Factories } from "./helpers/factories"; import { expect, impersonate, isNotVisible, - modalClickConfirmButton, navigate, - submit, test, - waitForPOSTResponse, } from "./helpers/playwright"; -import { createFormHelpers } from "./helpers/playwright-form"; +import { AnythingAdder } from "./pages/layout/anything-adder"; +import { JoinTeamPage } from "./pages/team/join-team-page"; +import { NewTeamPage } from "./pages/team/new-team-page"; +import { TeamEditPage } from "./pages/team/team-edit-page"; +import { TeamPage } from "./pages/team/team-page"; +import { UserPage } from "./pages/user/user-page"; + +const TEAM_NAME = "Alliance Rogue"; +const SECONDARY_TEAM_NAME = "Team Olive"; +const ROSTER_SIZE = 4; test.describe("New team creation", () => { test("creates new team", async ({ page }) => { - // await seed(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-team").click(); + await new AnythingAdder(page).add("team"); await expect(page).toHaveURL(/t\/new/); - const form = createFormHelpers(page, createTeamSchema); - await form.fill("name", "Chimera"); - await form.submit(); + const newTeam = new NewTeamPage(page); + await newTeam.form.fill("name", "Chimera"); + await newTeam.form.submit(); await expect(page).toHaveURL(/chimera/); }); }); test.describe("Team page", () => { - test("edit team info", async ({ page }) => { - // await seed(page); - await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - - await page.getByTestId("edit-team-button").click(); - - const form = createFormHelpers(page, editTeamFormSchema, { - submitTestId: "edit-team-submit-button", + test("edit team info", async ({ page, factories }) => { + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID], }); - await form.fill("name", "Better Alliance Rogue"); - await form.fill("bsky", "BetterAllianceRogue"); - await form.fill("bio", "shorter bio"); + await impersonate(page, ADMIN_ID); - await form.submit(); + const team = new TeamPage(page); + await team.goto(customUrl); + + const teamEdit = await team.openEdit(); + await teamEdit.form.fill("name", "Better Alliance Rogue"); + await teamEdit.form.fill("bsky", "BetterAllianceRogue"); + await teamEdit.form.fill("bio", "shorter bio"); + await teamEdit.form.submit(); await expect(page).toHaveURL(/better-alliance-rogue/); - await page.getByText("shorter bio").isVisible(); - await expect(page.getByTestId("bsky-link").first()).toHaveAttribute( + await expect(team.locators.bio).toHaveText("shorter bio"); + await expect(team.locators.bskyLink).toHaveAttribute( "href", "https://bsky.app/profile/BetterAllianceRogue", ); }); - test("kicks a member & changes a role", async ({ page }) => { - // await seed(page); + test("kicks a member & changes a role", async ({ page, factories }) => { + const { customUrl } = await createFullTeam(factories); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); + + const team = new TeamPage(page); + await team.goto(customUrl); // Owner is Sendou - await expect(page.getByTestId(`member-owner-${ADMIN_ID}`)).toBeVisible(); + await expect(team.ownerBadge(ADMIN_ID)).toBeVisible(); - await page.getByTestId("manage-roster-button").click(); + const roster = await team.openManageRoster(); + await roster.memberRow(0).selectRole("SUPPORT"); - await page - .getByTestId("member-row-0") - .locator("select") - .selectOption("SUPPORT"); + const lastMember = roster.memberRow(ROSTER_SIZE - 1); + await expect(lastMember.locators.row).toBeVisible(); + await lastMember.remove(); + await isNotVisible(lastMember.locators.row); - await expect(page.getByTestId("member-row-3")).toBeVisible(); - await page - .locator("fieldset:has([data-testid='member-row-3'])") - .getByRole("button", { name: "Remove item" }) - .click(); - await isNotVisible(page.getByTestId("member-row-3")); + await roster.save(); - await submit(page); + await team.goto(customUrl); - await navigate({ page, url: teamPage("alliance-rogue") }); - - await expect(page.getByTestId("member-row-role-0")).toHaveText("Support"); + await expect(team.memberRole(0)).toHaveText("Support"); }); - test("sets a custom role for a member", async ({ page }) => { - // await seed(page); + test("sets a custom role for a member", async ({ page, factories }) => { + const { customUrl } = await createFullTeam(factories); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("manage-roster-button").click(); + const team = new TeamPage(page); + await team.goto(customUrl); - const memberRow = page.getByTestId("member-row-1"); - await memberRow.locator("select").first().selectOption("CUSTOM"); - await memberRow.getByRole("textbox").fill("Strategist"); - await memberRow.locator("select").nth(1).selectOption("OTHER"); + const roster = await team.openManageRoster(); + await roster.memberRow(1).setCustomRole("Strategist", "OTHER"); + await roster.save(); - await submit(page); - - await navigate({ page, url: teamPage("alliance-rogue") }); + await team.goto(customUrl); // custom role is classified as "OTHER" so it lives under the "Other" tab - await page.getByRole("tab", { name: /Other/ }).click(); - await expect(page.getByText("Strategist").first()).toBeVisible(); + await team.locators.otherRolesTab.click(); + await expect(team.customRole("Strategist")).toBeVisible(); }); - test("reorders members via move buttons", async ({ page }) => { - // await seed(page); + test("reorders members via move buttons", async ({ page, factories }) => { + const { customUrl } = await createFullTeam(factories); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("manage-roster-button").click(); + const team = new TeamPage(page); + await team.goto(customUrl); - const firstName = await page - .getByTestId("member-row-username-0") - .innerText(); - const secondName = await page - .getByTestId("member-row-username-1") - .innerText(); + const roster = await team.openManageRoster(); + const firstRow = roster.memberRow(0); + const secondRow = roster.memberRow(1); + const lastRow = roster.memberRow(ROSTER_SIZE - 1); + + const firstName = await firstRow.locators.username.innerText(); + const secondName = await secondRow.locators.username.innerText(); expect(firstName).not.toBe(secondName); - const firstRow = page.locator("fieldset:has([data-testid='member-row-0'])"); - const lastRow = page.locator("fieldset:has([data-testid='member-row-3'])"); - // the first member can't move up and the last can't move down - await expect( - firstRow.getByRole("button", { name: "Move up" }), - ).toBeDisabled(); - await expect( - lastRow.getByRole("button", { name: "Move down" }), - ).toBeDisabled(); + await expect(firstRow.locators.moveUpButton).toBeDisabled(); + await expect(lastRow.locators.moveDownButton).toBeDisabled(); // move the first member down one slot - await firstRow.getByRole("button", { name: "Move down" }).click(); + await firstRow.moveDown(); - await expect(page.getByTestId("member-row-username-0")).toHaveText( - secondName, - ); - await expect(page.getByTestId("member-row-username-1")).toHaveText( - firstName, - ); + await expect(firstRow.locators.username).toHaveText(secondName); + await expect(secondRow.locators.username).toHaveText(firstName); - await submit(page); + await roster.save(); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("manage-roster-button").click(); + await team.goto(customUrl); + await team.openManageRoster(); // the new order is persisted - await expect(page.getByTestId("member-row-username-0")).toHaveText( - secondName, - ); + await expect(firstRow.locators.username).toHaveText(secondName); }); - test("deletes team", async ({ page }) => { - // await seed(page); + test("deletes team", async ({ page, factories }) => { + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID], + }); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("team-actions-menu-button").click(); - await page.getByTestId("delete-team-button").click(); - await modalClickConfirmButton(page); + const team = new TeamPage(page); + await team.goto(customUrl); - await expect(page).not.toHaveURL(/alliance-rogue/); + await team.openActionsMenu(); + await team.delete(); + + await expect(page).not.toHaveURL(new RegExp(customUrl)); }); - test("resets invite code, joins team, leaves, rejoins", async ({ page }) => { - // await seed(page); + test("resets invite code, joins team, leaves, rejoins", async ({ + page, + factories, + }) => { + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID], + }); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("manage-roster-button").click(); + const team = new TeamPage(page); + await team.goto(customUrl); - const oldInviteLink = await page.getByTestId("invite-link").innerText(); + const roster = await team.openManageRoster(); + const oldInviteLink = await roster.inviteLink(); - await submit(page, "reset-invite-link-button"); + await roster.resetInviteLink(); - await expect(page.getByTestId("invite-link")).not.toHaveText(oldInviteLink); - const newInviteLink = await page.getByTestId("invite-link").innerText(); + await expect(roster.locators.inviteLink).not.toHaveText(oldInviteLink); + const newInviteLink = await roster.inviteLink(); await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: newInviteLink }); - await submit(page); + const join = new JoinTeamPage(page); + await join.goto(newInviteLink); + await join.join(); - await page.getByTestId("team-actions-menu-button").click(); - await page.getByTestId("leave-team-button").click(); - await modalClickConfirmButton(page); + await team.openActionsMenu(); + await team.leave(); - await navigate({ page, url: newInviteLink }); - await submit(page); + await join.goto(newInviteLink); + await join.join(); - await page.getByTestId("team-actions-menu-button").click(); - await expect(page.getByTestId("leave-team-button")).toBeVisible(); + await team.openActionsMenu(); + await expect(team.locators.leaveTeamButton).toBeVisible(); }); test("joins a secondary team, makes main team & leaves making the seconary team the main one", async ({ page, + factories, }) => { - // await seed(page); - await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("team-olive") }); - - await page.getByTestId("manage-roster-button").click(); - - const inviteLink = await page.getByTestId("invite-link").innerText(); - await navigate({ page, url: inviteLink }); - await submit(page); - - await page.getByTestId("team-actions-menu-button").click(); - await waitForPOSTResponse(page, async () => { - await page.getByTestId("make-main-team-button").click(); + await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID], }); + const secondaryTeamOwner = await factories.UserFactory.create(); + const { customUrl: secondaryCustomUrl } = + await factories.TeamFactory.create({ + name: SECONDARY_TEAM_NAME, + memberUserIds: [secondaryTeamOwner.id], + }); - await navigate({ page, url: userPage({ discordId: ADMIN_DISCORD_ID }) }); + await impersonate(page, ADMIN_ID); - await expect(page.getByTestId("secondary-team-trigger")).toBeVisible(); - await isNotVisible(page.getByText("Alliance Rogue")); + const secondaryTeam = new TeamPage(page); + await secondaryTeam.goto(secondaryCustomUrl); - await page.getByTestId("main-team-link").click(); + const roster = await secondaryTeam.openManageRoster(); + const inviteLink = await roster.inviteLink(); - await page.getByTestId("team-actions-menu-button").click(); - await expect(page.getByTestId("main-team-indicator")).toBeVisible(); - await page.getByTestId("leave-team-button").click(); - await modalClickConfirmButton(page); + const join = new JoinTeamPage(page); + await join.goto(inviteLink); + await join.join(); - await navigate({ page, url: userPage({ discordId: ADMIN_DISCORD_ID }) }); + await secondaryTeam.openActionsMenu(); + await secondaryTeam.makeMainTeam(); - await isNotVisible(page.getByTestId("secondary-team-trigger")); - await expect(page.getByText("Alliance Rogue")).toBeVisible(); + const user = new UserPage(page); + await user.goto(ADMIN_DISCORD_ID); + + await expect(user.locators.secondaryTeamsTrigger).toBeVisible(); + await expect(user.locators.mainTeamLink).not.toContainText(TEAM_NAME); + + const mainTeam = await user.openMainTeam(); + + await mainTeam.openActionsMenu(); + await expect(mainTeam.locators.mainTeamIndicator).toBeVisible(); + await mainTeam.leave(); + + await user.goto(ADMIN_DISCORD_ID); + + await isNotVisible(user.locators.secondaryTeamsTrigger); + await expect(user.locators.mainTeamLink).toContainText(TEAM_NAME); }); test("makes another user editor, who can edit the page & becomes owner after the original leaves", async ({ page, + factories, }) => { - // await seed(page, "NZAP_IN_TEAM"); + const { customUrl } = await factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID, NZAP_TEST_ID], + }); + await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("manage-roster-button").click(); + const team = new TeamPage(page); + await team.goto(customUrl); - await page.getByLabel("Editor").first().click({ force: true }); - await submit(page); + const roster = await team.openManageRoster(); + // the owner has no editor toggle, so the first one belongs to N-ZAP + await roster.makeEditor(0); + await roster.save(); await impersonate(page, NZAP_TEST_ID); - await navigate({ page, url: editTeamPage("alliance-rogue") }); - const editorForm = createFormHelpers(page, editTeamFormSchema, { - submitTestId: "edit-team-submit-button", - }); - await editorForm.fill("bio", "from editor"); - await editorForm.submit(); + const teamEdit = new TeamEditPage(page); + await teamEdit.goto(customUrl); + await teamEdit.form.fill("bio", "from editor"); + await teamEdit.form.submit(); - await expect(page).toHaveURL(/alliance-rogue/); - await page.getByText("from editor").isVisible(); + await expect(page).toHaveURL(new RegExp(customUrl)); + await expect(team.locators.bio).toHaveText("from editor"); await impersonate(page, ADMIN_ID); - await navigate({ page, url: teamPage("alliance-rogue") }); - await page.getByTestId("team-actions-menu-button").click(); - await page.getByTestId("leave-team-button").click(); - await page.getByText("New owner will be N-ZAP").isVisible(); - await modalClickConfirmButton(page); + await team.goto(customUrl); - await page.getByTestId("team-actions-menu-button").click(); - await isNotVisible(page.getByTestId("leave-team-button")); + await team.openActionsMenu(); + await team.startLeaving(); + await expect(team.locators.confirmDialog).toContainText( + "New owner will be N-ZAP", + ); + await team.confirmLeaving(); + + await team.openActionsMenu(); + await isNotVisible(team.locators.leaveTeamButton); }); }); + +/** A team of the admin and three others, the admin its owner and first member. */ +async function createFullTeam(factories: Factories) { + const members = await factories.UserFactory.createMany(ROSTER_SIZE - 1); + + return factories.TeamFactory.create({ + name: TEAM_NAME, + memberUserIds: [ADMIN_ID, ...members.map((member) => member.id)], + }); +}