diff --git a/.idea/.idea.LexBox/.idea/sqldialects.xml b/.idea/.idea.LexBox/.idea/sqldialects.xml deleted file mode 100644 index 6df4889b0..000000000 --- a/.idea/.idea.LexBox/.idea/sqldialects.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/frontend/tests/email/e2e-mailbox.ts b/frontend/tests/email/e2e-mailbox.ts index dd8364960..f9c3ecde6 100644 --- a/frontend/tests/email/e2e-mailbox.ts +++ b/frontend/tests/email/e2e-mailbox.ts @@ -12,7 +12,7 @@ export class E2EMailbox extends Mailbox { super(email); } - async fetchEmails(subject: EmailSubjects): Promise { + async fetchEmails(subject: EmailSubjects | string): Promise { const emails = await this.e2eMailboxApi.fetchEmailList() return emails.filter(email => email.mail_subject.includes(subject)) .map(email => ({body: email.mail_body})); diff --git a/frontend/tests/email/email-page.ts b/frontend/tests/email/email-page.ts index bd0dac960..03ec295f4 100644 --- a/frontend/tests/email/email-page.ts +++ b/frontend/tests/email/email-page.ts @@ -7,6 +7,7 @@ export enum EmailSubjects { ForgotPassword = 'Forgot your password?', PasswordChanged = 'Your password was changed', ProjectInvitation = 'Project invitation:', + ProjectJoinRequest = 'Project join request', } export class EmailPage extends BasePage { @@ -22,6 +23,10 @@ export class EmailPage extends BasePage { return this.bodyLocator.getByRole('link', {name: 'Verify e-mail'}).click(); } + clickApproveRequest(): Promise { + return this.bodyLocator.getByRole('link', {name: 'Approve request'}).click(); + } + clickResetPassword(): Promise { return this.resetPasswordButton.click(); } diff --git a/frontend/tests/email/mailbox.ts b/frontend/tests/email/mailbox.ts index f256fa9ed..8c51265f2 100644 --- a/frontend/tests/email/mailbox.ts +++ b/frontend/tests/email/mailbox.ts @@ -9,9 +9,9 @@ export interface Email { export abstract class Mailbox { constructor(readonly email: string) { } - abstract fetchEmails(subject: EmailSubjects): Promise; + abstract fetchEmails(subject: EmailSubjects | string): Promise; - async openEmail(page: Page, subject: EmailSubjects, index: number = 0): Promise { + async openEmail(page: Page, subject: EmailSubjects | string, index: number = 0): Promise { let email: Email | undefined = undefined; await expect.poll(async () => { diff --git a/frontend/tests/email/maildev-mailbox.ts b/frontend/tests/email/maildev-mailbox.ts index c48650d75..a98858d62 100644 --- a/frontend/tests/email/maildev-mailbox.ts +++ b/frontend/tests/email/maildev-mailbox.ts @@ -14,7 +14,7 @@ export class MaildevMailbox extends Mailbox { super(email); } - async fetchEmails(subject: EmailSubjects): Promise { + async fetchEmails(subject: EmailSubjects | string): Promise { const emails = await this.fetchMyEmails(); return emails.filter(email => email.subject.includes(subject)) .map(email => ({body: email.html})); diff --git a/frontend/tests/emailWorkflow.test.ts b/frontend/tests/emailWorkflow.test.ts index 7b735d3b0..f8ea2b015 100644 --- a/frontend/tests/emailWorkflow.test.ts +++ b/frontend/tests/emailWorkflow.test.ts @@ -1,6 +1,5 @@ -import {TEST_TIMEOUT_2X, defaultPassword} from './envVars'; -import {deleteUser, getCurrentUserId, loginAs, logout} from './utils/authHelpers'; - +import {TEST_TIMEOUT_2X, defaultPassword, elawaProjectId, testOrgId} from './envVars'; +import {addUserToProject, deleteUser, getCurrentUserId, loginAs, logout, verifyTempUserEmail} from './utils/authHelpers'; import {AcceptInvitationPage} from './pages/acceptInvitationPage'; import {AdminDashboardPage} from './pages/adminDashboardPage'; import {EmailSubjects} from './email/email-page'; @@ -11,6 +10,8 @@ import {UserDashboardPage} from './pages/userDashboardPage'; import {expect} from '@playwright/test'; import {randomUUID} from 'crypto'; import {test} from './fixtures'; +import {ProjectPage} from './pages/projectPage'; +import {OrgPage} from './pages/orgPage'; const userIdsToDelete: string[] = []; @@ -152,3 +153,98 @@ test('register via new-user invitation email', async ({ page, mailboxFactory }) // Should be able to open sena-3 project from user dashboard as we are now a member await userDashboardPage.openProject('Sena 3', 'sena-3'); }); + +test('ask to join project via new-project page', async ({ page, tempUser, tempUserInTestOrg }) => { + test.setTimeout(TEST_TIMEOUT_2X); + + // First, set up new user to be manager of Elawa project (since it doesn't have one in default seed data) + const manager = tempUser; + await loginAs(page.request, manager.email, manager.password); + let dashboardPage = await new UserDashboardPage(page).goto(); + + // Must verify email before being made manager of a project + let newPage = await verifyTempUserEmail(page, manager); + + // Add manager to Elawa project + await loginAs(newPage.request, 'admin'); + await addUserToProject(newPage.request, manager.id, elawaProjectId, 'MANAGER'); + + const { name, email, password } = tempUserInTestOrg; + + await loginAs(page.request, email, password); + dashboardPage = await new UserDashboardPage(page).goto(); + + // Must verify email before being allowed to request project creation + newPage = await verifyTempUserEmail(page, tempUserInTestOrg); + dashboardPage = await new UserDashboardPage(newPage).goto(); + + // Create project with similar name to Elawa + const newProjectPage = await dashboardPage.clickCreateProject(); + await newProjectPage.fillForm({name: 'Elaw', code: 'xyz', purpose: 'Testing', organization: 'Test Org'}); + await expect(newProjectPage.extraProjectsDiv).toBeVisible(); + await expect(newProjectPage.askToJoinBtn).toBeDisabled(); + await newProjectPage.selectExtraProject('elawa-dev-flex'); + await expect(newProjectPage.askToJoinBtn).toBeEnabled(); + await newProjectPage.askToJoinBtn.click(); + await expect(newProjectPage.toast('has been sent to the project manager(s)')).toBeVisible(); + + // Log in as manager, approve join request. + await loginAs(page.request, manager.email, manager.password); + const emailPage = await manager.mailbox.openEmail(page, `${EmailSubjects.ProjectJoinRequest}: ${name}`); + const pagePromise = emailPage.page.context().waitForEvent('page'); + await emailPage.clickApproveRequest(); + newPage = await pagePromise; + const elawaProjectPage = await new ProjectPage(newPage, 'Elawa', 'elawa-dev-flex').waitFor(); + await elawaProjectPage.modal.getByRole('button', {name: 'Add Member'}).click(); + await elawaProjectPage.goto(); + + // Log in as temp user, should see Elawa project + await loginAs(page.request, email, password); + dashboardPage = await new UserDashboardPage(page).goto(); + await dashboardPage.openProject('Elawa', 'elawa-dev-flex'); +}); + +test('ask to join project via project page', async ({ page, tempUser, tempUserInTestOrg }) => { + test.setTimeout(TEST_TIMEOUT_2X); + + // First, set up new user to be manager of Elawa project (since it doesn't have one in default seed data) + const manager = tempUser; + await loginAs(page.request, manager.email, manager.password); + let dashboardPage = await new UserDashboardPage(page).goto(); + + // Must verify email before being made manager of a project + let newPage = await verifyTempUserEmail(page, manager); + + // Add manager to Elawa project + await loginAs(newPage.request, 'admin'); + await addUserToProject(newPage.request, manager.id, elawaProjectId, 'MANAGER'); + + const { name, email, password } = tempUserInTestOrg; + + await loginAs(page.request, email, password); + dashboardPage = await new UserDashboardPage(page).goto(); + + // Must verify email before being allowed to request project creation + newPage = await verifyTempUserEmail(page, tempUserInTestOrg); + + // Get to Elawa project page via org page, then ask to join + const testOrgPage = await new OrgPage(newPage, 'Test Org', testOrgId).goto(); + await testOrgPage.projectsTab.click(); + const projectPage = await testOrgPage.openProject('Elawa', 'elawa-dev-flex'); + await projectPage.askToJoinButton.click(); + + // Log in as manager, approve join request. + await loginAs(page.request, manager.email, manager.password); + const emailPage = await manager.mailbox.openEmail(page, `${EmailSubjects.ProjectJoinRequest}: ${name}`); + const pagePromise = emailPage.page.context().waitForEvent('page'); + await emailPage.clickApproveRequest(); + newPage = await pagePromise; + const elawaProjectPage = await new ProjectPage(newPage, 'Elawa', 'elawa-dev-flex').waitFor(); + await elawaProjectPage.modal.getByRole('button', {name: 'Add Member'}).click(); + await elawaProjectPage.goto(); + + // Log in as temp user, should see Elawa project + await loginAs(page.request, email, password); + dashboardPage = await new UserDashboardPage(page).goto(); + await dashboardPage.openProject('Elawa', 'elawa-dev-flex'); +}); diff --git a/frontend/tests/envVars.ts b/frontend/tests/envVars.ts index 378bb0d28..bcc9df6ea 100644 --- a/frontend/tests/envVars.ts +++ b/frontend/tests/envVars.ts @@ -3,6 +3,9 @@ export const isDev = process.env.NODE_ENV === 'development' || serverHostname.st export const httpScheme = isDev ? 'http://' : 'https://'; export const serverBaseUrl = `${httpScheme}${serverHostname}`; export const defaultPassword = process.env.TEST_DEFAULT_PASSWORD ?? 'pass'; +export const testOrgId = process.env.TEST_ORG_ID ?? '292c80e6-a815-4cd1-9ea2-34bd01274de6'; +export const elawaProjectId = process.env.ELAWA_PROJECT_ID ?? '9e972940-8a8e-4b29-a609-bdc2f93b3507'; + export const authCookieName = '.LexBoxAuth'; export const invalidJwt = 'eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTY5OTM0ODY2NywiaWF0IjoxNjk5MzQ4NjY3fQ.f8N63gcD_iv-E_x0ERhJwARaBKnZnORaZGe0N2J0VGM'; diff --git a/frontend/tests/fixtures.ts b/frontend/tests/fixtures.ts index 9221c4b26..3019d6327 100644 --- a/frontend/tests/fixtures.ts +++ b/frontend/tests/fixtures.ts @@ -1,7 +1,7 @@ import {test as base, expect, type BrowserContext, type BrowserContextOptions} from '@playwright/test'; import * as testEnv from './envVars'; import {type UUID, randomUUID} from 'crypto'; -import {deleteUser, loginAs, registerUser} from './utils/authHelpers'; +import {addUserToOrg, deleteUser, loginAs, registerUser} from './utils/authHelpers'; import {executeGql, type GqlResult} from './utils/gqlHelpers'; import {mkdtemp, rm} from 'fs/promises'; import {join} from 'path'; @@ -32,6 +32,7 @@ type Fixtures = { contextFactory: (options: BrowserContextOptions) => Promise, uniqueTestId: string, tempUser: Readonly, + tempUserInTestOrg: Readonly, tempProject: TempProject, tempDir: string, mailboxFactory: () => Promise, @@ -96,7 +97,7 @@ export const test = base.extend({ tempUser: async ({browser, page, mailboxFactory}, use, testInfo) => { const mailbox = await mailboxFactory(); const email = mailbox.email; - const name = `Test: ${testInfo.title} - ${email}`; + const name = `Test: ${testInfo.title} - ${email.replaceAll('@', '(at)')}`; const password = email; const tempUserId = await registerUser(page, name, email, password); const tempUser = Object.freeze({ @@ -112,6 +113,28 @@ export const test = base.extend({ await deleteUser(context.request, tempUser.id); await context.close(); }, + tempUserInTestOrg: async ({browser, page, mailboxFactory}, use, testInfo) => { + const mailbox = await mailboxFactory(); + const email = mailbox.email; + const name = `Test: ${testInfo.title} - ${email.replaceAll('@', '(at)')}`; + const password = email; + const tempUserId = await registerUser(page, name, email, password); + await loginAs(page.request, 'admin'); + await addUserToOrg(page.request, tempUserId, testEnv.testOrgId, 'USER'); + await loginAs(page.request, email, password); + const tempUser = Object.freeze({ + id: tempUserId, + name, + email, + password, + mailbox, + }); + await use(tempUser); + const context = await browser.newContext(); + await loginAs(context.request, 'admin'); + await deleteUser(context.request, tempUser.id); + await context.close(); + }, tempProject: async ({ page, uniqueTestId }, use, testInfo) => { const titleForCode = testInfo.title diff --git a/frontend/tests/pages/basePage.ts b/frontend/tests/pages/basePage.ts index e75806460..23b58018d 100644 --- a/frontend/tests/pages/basePage.ts +++ b/frontend/tests/pages/basePage.ts @@ -13,6 +13,11 @@ export class BasePage { return undefined; } + get toastDiv(): Locator { return this.page.locator('.toast'); } + toast(text: string|RegExp, options?: {exact: boolean}): Locator { + return this.toastDiv.getByText(text, options); + } + get urlPattern(): RegExp | undefined { if (!this.url) return undefined; return new RegExp(regexEscape(this.url) + '($|\\?|#)'); diff --git a/frontend/tests/pages/createProjectPage.ts b/frontend/tests/pages/createProjectPage.ts index 97054bfec..614221095 100644 --- a/frontend/tests/pages/createProjectPage.ts +++ b/frontend/tests/pages/createProjectPage.ts @@ -1,10 +1,11 @@ import {BasePage} from './basePage'; -import type {Page} from '@playwright/test'; +import type {Locator, Page} from '@playwright/test'; type ProjectConfig = { code: string; customCode: boolean; name: string; + organization: string; type: string; purpose: 'Software Developer' | 'Testing' | 'Training' | 'Language Project'; description: string; @@ -14,12 +15,15 @@ export class CreateProjectPage extends BasePage { constructor(page: Page) { super(page, page.getByRole('heading', { name: /(Create|Request) Project/ }), `/project/create`); } + get extraProjectsDiv(): Locator { return this.page.locator('#group-extra-projects'); } + get askToJoinBtn(): Locator { return this.page.getByRole('button', {name: 'Ask to join', exact: true}); } async fillForm(values: Pick & Partial): Promise { let code = values.code; - const { customCode = false, name = code, type = 'FLEx', purpose = 'Software Developer', description = name } = values; + const { customCode = false, name = code, type = 'FLEx', purpose = 'Software Developer', description = name, organization = '' } = values; await this.page.getByLabel('Name').fill(name); await this.page.getByLabel('Description').fill(description ?? name); + if (organization) await this.page.getByLabel('Organization').selectOption({ label: organization }) await this.page.getByLabel('Project type').selectOption({ label: type }); await this.page.getByLabel('Purpose').selectOption({ label: purpose }); await this.page.getByLabel('Language Code').fill(code); @@ -29,7 +33,11 @@ export class CreateProjectPage extends BasePage { } else { code = await this.page.getByLabel('Code', {exact: true}).inputValue(); } - return { code, name, type, purpose, description, customCode }; + return { code, name, organization, type, purpose, description, customCode }; + } + + async selectExtraProject(projectCode: string): Promise { + await this.extraProjectsDiv.locator(`#extra-projects-${projectCode}`).check(); } async submit(): Promise { diff --git a/frontend/tests/pages/orgPage.ts b/frontend/tests/pages/orgPage.ts new file mode 100644 index 000000000..21938a9c8 --- /dev/null +++ b/frontend/tests/pages/orgPage.ts @@ -0,0 +1,30 @@ +import type {Locator, Page} from '@playwright/test'; + +import {BasePage} from './basePage'; +import {ProjectPage} from './projectPage'; + +export class OrgPage extends BasePage { + get moreSettingsDiv(): Locator { return this.page.locator('.collapse').filter({ hasText: 'More settings' }); } + get deleteProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Delete project'}); } + + get projectsTab(): Locator { return this.page.getByRole('tab', { name: 'Projects' }); } + get membersTab(): Locator { return this.page.getByRole('tab', { name: 'Members' }); } + get settingsTab(): Locator { return this.page.getByRole('tab', { name: 'Settings' }); } + get historyTab(): Locator { return this.page.getByRole('tab', { name: 'History' }); } + + projectLink(projectName: string): Locator { return this.page.getByRole('link', {name: projectName, exact: true}); } + + constructor(page: Page, private name: string, private orgId: string) { + super(page, page.getByRole('heading', {name: `Organization: ${name}`}), `/org/${orgId}`); + } + + async clickProject(projectName: string): Promise { + await this.projectsTab.click(); + return this.projectLink(projectName).click(); + } + + async openProject(projectName: string, projectCode: string): Promise { + await this.clickProject(projectName); + return await new ProjectPage(this.page, projectName, projectCode).waitFor(); + } +} diff --git a/frontend/tests/pages/projectPage.ts b/frontend/tests/pages/projectPage.ts index 2e38f4b59..47018f1c9 100644 --- a/frontend/tests/pages/projectPage.ts +++ b/frontend/tests/pages/projectPage.ts @@ -12,7 +12,9 @@ export class ProjectPage extends BasePage { get resetProjectButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Reset project'}); } get verifyRepoButton(): Locator { return this.moreSettingsDiv.getByRole('button', {name: 'Verify repository'}); } get addMemberButton(): Locator { return this.page.getByRole('button', {name: 'Add/Invite Member'}); } + get askToJoinButton(): Locator { return this.page.getByRole('button', {name: 'Ask to join'}); } get browseButton(): Locator { return this.page.getByRole('link', {name: 'Browse'}); } + get modal(): Locator { return this.page.locator('.modal-box'); } constructor(page: Page, private name: string, private code: string) { super(page, page.getByRole('heading', {name: `Project: ${name}`}), `/project/${code}`); diff --git a/frontend/tests/utils/authHelpers.ts b/frontend/tests/utils/authHelpers.ts index b51d5c0ee..c408f5424 100644 --- a/frontend/tests/utils/authHelpers.ts +++ b/frontend/tests/utils/authHelpers.ts @@ -5,6 +5,8 @@ import {UserDashboardPage} from '../pages/userDashboardPage'; import type {UUID} from 'crypto'; import {executeGql} from './gqlHelpers'; import {LoginPage} from '../pages/loginPage'; +import type {TempUser} from '../fixtures'; +import {EmailSubjects} from '../email/email-page'; export async function loginAs(api: APIRequestContext, emailOrUsername: string, password: string = defaultPassword): Promise { const loginData = { @@ -37,6 +39,49 @@ export async function registerUser(page: Page, name: string, email: string, pass return userId; } +// Verify email address; returns a Page promise that should be used to load a UserDashboardPage or AdminDashboardPage +// Does not call loginAs(page.request, tempUser.email, tempUser.password); calling code should be doing that +export async function verifyTempUserEmail(page: Page, tempUser: TempUser): Promise { + const emailPage = await tempUser.mailbox.openEmail(page, EmailSubjects.VerifyEmail); + const pagePromise = emailPage.page.context().waitForEvent('page'); + await emailPage.clickVerifyEmail(); + return pagePromise; +} + +export async function addUserToOrg(api: APIRequestContext, userId: string, orgId: string, role: 'ADMIN' | 'USER' | 'UNKNOWN'): Promise { + return executeGql(api, ` + mutation { + changeOrgMemberRole(input: { userId: "${userId}", orgId: "${orgId}", role: ${role} }) { + organization { + id + } + errors { + ... on Error { + message + } + } + } + } + `); +} + +export async function addUserToProject(api: APIRequestContext, userId: string, projectId: string, role: 'EDITOR' | 'MANAGER' | 'UNKNOWN'): Promise { + return executeGql(api, ` + mutation { + addProjectMember(input: { userId: "${userId}", projectId: "${projectId}", role: ${role}, canInvite: false }) { + project { + id + } + errors { + ... on Error { + message + } + } + } + } + `); +} + export async function deleteUser(api: APIRequestContext, userId: string): Promise { return executeGql(api, ` mutation {