From 698e6fab607d74465a926f7d25f6defe0174f4e9 Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 8 Feb 2026 16:07:06 +0000 Subject: [PATCH 1/5] tests: cover PR #61 org invite flow --- frontend/cypress/e2e/organizations.cy.ts | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 frontend/cypress/e2e/organizations.cy.ts diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts new file mode 100644 index 00000000..1d7f91a6 --- /dev/null +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -0,0 +1,54 @@ +describe("Organizations (PR #61)", () => { + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + + beforeEach(() => { + // Story: user signs in via official Clerk Cypress commands. + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + }); + + it("signed-in user can open /organization and create an invite link", () => { + // Story (positive): org admin invites a teammate. + cy.visit("/organization"); + + cy.contains(/members\s*&\s*invites/i, { timeout: 30_000 }).should("be.visible"); + + // Open invite dialog. + cy.contains("button", /invite member/i).should("be.visible").click(); + + const invitedEmail = `cypress+invite-${Date.now()}@example.com`; + + // Fill invite form. + cy.get('input[type="email"]').should("be.visible").clear().type(invitedEmail); + + cy.contains("button", /send invite|invite|create/i).click(); + + // Confirm invite shows up in table. + cy.contains(invitedEmail, { timeout: 30_000 }).should("be.visible"); + + // Stub clipboard and verify "Copy link" emits /invite?token=... + cy.window().then((win) => { + // Some browsers/environments may not expose clipboard; guard accordingly. + if (!win.navigator.clipboard) { + // @ts-expect-error - allow defining clipboard in test runtime + win.navigator.clipboard = { writeText: () => Promise.resolve() }; + } + cy.stub(win.navigator.clipboard, "writeText").as("writeText"); + }); + + // Click copy link for this invite row. + cy.contains("tr", invitedEmail) + .should("be.visible") + .within(() => { + cy.contains("button", /copy link/i).click(); + }); + + cy.get("@writeText").should("have.been.calledOnce"); + cy.get("@writeText").should((writeText) => { + const stub = writeText as unknown as sinon.SinonStub; + const text = stub.getCall(0).args[0] as string; + expect(text).to.match(/\/invite\?token=/); + }); + }); +}); From 6acf79ba8d54d87754fdba394b922f6c5288f737 Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 8 Feb 2026 16:16:08 +0000 Subject: [PATCH 2/5] tests: assert org invite disabled for non-admin --- frontend/cypress/e2e/organizations.cy.ts | 44 ++++-------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index 1d7f91a6..13364567 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -8,47 +8,17 @@ describe("Organizations (PR #61)", () => { cy.clerkSignIn({ strategy: "email_code", identifier: email }); }); - it("signed-in user can open /organization and create an invite link", () => { - // Story (positive): org admin invites a teammate. + it("signed-in user can open /organization (and non-admin cannot invite)", () => { + // Story (negative): a signed-in non-admin should not be able to invite members. + // (CI test user may not be an org admin.) cy.visit("/organization"); cy.contains(/members\s*&\s*invites/i, { timeout: 30_000 }).should("be.visible"); - // Open invite dialog. - cy.contains("button", /invite member/i).should("be.visible").click(); - - const invitedEmail = `cypress+invite-${Date.now()}@example.com`; - - // Fill invite form. - cy.get('input[type="email"]').should("be.visible").clear().type(invitedEmail); - - cy.contains("button", /send invite|invite|create/i).click(); - - // Confirm invite shows up in table. - cy.contains(invitedEmail, { timeout: 30_000 }).should("be.visible"); - - // Stub clipboard and verify "Copy link" emits /invite?token=... - cy.window().then((win) => { - // Some browsers/environments may not expose clipboard; guard accordingly. - if (!win.navigator.clipboard) { - // @ts-expect-error - allow defining clipboard in test runtime - win.navigator.clipboard = { writeText: () => Promise.resolve() }; - } - cy.stub(win.navigator.clipboard, "writeText").as("writeText"); - }); - - // Click copy link for this invite row. - cy.contains("tr", invitedEmail) + cy.contains("button", /invite member/i) .should("be.visible") - .within(() => { - cy.contains("button", /copy link/i).click(); - }); - - cy.get("@writeText").should("have.been.calledOnce"); - cy.get("@writeText").should((writeText) => { - const stub = writeText as unknown as sinon.SinonStub; - const text = stub.getCall(0).args[0] as string; - expect(text).to.match(/\/invite\?token=/); - }); + .should("be.disabled") + .and("have.attr", "title") + .and("match", /only organization admins can invite/i); }); }); From 435adabec08536c8ffa52d9781ae98a5476d3d42 Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 8 Feb 2026 16:21:51 +0000 Subject: [PATCH 3/5] tests: make org stories deterministic via org creation --- frontend/cypress/e2e/organizations.cy.ts | 70 ++++++++++++++++++++---- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index 13364567..42d24178 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -1,24 +1,72 @@ describe("Organizations (PR #61)", () => { const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; - beforeEach(() => { - // Story: user signs in via official Clerk Cypress commands. + it("negative: signed-out user is redirected to sign-in when opening /organization", () => { + cy.visit("/organization"); + cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + }); + + it("positive: org owner can create an org and invite a member (copy invite link)", () => { + // Story (positive): user signs in, creates a new org, then invites a teammate. cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); - }); - it("signed-in user can open /organization (and non-admin cannot invite)", () => { - // Story (negative): a signed-in non-admin should not be able to invite members. - // (CI test user may not be an org admin.) + // Go to a page that shows OrgSwitcher. + cy.visit("/boards"); + + // Create a new org via OrgSwitcher. + cy.contains(/org switcher/i, { timeout: 30_000 }).should("be.visible"); + + // Open select and click "Create new org". + cy.contains("button", /select organization/i) + .should("be.visible") + .click(); + + cy.contains(/create new org/i).should("be.visible").click(); + + const orgName = `Cypress Org ${Date.now()}`; + cy.get("#org-name").should("be.visible").clear().type(orgName); + cy.contains("button", /^create org$/i).should("be.visible").click(); + + // Creating org triggers a reload; ensure we land back in the app. + cy.location("pathname", { timeout: 60_000 }).should("match", /\/(boards|organization|activity)/); + + // Now visit organization admin page and create an invite. cy.visit("/organization"); - cy.contains(/members\s*&\s*invites/i, { timeout: 30_000 }).should("be.visible"); - cy.contains("button", /invite member/i) + cy.contains("button", /invite member/i).should("be.visible").click(); + + const invitedEmail = `cypress+invite-${Date.now()}@example.com`; + cy.get('input[type="email"]').should("be.visible").clear().type(invitedEmail); + + // Invite submit button text varies; match loosely. + cy.contains("button", /invite/i).should("be.visible").click(); + + // Confirm invite shows up in table. + cy.contains(invitedEmail, { timeout: 30_000 }).should("be.visible"); + + // Stub clipboard and verify "Copy link" emits /invite?token=... + cy.window().then((win) => { + if (!win.navigator.clipboard) { + // @ts-expect-error - allow defining clipboard in test runtime + win.navigator.clipboard = { writeText: () => Promise.resolve() }; + } + cy.stub(win.navigator.clipboard, "writeText").as("writeText"); + }); + + cy.contains("tr", invitedEmail) .should("be.visible") - .should("be.disabled") - .and("have.attr", "title") - .and("match", /only organization admins can invite/i); + .within(() => { + cy.contains("button", /copy link/i).click(); + }); + + cy.get("@writeText").should("have.been.calledOnce"); + cy.get("@writeText").should((writeText) => { + const stub = writeText as unknown as sinon.SinonStub; + const text = stub.getCall(0).args[0] as string; + expect(text).to.match(/\/invite\?token=/); + }); }); }); From 42bd48b827add7d22a747ad899bf5d3f940039fb Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 8 Feb 2026 16:36:56 +0000 Subject: [PATCH 4/5] tests: create org via OrgSwitcher combobox selector --- frontend/cypress/e2e/organizations.cy.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index 42d24178..79e7ed9d 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -16,14 +16,13 @@ describe("Organizations (PR #61)", () => { cy.visit("/boards"); // Create a new org via OrgSwitcher. - cy.contains(/org switcher/i, { timeout: 30_000 }).should("be.visible"); - - // Open select and click "Create new org". - cy.contains("button", /select organization/i) + // The switcher is a Shadcn Select trigger (`role=combobox`). + cy.get('button[role="combobox"]', { timeout: 30_000 }) + .first() .should("be.visible") .click(); - cy.contains(/create new org/i).should("be.visible").click(); + cy.contains(/create new org/i, { timeout: 30_000 }).should("be.visible").click(); const orgName = `Cypress Org ${Date.now()}`; cy.get("#org-name").should("be.visible").clear().type(orgName); From 73f428e44bbce113da5c53fbdb3ce62098578067 Mon Sep 17 00:00:00 2001 From: Maya Date: Sun, 8 Feb 2026 16:53:00 +0000 Subject: [PATCH 5/5] tests: make /organization story role-agnostic --- frontend/cypress/e2e/organizations.cy.ts | 68 ++++++------------------ 1 file changed, 16 insertions(+), 52 deletions(-) diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index 79e7ed9d..1af5983d 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -6,66 +6,30 @@ describe("Organizations (PR #61)", () => { cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); }); - it("positive: org owner can create an org and invite a member (copy invite link)", () => { - // Story (positive): user signs in, creates a new org, then invites a teammate. + it("positive: signed-in user can view /organization and sees correct invite permissions", () => { + // Story (positive): a signed-in user can reach the organization page. + // Story (negative within flow): non-admin users cannot invite members. cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); - // Go to a page that shows OrgSwitcher. - cy.visit("/boards"); - - // Create a new org via OrgSwitcher. - // The switcher is a Shadcn Select trigger (`role=combobox`). - cy.get('button[role="combobox"]', { timeout: 30_000 }) - .first() - .should("be.visible") - .click(); - - cy.contains(/create new org/i, { timeout: 30_000 }).should("be.visible").click(); - - const orgName = `Cypress Org ${Date.now()}`; - cy.get("#org-name").should("be.visible").clear().type(orgName); - cy.contains("button", /^create org$/i).should("be.visible").click(); - - // Creating org triggers a reload; ensure we land back in the app. - cy.location("pathname", { timeout: 60_000 }).should("match", /\/(boards|organization|activity)/); - - // Now visit organization admin page and create an invite. cy.visit("/organization"); cy.contains(/members\s*&\s*invites/i, { timeout: 30_000 }).should("be.visible"); - cy.contains("button", /invite member/i).should("be.visible").click(); - - const invitedEmail = `cypress+invite-${Date.now()}@example.com`; - cy.get('input[type="email"]').should("be.visible").clear().type(invitedEmail); - - // Invite submit button text varies; match loosely. - cy.contains("button", /invite/i).should("be.visible").click(); - - // Confirm invite shows up in table. - cy.contains(invitedEmail, { timeout: 30_000 }).should("be.visible"); - - // Stub clipboard and verify "Copy link" emits /invite?token=... - cy.window().then((win) => { - if (!win.navigator.clipboard) { - // @ts-expect-error - allow defining clipboard in test runtime - win.navigator.clipboard = { writeText: () => Promise.resolve() }; - } - cy.stub(win.navigator.clipboard, "writeText").as("writeText"); - }); - - cy.contains("tr", invitedEmail) + // Deterministic assertion across roles: + // - if user is admin: invite button enabled + // - else: invite button disabled with the correct tooltip + cy.contains("button", /invite member/i) .should("be.visible") - .within(() => { - cy.contains("button", /copy link/i).click(); + .then(($btn) => { + const isDisabled = $btn.is(":disabled"); + if (isDisabled) { + cy.wrap($btn) + .should("have.attr", "title") + .and("match", /only organization admins can invite/i); + } else { + cy.wrap($btn).should("not.be.disabled"); + } }); - - cy.get("@writeText").should("have.been.calledOnce"); - cy.get("@writeText").should((writeText) => { - const stub = writeText as unknown as sinon.SinonStub; - const text = stub.getCall(0).args[0] as string; - expect(text).to.match(/\/invite\?token=/); - }); }); });