Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for requesting to join project #1410

Merged
merged 13 commits into from
Jan 31, 2025
6 changes: 0 additions & 6 deletions .idea/.idea.LexBox/.idea/sqldialects.xml

This file was deleted.

2 changes: 1 addition & 1 deletion frontend/tests/email/e2e-mailbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class E2EMailbox extends Mailbox {
super(email);
}

async fetchEmails(subject: EmailSubjects): Promise<Email[]> {
async fetchEmails(subject: EmailSubjects | string): Promise<Email[]> {
const emails = await this.e2eMailboxApi.fetchEmailList()
return emails.filter(email => email.mail_subject.includes(subject))
.map(email => ({body: email.mail_body}));
Expand Down
5 changes: 5 additions & 0 deletions frontend/tests/email/email-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,6 +23,10 @@ export class EmailPage extends BasePage {
return this.bodyLocator.getByRole('link', {name: 'Verify e-mail'}).click();
}

clickApproveRequest(): Promise<void> {
return this.bodyLocator.getByRole('link', {name: 'Approve request'}).click();
}

clickResetPassword(): Promise<void> {
return this.resetPasswordButton.click();
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/tests/email/mailbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ export interface Email {
export abstract class Mailbox {
constructor(readonly email: string) { }

abstract fetchEmails(subject: EmailSubjects): Promise<Email[]>;
abstract fetchEmails(subject: EmailSubjects | string): Promise<Email[]>;

async openEmail(page: Page, subject: EmailSubjects, index: number = 0): Promise<EmailPage> {
async openEmail(page: Page, subject: EmailSubjects | string, index: number = 0): Promise<EmailPage> {
let email: Email | undefined = undefined;

await expect.poll(async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/email/maildev-mailbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
super(email);
}

async fetchEmails(subject: EmailSubjects): Promise<Email[]> {
async fetchEmails(subject: EmailSubjects | string): Promise<Email[]> {
const emails = await this.fetchMyEmails();
return emails.filter(email => email.subject.includes(subject))
.map(email => ({body: email.html}));
Expand All @@ -22,7 +22,7 @@

async fetchMyEmails(): Promise<MaildevEmail[]> {
// Maildev REST API docs: https://github.com/maildev/maildev/blob/master/docs/rest.md
const response = await this.api.get(`http://localhost:1080/email`);

Check failure on line 25 in frontend/tests/email/maildev-mailbox.ts

View workflow job for this annotation

GitHub Actions / GHA integration tests / execute

[firefox] › emailWorkflow.test.ts:75:1 › forgot password

1) [firefox] › emailWorkflow.test.ts:75:1 › forgot password ────────────────────────────────────── Error: apiRequestContext.get: socket hang up Call log: - → GET http://localhost:1080/email - user-agent: Playwright Firefox - accept: */* - accept-encoding: gzip,deflate,br at email/maildev-mailbox.ts:25 23 | async fetchMyEmails(): Promise<MaildevEmail[]> { 24 | // Maildev REST API docs: https://github.com/maildev/maildev/blob/master/docs/rest.md > 25 | const response = await this.api.get(`http://localhost:1080/email`); | ^ 26 | const emails = await response.json() as MaildevEmail[]; 27 | return emails.filter(email => email.to.some(to => to.address === this.email)); 28 | } at MaildevMailbox.fetchMyEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:25:37) at MaildevMailbox.fetchEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:18:31) at Object.expect.poll.intervals (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/mailbox.ts:18:33)

Check failure on line 25 in frontend/tests/email/maildev-mailbox.ts

View workflow job for this annotation

GitHub Actions / GHA integration tests / execute

[firefox] › emailWorkflow.test.ts:111:1 › register via new-user invitation email

2) [firefox] › emailWorkflow.test.ts:111:1 › register via new-user invitation email ────────────── Error: apiRequestContext.get: socket hang up Call log: - → GET http://localhost:1080/email - user-agent: Playwright Firefox - accept: */* - accept-encoding: gzip,deflate,br - cookie: .LexBoxAuth=*** at email/maildev-mailbox.ts:25 23 | async fetchMyEmails(): Promise<MaildevEmail[]> { 24 | // Maildev REST API docs: https://github.com/maildev/maildev/blob/master/docs/rest.md > 25 | const response = await this.api.get(`http://localhost:1080/email`); | ^ 26 | const emails = await response.json() as MaildevEmail[]; 27 | return emails.filter(email => email.to.some(to => to.address === this.email)); 28 | } at MaildevMailbox.fetchMyEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:25:37) at MaildevMailbox.fetchEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:18:31) at Object.expect.poll.intervals (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/mailbox.ts:18:33)

Check failure on line 25 in frontend/tests/email/maildev-mailbox.ts

View workflow job for this annotation

GitHub Actions / GHA integration tests / execute

[firefox] › emailWorkflow.test.ts:157:1 › ask to join project via new-project page

3) [firefox] › emailWorkflow.test.ts:157:1 › ask to join project via new-project page ──────────── Error: apiRequestContext.get: socket hang up Call log: - → GET http://localhost:1080/email - user-agent: Playwright Firefox - accept: */* - accept-encoding: gzip,deflate,br - cookie: .LexBoxAuth=*** at email/maildev-mailbox.ts:25 23 | async fetchMyEmails(): Promise<MaildevEmail[]> { 24 | // Maildev REST API docs: https://github.com/maildev/maildev/blob/master/docs/rest.md > 25 | const response = await this.api.get(`http://localhost:1080/email`); | ^ 26 | const emails = await response.json() as MaildevEmail[]; 27 | return emails.filter(email => email.to.some(to => to.address === this.email)); 28 | } at MaildevMailbox.fetchMyEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:25:37) at MaildevMailbox.fetchEmails (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/maildev-mailbox.ts:18:31) at Object.expect.poll.intervals (/home/runner/work/languageforge-lexbox/languageforge-lexbox/frontend/tests/email/mailbox.ts:18:33)
const emails = await response.json() as MaildevEmail[];
return emails.filter(email => email.to.some(to => to.address === this.email));
}
Expand Down
102 changes: 99 additions & 3 deletions frontend/tests/emailWorkflow.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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[] = [];

Expand Down Expand Up @@ -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');
});
3 changes: 3 additions & 0 deletions frontend/tests/envVars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
27 changes: 25 additions & 2 deletions frontend/tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +32,7 @@ type Fixtures = {
contextFactory: (options: BrowserContextOptions) => Promise<BrowserContext>,
uniqueTestId: string,
tempUser: Readonly<TempUser>,
tempUserInTestOrg: Readonly<TempUser>,
tempProject: TempProject,
tempDir: string,
mailboxFactory: () => Promise<Mailbox>,
Expand Down Expand Up @@ -96,7 +97,7 @@ export const test = base.extend<Fixtures>({
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({
Expand All @@ -112,6 +113,28 @@ export const test = base.extend<Fixtures>({
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);
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
5 changes: 5 additions & 0 deletions frontend/tests/pages/basePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) + '($|\\?|#)');
Expand Down
14 changes: 11 additions & 3 deletions frontend/tests/pages/createProjectPage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ProjectConfig, 'code'> & Partial<ProjectConfig>): Promise<ProjectConfig> {
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);
Expand All @@ -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<void> {
await this.extraProjectsDiv.locator(`#extra-projects-${projectCode}`).check();
}

async submit(): Promise<void> {
Expand Down
30 changes: 30 additions & 0 deletions frontend/tests/pages/orgPage.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await this.projectsTab.click();
return this.projectLink(projectName).click();
}

async openProject(projectName: string, projectCode: string): Promise<ProjectPage> {
await this.clickProject(projectName);
return await new ProjectPage(this.page, projectName, projectCode).waitFor();
}
}
2 changes: 2 additions & 0 deletions frontend/tests/pages/projectPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
45 changes: 45 additions & 0 deletions frontend/tests/utils/authHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const loginData = {
Expand Down Expand Up @@ -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<Page> {
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<unknown> {
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<unknown> {
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<unknown> {
return executeGql(api, `
mutation {
Expand Down
Loading