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 {