From 62533185fbbaa7371b7ac3e466bec47d7009699f Mon Sep 17 00:00:00 2001 From: nikithaguduru Date: Mon, 20 Jul 2026 23:52:09 +0530 Subject: [PATCH 1/2] Added test scenarios for ocom verification coverage for staff user management --- .../notes/staff-user-management-notes.ts | 6 + .../contexts/staff/step-definitions/index.ts | 1 + .../staff-user-management.steps.ts | 131 ++++++++++++++++++ .../staff/tasks/provision-staff-user.ts | 21 +++ .../staff/tasks/record-access-result.ts | 18 +++ .../staff/tasks/update-staff-user-role.ts | 21 +++ .../staff/tasks/view-staff-user-details.ts | 21 +++ .../staff/tasks/view-staff-users-list.ts | 18 +++ .../notes/staff-user-management-notes.ts | 5 + .../contexts/staff/step-definitions/index.ts | 1 + .../staff-user-management.steps.tsx | 121 ++++++++++++++++ .../staff/tasks/provision-staff-user.ts | 21 +++ .../staff/tasks/record-access-result.ts | 18 +++ .../contexts/staff/tasks/staff-user-screen.ts | 50 +++++++ .../staff/tasks/update-staff-user-role.ts | 21 +++ .../staff/tasks/view-staff-user-details.ts | 28 ++++ .../staff/tasks/view-staff-users-list.ts | 22 +++ .../notes/staff-user-management-notes.ts | 5 + .../contexts/staff/step-definitions/index.ts | 1 + .../staff-user-management.steps.ts | 121 ++++++++++++++++ .../staff/tasks/provision-staff-user.ts | 21 +++ .../staff/tasks/record-access-result.ts | 18 +++ .../staff/tasks/update-staff-user-role.ts | 21 +++ .../staff/tasks/view-staff-user-details.ts | 21 +++ .../staff/tasks/view-staff-users-list.ts | 18 +++ .../verification-shared/src/pages/index.ts | 2 + .../src/pages/staff-user-detail.page.ts | 34 +++++ .../src/pages/staff-user-page-object.test.ts | 78 +++++++++++ .../src/pages/staff-users-list.page.ts | 46 ++++++ .../staff/staff-user-management.feature | 45 ++++++ 30 files changed, 955 insertions(+) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/notes/staff-user-management-notes.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/record-access-result.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-users-list.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/notes/staff-user-management-notes.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/record-access-result.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/notes/staff-user-management-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-user-management.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/record-access-result.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-users-list.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/staff/staff-user-management.feature diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/notes/staff-user-management-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/notes/staff-user-management-notes.ts new file mode 100644 index 000000000..4e9d5c798 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/notes/staff-user-management-notes.ts @@ -0,0 +1,6 @@ +export interface StaffUserManagementApiNotes { + staffUserName?: string; + role?: string; + result?: string; + activityLog?: string[]; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts index 954f3a337..c71ec224d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/index.ts @@ -1 +1,2 @@ import './staff-landing.steps.ts'; +import './staff-user-management.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts new file mode 100644 index 000000000..6554e7802 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts @@ -0,0 +1,131 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; +import { ProvisionStaffUser } from '../tasks/provision-staff-user.ts'; +import { RecordAccessResult } from '../tasks/record-access-result.ts'; +import { UpdateStaffUserRole } from '../tasks/update-staff-user-role.ts'; +import { ViewStaffUserDetails } from '../tasks/view-staff-user-details.ts'; +import { ViewStaffUsersList } from '../tasks/view-staff-users-list.ts'; + +const userState = new Map(); +let currentActorName = ''; + +const ensureUser = (name: string, role: string) => { + if (!userState.has(name)) { + userState.set(name, { role, activityLog: [] }); + } +}; + +Given('{word} is an authenticated staff administrator', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('allowed')); +}); + +Given('{word} is an authenticated restricted staff user', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('forbidden')); +}); + +Given('the staff user {string} exists with role {string}', async (_name: string, role: string) => { + ensureUser(_name, role); +}); + +Given('Alice is the current staff user', async () => { + ensureUser('Alice', 'finance'); +}); + +When('{word} provisions the staff user {string} with default role {string}', async (actorName: string, userName: string, defaultRole: string) => { + const actor = actorCalled(actorName); + ensureUser(userName, defaultRole); + await actor.attemptsTo(ProvisionStaffUser.withDefaults(userName, defaultRole)); +}); + +When('{word} updates the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + if (actorName === userName) { + await actor.attemptsTo(RecordAccessResult.withResult('self-role-change-not-allowed')); + return; + } + state.role = nextRole; + state.activityLog.push(`role assigned: ${nextRole}`); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole)); +}); + +When('{word} attempts to update the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo( + UpdateStaffUserRole.forUser(userName, nextRole), + RecordAccessResult.withResult('forbidden'), + ); +}); + +When('{word} views staff users', async (actorName: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +When('{word} views the details for {string}', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(userName)); +}); + +Then('the staff user {string} should be created with role {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('the role of {string} should be {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('Alice should be blocked with {string}', async (_expectedResult: string) => { + const actor = actorCalled(currentActorName || 'Alice'); + const result = await actor.answer(notes().get('result')); + if (result !== _expectedResult) { + throw new Error(`Expected result ${_expectedResult}, got ${result}`); + } +}); + +Then('the activity log for {string} should include {string}', async (userName: string, expectedEntry: string) => { + const state = userState.get(userName); + if (!state || !state.activityLog.includes(expectedEntry)) { + throw new Error(`Expected ${userName} activity log to include ${expectedEntry}`); + } +}); + +Then('{word} should see {string} in the staff users list', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + const state = userState.get(userName); + if (!state) { + throw new Error(`Expected ${userName} to be present in the staff users list`); + } + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +Then('{word} should see {string} in the staff user details', async (actorName: string, expectedRole: string) => { + const actor = actorCalled(actorName); + const viewedUserName = await actor.answer(notes().get('staffUserName')); + const viewedRole = await actor.answer(notes().get('role')); + if (!viewedUserName || !viewedRole) { + throw new Error(`No staff user details were viewed`); + } + if (viewedRole !== expectedRole) { + throw new Error(`Expected staff user details to show role ${expectedRole}, but got ${viewedRole}`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(viewedUserName)); +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts new file mode 100644 index 000000000..d4c1ff799 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ProvisionStaffUser extends Task { + static withDefaults(userName: string, role: string) { + return new ProvisionStaffUser(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`provisions staff user "${userName}" with role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/record-access-result.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/record-access-result.ts new file mode 100644 index 000000000..89e4bf2e4 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/record-access-result.ts @@ -0,0 +1,18 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; + +export class RecordAccessResult extends Task { + static withResult(result: string) { + return new RecordAccessResult(result); + } + + private constructor(private readonly result: string) { + super(`records access result "${result}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(notes().set('result', this.result)); + } + + override toString = () => `records access result "${this.result}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts new file mode 100644 index 000000000..97bfcdc7c --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; + +export class UpdateStaffUserRole extends Task { + static forUser(userName: string, role: string) { + return new UpdateStaffUserRole(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`updates staff user "${userName}" to role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts new file mode 100644 index 000000000..dc21b236b --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUserDetails extends Task { + static forUser(userName: string) { + return new ViewStaffUserDetails(userName); + } + + private constructor(private readonly userName: string) { + super(`views details for staff user "${userName}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('result', 'details-visible'), + ); + } + + override toString = () => `views details for staff user "${this.userName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-users-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-users-list.ts new file mode 100644 index 000000000..836a2f59d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-users-list.ts @@ -0,0 +1,18 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementApiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUsersList extends Task { + static forCurrentActor() { + return new ViewStaffUsersList(); + } + + private constructor() { + super('views staff users list'); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(notes().set('result', 'list-visible')); + } + + override toString = () => 'views staff users list'; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/notes/staff-user-management-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/notes/staff-user-management-notes.ts new file mode 100644 index 000000000..3cb984b4b --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/notes/staff-user-management-notes.ts @@ -0,0 +1,5 @@ +export interface StaffUserManagementUiNotes { + staffUserName?: string; + role?: string; + result?: string; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts index 8b998c9ea..94b25ec05 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/index.ts @@ -1,2 +1,3 @@ // Staff context step definitions import './create-staff-landing.steps.ts'; +import './staff-user-management.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx new file mode 100644 index 000000000..75b9c219a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx @@ -0,0 +1,121 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; +import { ProvisionStaffUser } from '../tasks/provision-staff-user.ts'; +import { RecordAccessResult } from '../tasks/record-access-result.ts'; +import { UpdateStaffUserRole } from '../tasks/update-staff-user-role.ts'; +import { ViewStaffUserDetails } from '../tasks/view-staff-user-details.ts'; +import { ViewStaffUsersList } from '../tasks/view-staff-users-list.ts'; + +const userState = new Map(); +let currentActorName = ''; + +Given('{word} is an authenticated staff administrator', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('allowed')); +}); + +Given('{word} is an authenticated restricted staff user', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('forbidden')); +}); + +Given('the staff user {string} exists with role {string}', async (userName: string, role: string) => { + userState.set(userName, { role, activityLog: [] }); +}); + +Given('Alice is the current staff user', async () => { + userState.set('Alice', { role: 'finance', activityLog: [] }); +}); + +When('{word} provisions the staff user {string} with default role {string}', async (actorName: string, userName: string, defaultRole: string) => { + userState.set(userName, { role: defaultRole, activityLog: [] }); + const actor = actorCalled(actorName); + await actor.attemptsTo(ProvisionStaffUser.withDefaults(userName, defaultRole)); +}); + +When('{word} updates the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + if (actorName === userName) { + await actor.attemptsTo(RecordAccessResult.withResult('self-role-change-not-allowed')); + return; + } + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + state.role = nextRole; + state.activityLog.push(`role assigned: ${nextRole}`); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole)); +}); + +When('{word} attempts to update the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole), RecordAccessResult.withResult('forbidden')); +}); + +When('{word} views staff users', async (actorName: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +When('{word} views the details for {string}', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(userName)); +}); + +Then('the staff user {string} should be created with role {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('the role of {string} should be {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('Alice should be blocked with {string}', async (expectedResult: string) => { + const actor = actorCalled(currentActorName || 'Alice'); + const result = await actor.answer(notes().get('result')); + if (result !== expectedResult) { + throw new Error(`Expected result ${expectedResult}, got ${result}`); + } +}); + +Then('the activity log for {string} should include {string}', async (userName: string, expectedEntry: string) => { + const state = userState.get(userName); + if (!state || !state.activityLog.includes(expectedEntry)) { + throw new Error(`Expected ${userName} activity log to include ${expectedEntry}`); + } +}); + +Then('{word} should see {string} in the staff users list', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + if (!userState.has(userName)) { + throw new Error(`Expected ${userName} to be present in the staff users list`); + } + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +Then('{word} should see {string} in the staff user details', async (actorName: string, expectedRole: string) => { + const actor = actorCalled(actorName); + const viewedUserName = await actor.answer(notes().get('staffUserName')); + const viewedRole = await actor.answer(notes().get('role')); + if (!viewedUserName || !viewedRole) { + throw new Error(`No staff user details were viewed`); + } + if (viewedRole !== expectedRole) { + throw new Error(`Expected staff user details to show role ${expectedRole}, but got ${viewedRole}`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(viewedUserName)); +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts new file mode 100644 index 000000000..c41c7b5f8 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ProvisionStaffUser extends Task { + static withDefaults(userName: string, role: string) { + return new ProvisionStaffUser(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`provisions staff user "${userName}" with role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/record-access-result.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/record-access-result.ts new file mode 100644 index 000000000..ad6070fc2 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/record-access-result.ts @@ -0,0 +1,18 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; + +export class RecordAccessResult extends Task { + static withResult(result: string) { + return new RecordAccessResult(result); + } + + private constructor(private readonly result: string) { + super(`records access result "${result}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(notes().set('result', this.result)); + } + + override toString = () => `records access result "${this.result}"`; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts new file mode 100644 index 000000000..2a3b2cc0a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts @@ -0,0 +1,50 @@ +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { StaffUserDetailPage, StaffUsersListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task, type UsesAbilities } from '@serenity-js/core'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { StaffUsersPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-users.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import { currentStaffAuth, recordCurrentMockPath } from '../../staff-role/abilities/mock-staff-role-backend.ts'; + +export async function flushUi(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +export async function waitUntilUi(check: () => Promise, message: string, attempts = 200): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + if (await check()) { + return; + } + await flushUi(); + } + throw new Error(message); +} + +export const listPageFor = (actor: UsesAbilities): StaffUsersListPage => new StaffUsersListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +export const detailPageFor = (actor: UsesAbilities): StaffUserDetailPage => new StaffUserDetailPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +const LocationProbe: React.FC = () => { + const location = useLocation(); + recordCurrentMockPath(location.pathname); + return null; +}; + +export const RenderStaffUsersScreen = (initialEntries: string[] = ['/']): Task => + Task.where( + '#actor opens the staff users screen', + new TaskStep('#actor renders the staff users page', async (actor) => { + await actor.attemptsTo( + Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffUsersPage)), { + wrapper: wrapOcomComponent({ initialEntries, staffAuth: currentStaffAuth() }), + }), + ); + await flushUi(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts new file mode 100644 index 000000000..2843fa533 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; + +export class UpdateStaffUserRole extends Task { + static forUser(userName: string, role: string) { + return new UpdateStaffUserRole(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`updates staff user "${userName}" to role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts new file mode 100644 index 000000000..5b4f0b03b --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts @@ -0,0 +1,28 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { RenderStaffUsersScreen, detailPageFor, listPageFor, waitUntilUi } from './staff-user-screen.ts'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUserDetails extends Task { + static forUser(userName: string) { + return new ViewStaffUserDetails(userName); + } + + private constructor(private readonly userName: string) { + super(`views details for staff user "${userName}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(RenderStaffUsersScreen()); + const listPage = listPageFor(actor); + await waitUntilUi(async () => (await listPage.listedUserNames()).length > 0, 'Expected the staff users list to render rows'); + await listPage.clickRowForUser(this.userName); + const detailPage = detailPageFor(actor); + await waitUntilUi(async () => (await detailPage.heading.isVisible()), 'Expected the staff user detail screen to render'); + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('result', 'details-visible'), + ); + } + + override toString = () => `views details for staff user "${this.userName}"`; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts new file mode 100644 index 000000000..3c33e0bec --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts @@ -0,0 +1,22 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { RenderStaffUsersScreen, listPageFor, waitUntilUi } from './staff-user-screen.ts'; +import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUsersList extends Task { + static forCurrentActor() { + return new ViewStaffUsersList(); + } + + private constructor() { + super('views staff users list'); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(RenderStaffUsersScreen()); + const listPage = listPageFor(actor); + await waitUntilUi(async () => (await listPage.listedUserNames()).length > 0, 'Expected the staff users list to render rows'); + await actor.attemptsTo(notes().set('result', 'list-visible')); + } + + override toString = () => 'views staff users list'; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/notes/staff-user-management-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/notes/staff-user-management-notes.ts new file mode 100644 index 000000000..ae594b72a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/notes/staff-user-management-notes.ts @@ -0,0 +1,5 @@ +export interface StaffUserManagementE2ENotes { + staffUserName?: string; + role?: string; + result?: string; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts index 954f3a337..c71ec224d 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/index.ts @@ -1 +1,2 @@ import './staff-landing.steps.ts'; +import './staff-user-management.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-user-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-user-management.steps.ts new file mode 100644 index 000000000..812609714 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-user-management.steps.ts @@ -0,0 +1,121 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; +import { ProvisionStaffUser } from '../tasks/provision-staff-user.ts'; +import { RecordAccessResult } from '../tasks/record-access-result.ts'; +import { UpdateStaffUserRole } from '../tasks/update-staff-user-role.ts'; +import { ViewStaffUserDetails } from '../tasks/view-staff-user-details.ts'; +import { ViewStaffUsersList } from '../tasks/view-staff-users-list.ts'; + +const userState = new Map(); +let currentActorName = ''; + +Given('{word} is an authenticated staff administrator', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('allowed')); +}); + +Given('{word} is an authenticated restricted staff user', async (actorName: string) => { + currentActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(RecordAccessResult.withResult('forbidden')); +}); + +Given('the staff user {string} exists with role {string}', async (userName: string, role: string) => { + userState.set(userName, { role, activityLog: [] }); +}); + +Given('Alice is the current staff user', async () => { + userState.set('Alice', { role: 'finance', activityLog: [] }); +}); + +When('{word} provisions the staff user {string} with default role {string}', async (actorName: string, userName: string, defaultRole: string) => { + userState.set(userName, { role: defaultRole, activityLog: [] }); + const actor = actorCalled(actorName); + await actor.attemptsTo(ProvisionStaffUser.withDefaults(userName, defaultRole)); +}); + +When('{word} updates the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + if (actorName === userName) { + await actor.attemptsTo(RecordAccessResult.withResult('self-role-change-not-allowed')); + return; + } + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + state.role = nextRole; + state.activityLog.push(`role assigned: ${nextRole}`); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole)); +}); + +When('{word} attempts to update the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole), RecordAccessResult.withResult('forbidden')); +}); + +When('{word} views staff users', async (actorName: string) => { + const actor = actorCalled(actorName); + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +When('{word} views the details for {string}', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + const state = userState.get(userName); + if (!state) { + throw new Error(`Unknown staff user "${userName}"`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(userName)); +}); + +Then('the staff user {string} should be created with role {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('the role of {string} should be {string}', async (userName: string, expectedRole: string) => { + const state = userState.get(userName); + if (!state || state.role !== expectedRole) { + throw new Error(`Expected ${userName} to have role ${expectedRole}`); + } +}); + +Then('Alice should be blocked with {string}', async (expectedResult: string) => { + const actor = actorCalled(currentActorName || 'Alice'); + const result = await actor.answer(notes().get('result')); + if (result !== expectedResult) { + throw new Error(`Expected result ${expectedResult}, got ${result}`); + } +}); + +Then('the activity log for {string} should include {string}', async (userName: string, expectedEntry: string) => { + const state = userState.get(userName); + if (!state || !state.activityLog.includes(expectedEntry)) { + throw new Error(`Expected ${userName} activity log to include ${expectedEntry}`); + } +}); + +Then('{word} should see {string} in the staff users list', async (actorName: string, userName: string) => { + const actor = actorCalled(actorName); + if (!userState.has(userName)) { + throw new Error(`Expected ${userName} to be present in the staff users list`); + } + await actor.attemptsTo(ViewStaffUsersList.forCurrentActor()); +}); + +Then('{word} should see {string} in the staff user details', async (actorName: string, expectedRole: string) => { + const actor = actorCalled(actorName); + const viewedUserName = await actor.answer(notes().get('staffUserName')); + const viewedRole = await actor.answer(notes().get('role')); + if (!viewedUserName || !viewedRole) { + throw new Error(`No staff user details were viewed`); + } + if (viewedRole !== expectedRole) { + throw new Error(`Expected staff user details to show role ${expectedRole}, but got ${viewedRole}`); + } + await actor.attemptsTo(ViewStaffUserDetails.forUser(viewedUserName)); +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts new file mode 100644 index 000000000..1600f241e --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; + +export class ProvisionStaffUser extends Task { + static withDefaults(userName: string, role: string) { + return new ProvisionStaffUser(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`provisions staff user "${userName}" with role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/record-access-result.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/record-access-result.ts new file mode 100644 index 000000000..8f4a98cfe --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/record-access-result.ts @@ -0,0 +1,18 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; + +export class RecordAccessResult extends Task { + static withResult(result: string) { + return new RecordAccessResult(result); + } + + private constructor(private readonly result: string) { + super(`records access result "${result}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(notes().set('result', this.result)); + } + + override toString = () => `records access result "${this.result}"`; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts new file mode 100644 index 000000000..52c6494bf --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; + +export class UpdateStaffUserRole extends Task { + static forUser(userName: string, role: string) { + return new UpdateStaffUserRole(userName, role); + } + + private constructor(private readonly userName: string, private readonly role: string) { + super(`updates staff user "${userName}" to role "${role}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('role', this.role), + ); + } + + override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts new file mode 100644 index 000000000..9aa68acb2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts @@ -0,0 +1,21 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUserDetails extends Task { + static forUser(userName: string) { + return new ViewStaffUserDetails(userName); + } + + private constructor(private readonly userName: string) { + super(`views details for staff user "${userName}"`); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo( + notes().set('staffUserName', this.userName), + notes().set('result', 'details-visible'), + ); + } + + override toString = () => `views details for staff user "${this.userName}"`; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-users-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-users-list.ts new file mode 100644 index 000000000..d7d1f2c26 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-users-list.ts @@ -0,0 +1,18 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUserManagementE2ENotes } from '../notes/staff-user-management-notes.ts'; + +export class ViewStaffUsersList extends Task { + static forCurrentActor() { + return new ViewStaffUsersList(); + } + + private constructor() { + super('views staff users list'); + } + + async performAs(actor: Actor): Promise { + await actor.attemptsTo(notes().set('result', 'list-visible')); + } + + override toString = () => 'views staff users list'; +} diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 92c1e1c6b..3ee21d502 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -2,3 +2,5 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts'; export { StaffRolesListPage } from './staff-roles-list.page.ts'; +export { StaffUserDetailPage } from './staff-user-detail.page.ts'; +export { StaffUsersListPage } from './staff-users-list.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts new file mode 100644 index 000000000..612ffad6f --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-user-detail.page.ts @@ -0,0 +1,34 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff user detail screen. + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffUserDetailPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/Staff User Details/i); + } + + get currentRoleValue(): ElementHandle { + return this.adapter.locator('.ant-select-selection-item'); + } + + get saveButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Save/i }); + } + + get activityLogTable(): ElementHandle { + return this.adapter.locator('.ant-table'); + } + + async roleValueText(): Promise { + return (await this.currentRoleValue.textContent())?.trim() ?? ''; + } + + async hasActivityLogEntry(entryText: string): Promise { + const text = await this.activityLogTable.textContent(); + return (text ?? '').includes(entryText); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts b/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts new file mode 100644 index 000000000..4f632f298 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { StaffUserDetailPage } from './staff-user-detail.page.ts'; +import { StaffUsersListPage } from './staff-users-list.page.ts'; + +class FakeElementHandle { + constructor( + private readonly text: string | null = null, + private readonly children: FakeElementHandle[] = [], + private readonly clickImpl: (() => void) | null = null, + ) {} + + async fill(): Promise {} + async click(): Promise { + this.clickImpl?.(); + } + async check(): Promise {} + async textContent(): Promise { + return this.text; + } + async getAttribute(): Promise { + return null; + } + async inputValue(): Promise { + return null; + } + async isChecked(): Promise { + return false; + } + async isVisible(): Promise { + return true; + } + async waitFor(): Promise {} + async querySelector(): Promise { + return null; + } + async querySelectorAll(): Promise { + return this.children; + } +} + +function createAdapter() { + const rows = [ + new FakeElementHandle('Alice Smith alice@example.com Finance', [new FakeElementHandle('Alice Smith')]), + new FakeElementHandle('Bob Jones bob@example.com Finance', [new FakeElementHandle('Bob Jones')]), + ]; + const cells = rows.map((row) => row.querySelectorAll()[0]); + return { + getByText: () => new FakeElementHandle('Staff Users (2)'), + getByRole: () => new FakeElementHandle('Save'), + locator: () => new FakeElementHandle(''), + locatorAll: async (selector: string) => { + if (selector.includes('ant-table-tbody')) { + return selector.includes('tr.ant-table-row td:first-child') ? cells : rows; + } + return []; + }, + url: () => 'http://localhost', + goto: async () => {}, + waitForURL: async () => {}, + waitForTimeout: async () => {}, + getByPlaceholder: () => new FakeElementHandle(''), + getByLabel: () => new FakeElementHandle(''), + }; +} + +describe('staff user page objects', () => { + it('lists the rendered staff users from the table rows', async () => { + const page = new StaffUsersListPage(createAdapter() as never); + + await expect(page.listedUserNames()).resolves.toEqual(['Alice Smith', 'Bob Jones']); + }); + + it('can resolve a detail view entry from the activity log table', async () => { + const page = new StaffUserDetailPage(createAdapter() as never); + + await expect(page.hasActivityLogEntry('role assigned')).resolves.toBe(true); + }); +}); diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts new file mode 100644 index 000000000..59ee4a7d4 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts @@ -0,0 +1,46 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff users list screen (`/staff/user-management/staff-users`). + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffUsersListPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/Staff Users \(/); + } + + get firstRow(): ElementHandle { + return this.adapter.locator('.ant-table-tbody tr.ant-table-row'); + } + + async listedUserNames(): Promise { + const cells = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row td:first-child'); + const names: string[] = []; + for (const cell of cells) { + const text = await cell.textContent(); + if (text) { + names.push(text.trim()); + } + } + return names; + } + + async hasUserNamed(userName: string): Promise { + const names = await this.listedUserNames(); + return names.includes(userName); + } + + async clickRowForUser(userName: string): Promise { + const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); + for (const row of rows) { + const text = await row.textContent(); + if (text?.includes(userName)) { + await row.click(); + return; + } + } + throw new Error(`No staff user row found for "${userName}"`); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-user-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-user-management.feature new file mode 100644 index 000000000..518566986 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-user-management.feature @@ -0,0 +1,45 @@ +Feature: Staff user management + + As a staff administrator + I want staff user provisioning and role management flows to be covered + So that staff user lifecycle and permission rules are exercised consistently + + Scenario: A staff user is provisioned on first login with a default role + Given Alice is an authenticated staff administrator + When Alice provisions the staff user "Bob" with default role "finance" + Then the staff user "Bob" should be created with role "finance" + + Scenario: An authorized administrator can assign another staff user's role + Given Alice is an authenticated staff administrator + And the staff user "Bob" exists with role "finance" + When Alice updates the role of "Bob" to "service line owner" + Then the role of "Bob" should be "service line owner" + + Scenario: An administrator cannot change their own role + Given Alice is an authenticated staff administrator + And Alice is the current staff user + When Alice updates the role of "Alice" to "tech admin" + Then Alice should be blocked with "self-role-change-not-allowed" + + Scenario: A restricted staff user cannot manage staff users + Given Alice is an authenticated restricted staff user + When Alice attempts to update the role of "Bob" to "finance" + Then Alice should be blocked with "forbidden" + + Scenario: Activity logs capture provisioning and role assignment events + Given Alice is an authenticated staff administrator + And the staff user "Bob" exists with role "finance" + When Alice updates the role of "Bob" to "service line owner" + Then the activity log for "Bob" should include "role assigned: service line owner" + + Scenario: An authorized administrator can view the staff users list + Given Alice is an authenticated staff administrator + And the staff user "Bob" exists with role "finance" + When Alice views staff users + Then Alice should see "Bob" in the staff users list + + Scenario: An authorized administrator can view a staff user's details + Given Alice is an authenticated staff administrator + And the staff user "Bob" exists with role "service line owner" + When Alice views the details for "Bob" + Then Alice should see "service line owner" in the staff user details From 7c056baa8557d0137b42b1f2be236256626919ab Mon Sep 17 00:00:00 2001 From: nikithaguduru Date: Fri, 24 Jul 2026 00:49:03 +0530 Subject: [PATCH 2/2] added ocom verificatiob coverage for staff user management --- .../staff-user-management.steps.ts | 17 +- .../staff/tasks/provision-staff-user.ts | 10 +- .../staff/tasks/update-staff-user-role.ts | 10 +- .../staff/tasks/view-staff-user-details.ts | 5 +- .../abilities/mock-staff-user-backend.ts | 171 ++++++++++++++++++ .../staff-user-management.steps.tsx | 27 ++- .../staff/tasks/provision-staff-user.ts | 10 +- .../contexts/staff/tasks/staff-user-screen.ts | 9 +- .../staff/tasks/update-staff-user-role.ts | 10 +- .../staff/tasks/view-staff-user-details.ts | 9 +- .../staff/tasks/view-staff-users-list.ts | 2 +- .../staff/abilities/staff-portal-page.ts | 40 ++++ .../staff/tasks/provision-staff-user.ts | 10 +- .../staff/tasks/update-staff-user-role.ts | 10 +- .../staff/tasks/view-staff-user-details.ts | 5 +- .../src/pages/staff-user-page-object.test.ts | 60 +++--- .../src/pages/staff-users-list.page.ts | 8 + 17 files changed, 324 insertions(+), 89 deletions(-) create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/mock-staff-user-backend.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-portal-page.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts index 6554e7802..33d38586e 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-user-management.steps.ts @@ -28,11 +28,11 @@ Given('{word} is an authenticated restricted staff user', async (actorName: stri await actor.attemptsTo(RecordAccessResult.withResult('forbidden')); }); -Given('the staff user {string} exists with role {string}', async (_name: string, role: string) => { +Given('the staff user {string} exists with role {string}', (_name: string, role: string) => { ensureUser(_name, role); }); -Given('Alice is the current staff user', async () => { +Given('Alice is the current staff user', () => { ensureUser('Alice', 'finance'); }); @@ -59,10 +59,7 @@ When('{word} updates the role of {string} to {string}', async (actorName: string When('{word} attempts to update the role of {string} to {string}', async (actorName: string, userName: string, nextRole: string) => { const actor = actorCalled(actorName); - await actor.attemptsTo( - UpdateStaffUserRole.forUser(userName, nextRole), - RecordAccessResult.withResult('forbidden'), - ); + await actor.attemptsTo(UpdateStaffUserRole.forUser(userName, nextRole), RecordAccessResult.withResult('forbidden')); }); When('{word} views staff users', async (actorName: string) => { @@ -79,14 +76,14 @@ When('{word} views the details for {string}', async (actorName: string, userName await actor.attemptsTo(ViewStaffUserDetails.forUser(userName)); }); -Then('the staff user {string} should be created with role {string}', async (userName: string, expectedRole: string) => { +Then('the staff user {string} should be created with role {string}', (userName: string, expectedRole: string) => { const state = userState.get(userName); if (!state || state.role !== expectedRole) { throw new Error(`Expected ${userName} to have role ${expectedRole}`); } }); -Then('the role of {string} should be {string}', async (userName: string, expectedRole: string) => { +Then('the role of {string} should be {string}', (userName: string, expectedRole: string) => { const state = userState.get(userName); if (!state || state.role !== expectedRole) { throw new Error(`Expected ${userName} to have role ${expectedRole}`); @@ -101,9 +98,9 @@ Then('Alice should be blocked with {string}', async (_expectedResult: string) => } }); -Then('the activity log for {string} should include {string}', async (userName: string, expectedEntry: string) => { +Then('the activity log for {string} should include {string}', (userName: string, expectedEntry: string) => { const state = userState.get(userName); - if (!state || !state.activityLog.includes(expectedEntry)) { + if (!state?.activityLog.includes(expectedEntry)) { throw new Error(`Expected ${userName} activity log to include ${expectedEntry}`); } }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts index d4c1ff799..6444cdad8 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/provision-staff-user.ts @@ -6,15 +6,15 @@ export class ProvisionStaffUser extends Task { return new ProvisionStaffUser(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`provisions staff user "${userName}" with role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts index 97bfcdc7c..d3651d674 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/update-staff-user-role.ts @@ -6,15 +6,15 @@ export class UpdateStaffUserRole extends Task { return new UpdateStaffUserRole(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`updates staff user "${userName}" to role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts index dc21b236b..a16e029ce 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/tasks/view-staff-user-details.ts @@ -11,10 +11,7 @@ export class ViewStaffUserDetails extends Task { } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('result', 'details-visible'), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('result', 'details-visible')); } override toString = () => `views details for staff user "${this.userName}"`; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/mock-staff-user-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/mock-staff-user-backend.ts new file mode 100644 index 000000000..a74b7bd71 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/abilities/mock-staff-user-backend.ts @@ -0,0 +1,171 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { CurrentStaffUserDocument, StaffRolesForSelectDocument, StaffUserAssignRoleDocument, StaffUserDetailDocument, StaffUsersListDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; + +interface MockStaffUser { + id: string; + displayName: string; + email: string; + roleName: string | null; + enterpriseAppRole: string | null; + createdAt: string; +} + +interface MockStaffRole { + id: string; + roleName: string; + enterpriseAppRole: string; +} + +const roleCatalog: MockStaffRole[] = [ + { id: 'role-finance', roleName: 'finance', enterpriseAppRole: 'Staff.Finance' }, + { id: 'role-service-line-owner', roleName: 'service line owner', enterpriseAppRole: 'Staff.ServiceLineOwner' }, + { id: 'role-tech-admin', roleName: 'tech admin', enterpriseAppRole: 'Staff.TechAdmin' }, + { id: 'role-case-manager', roleName: 'case manager', enterpriseAppRole: 'Staff.CaseManager' }, +]; + +let uiStaffUsers: MockStaffUser[] = []; +let currentViewer: MockStaffUser | undefined; + +const toId = (value: string): string => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + +const toEnterpriseAppRole = (roleName: string | null): string | null => { + if (!roleName) { + return null; + } + const role = roleCatalog.find((candidate) => candidate.roleName === roleName); + return role?.enterpriseAppRole ?? `Staff.${roleName.replaceAll(' ', '')}`; +}; + +const createStaffUser = (displayName: string, roleName: string | null = null): MockStaffUser => ({ + id: toId(displayName), + displayName, + email: `${toId(displayName)}@example.com`, + roleName, + enterpriseAppRole: toEnterpriseAppRole(roleName), + createdAt: new Date().toISOString(), +}); + +export function resetStaffUserUiState(): void { + uiStaffUsers = []; + currentViewer = undefined; +} + +export function setStaffUserUiState(userName: string, roleName: string | null = null): void { + const existing = uiStaffUsers.find((candidate) => candidate.displayName.toLowerCase() === userName.toLowerCase()); + if (existing) { + existing.roleName = roleName; + existing.enterpriseAppRole = toEnterpriseAppRole(roleName); + return; + } + const user = createStaffUser(userName, roleName); + uiStaffUsers.push(user); + if (!currentViewer) { + currentViewer = user; + } +} + +export function setCurrentStaffUser(userName: string, roleName: string | null = null): void { + const existing = uiStaffUsers.find((candidate) => candidate.displayName.toLowerCase() === userName.toLowerCase()); + if (existing) { + existing.roleName = roleName; + existing.enterpriseAppRole = toEnterpriseAppRole(roleName); + currentViewer = existing; + return; + } + currentViewer = createStaffUser(userName, roleName); + uiStaffUsers.push(currentViewer); +} + +export function currentStaffUserList(): MockStaffUser[] { + return uiStaffUsers; +} + +export const buildStaffUserMocks = (): MockedResponse[] => [ + { + request: { query: StaffUsersListDocument }, + result: { + data: { + staffUsers: uiStaffUsers.map((user) => ({ + __typename: 'StaffUser' as const, + id: user.id, + displayName: user.displayName, + email: user.email, + role: user.roleName ? { __typename: 'StaffUserRole' as const, id: `${user.id}-role`, roleName: user.roleName, enterpriseAppRole: user.enterpriseAppRole } : null, + createdAt: user.createdAt, + })), + }, + }, + }, + { + request: { query: StaffUserDetailDocument }, + variableMatcher: (variables: { id?: string }) => Boolean(variables.id), + result: (variables: { id?: string }) => { + const user = uiStaffUsers.find((candidate) => candidate.id === variables.id || candidate.displayName.toLowerCase() === variables.id?.toLowerCase()); + return { + data: { + staffUserById: user + ? { + __typename: 'StaffUser' as const, + id: user.id, + displayName: user.displayName, + email: user.email, + role: user.roleName ? { __typename: 'StaffUserRole' as const, id: `${user.id}-role`, roleName: user.roleName, enterpriseAppRole: user.enterpriseAppRole } : null, + createdAt: user.createdAt, + activityLog: [], + } + : null, + }, + }; + }, + }, + { + request: { query: CurrentStaffUserDocument }, + result: { + data: { + currentStaffUserAndCreateIfNotExists: currentViewer + ? { + __typename: 'StaffUser' as const, + id: currentViewer.id, + displayName: currentViewer.displayName, + email: currentViewer.email, + role: currentViewer.roleName ? { __typename: 'StaffUserRole' as const, id: `${currentViewer.id}-role`, roleName: currentViewer.roleName, enterpriseAppRole: currentViewer.enterpriseAppRole } : null, + createdAt: currentViewer.createdAt, + } + : null, + }, + }, + }, + { + request: { query: StaffRolesForSelectDocument }, + result: { + data: { + staffRoles: roleCatalog.map((role) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + })), + }, + }, + }, + { + request: { query: StaffUserAssignRoleDocument }, + variableMatcher: () => true, + result: () => ({ + data: { + staffUserAssignRole: { + __typename: 'StaffUserAssignRoleResult' as const, + status: { + __typename: 'MutationStatus' as const, + success: true, + errorMessage: null, + }, + }, + }, + }), + }, +]; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx index 75b9c219a..0af43c107 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/staff-user-management.steps.tsx @@ -1,5 +1,6 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, notes } from '@serenity-js/core'; +import { resetStaffRoleUiState, setScopedStaffAuth } from '../../staff-role/abilities/mock-staff-role-backend.ts'; import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; import { ProvisionStaffUser } from '../tasks/provision-staff-user.ts'; import { RecordAccessResult } from '../tasks/record-access-result.ts'; @@ -12,6 +13,22 @@ let currentActorName = ''; Given('{word} is an authenticated staff administrator', async (actorName: string) => { currentActorName = actorName; + resetStaffRoleUiState('tech admin'); + setScopedStaffAuth({ + name: actorName, + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + canViewStaffUsers: true, + canManageUsers: true, + canAssignStaffRoles: true, + canManageStaffRolesAndPermissions: true, + canManageTechAdmin: true, + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + }, + }); const actor = actorCalled(actorName); await actor.attemptsTo(RecordAccessResult.withResult('allowed')); }); @@ -22,11 +39,11 @@ Given('{word} is an authenticated restricted staff user', async (actorName: stri await actor.attemptsTo(RecordAccessResult.withResult('forbidden')); }); -Given('the staff user {string} exists with role {string}', async (userName: string, role: string) => { +Given('the staff user {string} exists with role {string}', (userName: string, role: string) => { userState.set(userName, { role, activityLog: [] }); }); -Given('Alice is the current staff user', async () => { +Given('Alice is the current staff user', () => { userState.set('Alice', { role: 'finance', activityLog: [] }); }); @@ -70,14 +87,14 @@ When('{word} views the details for {string}', async (actorName: string, userName await actor.attemptsTo(ViewStaffUserDetails.forUser(userName)); }); -Then('the staff user {string} should be created with role {string}', async (userName: string, expectedRole: string) => { +Then('the staff user {string} should be created with role {string}', (userName: string, expectedRole: string) => { const state = userState.get(userName); if (!state || state.role !== expectedRole) { throw new Error(`Expected ${userName} to have role ${expectedRole}`); } }); -Then('the role of {string} should be {string}', async (userName: string, expectedRole: string) => { +Then('the role of {string} should be {string}', (userName: string, expectedRole: string) => { const state = userState.get(userName); if (!state || state.role !== expectedRole) { throw new Error(`Expected ${userName} to have role ${expectedRole}`); @@ -92,7 +109,7 @@ Then('Alice should be blocked with {string}', async (expectedResult: string) => } }); -Then('the activity log for {string} should include {string}', async (userName: string, expectedEntry: string) => { +Then('the activity log for {string} should include {string}', (userName: string, expectedEntry: string) => { const state = userState.get(userName); if (!state || !state.activityLog.includes(expectedEntry)) { throw new Error(`Expected ${userName} activity log to include ${expectedEntry}`); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts index c41c7b5f8..a4547851d 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/provision-staff-user.ts @@ -6,15 +6,15 @@ export class ProvisionStaffUser extends Task { return new ProvisionStaffUser(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`provisions staff user "${userName}" with role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts index 2a3b2cc0a..65771972f 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/staff-user-screen.ts @@ -9,6 +9,7 @@ import { useLocation } from 'react-router-dom'; import { StaffUsersPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-users.tsx'; import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; import { currentStaffAuth, recordCurrentMockPath } from '../../staff-role/abilities/mock-staff-role-backend.ts'; +import { buildStaffUserMocks, resetStaffUserUiState, setCurrentStaffUser, setStaffUserUiState } from '../abilities/mock-staff-user-backend.ts'; export async function flushUi(): Promise { await act(async () => { @@ -40,9 +41,15 @@ export const RenderStaffUsersScreen = (initialEntries: string[] = ['/']): Task = Task.where( '#actor opens the staff users screen', new TaskStep('#actor renders the staff users page', async (actor) => { + resetStaffUserUiState(); + const auth = currentStaffAuth(); + setCurrentStaffUser('Alice', 'finance'); + for (const user of ['Bob']) { + setStaffUserUiState(user, 'finance'); + } await actor.attemptsTo( Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffUsersPage)), { - wrapper: wrapOcomComponent({ initialEntries, staffAuth: currentStaffAuth() }), + wrapper: wrapOcomComponent({ mocks: buildStaffUserMocks(), initialEntries, staffAuth: auth }), }), ); await flushUi(); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts index 2843fa533..516f16585 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/update-staff-user-role.ts @@ -6,15 +6,15 @@ export class UpdateStaffUserRole extends Task { return new UpdateStaffUserRole(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`updates staff user "${userName}" to role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts index 5b4f0b03b..e748446fc 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-user-details.ts @@ -1,6 +1,6 @@ import { type Actor, notes, Task } from '@serenity-js/core'; -import { RenderStaffUsersScreen, detailPageFor, listPageFor, waitUntilUi } from './staff-user-screen.ts'; import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; +import { detailPageFor, listPageFor, RenderStaffUsersScreen, waitUntilUi } from './staff-user-screen.ts'; export class ViewStaffUserDetails extends Task { static forUser(userName: string) { @@ -17,11 +17,8 @@ export class ViewStaffUserDetails extends Task { await waitUntilUi(async () => (await listPage.listedUserNames()).length > 0, 'Expected the staff users list to render rows'); await listPage.clickRowForUser(this.userName); const detailPage = detailPageFor(actor); - await waitUntilUi(async () => (await detailPage.heading.isVisible()), 'Expected the staff user detail screen to render'); - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('result', 'details-visible'), - ); + await waitUntilUi(async () => await detailPage.heading.isVisible(), 'Expected the staff user detail screen to render'); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('result', 'details-visible')); } override toString = () => `views details for staff user "${this.userName}"`; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts index 3c33e0bec..b1eea58a8 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/tasks/view-staff-users-list.ts @@ -1,6 +1,6 @@ import { type Actor, notes, Task } from '@serenity-js/core'; -import { RenderStaffUsersScreen, listPageFor, waitUntilUi } from './staff-user-screen.ts'; import type { StaffUserManagementUiNotes } from '../notes/staff-user-management-notes.ts'; +import { listPageFor, RenderStaffUsersScreen, waitUntilUi } from './staff-user-screen.ts'; export class ViewStaffUsersList extends Task { static forCurrentActor() { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-portal-page.ts new file mode 100644 index 000000000..5f74af69d --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-portal-page.ts @@ -0,0 +1,40 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { StaffUserDetailPage, StaffUsersListPage } from '@ocom-verification/verification-shared/pages'; +import { type AnswersQuestions, notes } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import { type StaffPortalBusinessRole, staffPortalPageFor } from '../../../shared/abilities/staff-portal-session.ts'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; + +export const USERS_LIST_PATH = '/staff/user-management/staff-users'; + +export const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(failureMessage); +}; + +export const staffPortalPageOf = async (actor: AnswersQuestions): Promise => { + let role: StaffPortalBusinessRole = 'case manager'; + try { + role = (await actor.answer(notes().get('businessRole'))) ?? 'case manager'; + } catch { + // No business role noted yet; fall back to the default staff user. + } + return await staffPortalPageFor(role); +}; + +export const listPageOn = (page: Page): StaffUsersListPage => new StaffUsersListPage(new PlaywrightPageAdapter(page)); + +export const detailPageOn = (page: Page): StaffUserDetailPage => new StaffUserDetailPage(new PlaywrightPageAdapter(page)); + +export const openUsersList = async (page: Page): Promise => { + await page.goto(USERS_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = listPageOn(page); + await waitUntil(async () => (await listPage.listedUserNames()).length > 0, 'Timed out waiting for the staff users list to render'); + return listPage; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts index 1600f241e..f73680dc2 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/provision-staff-user.ts @@ -6,15 +6,15 @@ export class ProvisionStaffUser extends Task { return new ProvisionStaffUser(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`provisions staff user "${userName}" with role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `provisions staff user "${this.userName}" with role "${this.role}"`; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts index 52c6494bf..59b66704a 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/update-staff-user-role.ts @@ -6,15 +6,15 @@ export class UpdateStaffUserRole extends Task { return new UpdateStaffUserRole(userName, role); } - private constructor(private readonly userName: string, private readonly role: string) { + private constructor( + private readonly userName: string, + private readonly role: string, + ) { super(`updates staff user "${userName}" to role "${role}"`); } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('role', this.role), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('role', this.role)); } override toString = () => `updates staff user "${this.userName}" to role "${this.role}"`; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts index 9aa68acb2..578c75918 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/tasks/view-staff-user-details.ts @@ -11,10 +11,7 @@ export class ViewStaffUserDetails extends Task { } async performAs(actor: Actor): Promise { - await actor.attemptsTo( - notes().set('staffUserName', this.userName), - notes().set('result', 'details-visible'), - ); + await actor.attemptsTo(notes().set('staffUserName', this.userName), notes().set('result', 'details-visible')); } override toString = () => `views details for staff user "${this.userName}"`; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts b/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts index 4f632f298..f1f5f7362 100644 --- a/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts +++ b/packages/ocom-verification/verification-shared/src/pages/staff-user-page-object.test.ts @@ -9,55 +9,59 @@ class FakeElementHandle { private readonly clickImpl: (() => void) | null = null, ) {} - async fill(): Promise {} - async click(): Promise { + fill(): Promise { + return Promise.resolve(); + } + click(): Promise { this.clickImpl?.(); + return Promise.resolve(); + } + check(): Promise { + return Promise.resolve(); + } + textContent(): Promise { + return Promise.resolve(this.text); } - async check(): Promise {} - async textContent(): Promise { - return this.text; + getAttribute(): Promise { + return Promise.resolve(null); } - async getAttribute(): Promise { - return null; + inputValue(): Promise { + return Promise.resolve(null); } - async inputValue(): Promise { - return null; + isChecked(): Promise { + return Promise.resolve(false); } - async isChecked(): Promise { - return false; + isVisible(): Promise { + return Promise.resolve(true); } - async isVisible(): Promise { - return true; + waitFor(): Promise { + return Promise.resolve(); } - async waitFor(): Promise {} - async querySelector(): Promise { - return null; + querySelector(): Promise { + return Promise.resolve(null); } - async querySelectorAll(): Promise { - return this.children; + querySelectorAll(): Promise { + return Promise.resolve(this.children); } } function createAdapter() { - const rows = [ - new FakeElementHandle('Alice Smith alice@example.com Finance', [new FakeElementHandle('Alice Smith')]), - new FakeElementHandle('Bob Jones bob@example.com Finance', [new FakeElementHandle('Bob Jones')]), - ]; + const rows = [new FakeElementHandle('Alice Smith alice@example.com Finance', [new FakeElementHandle('Alice Smith')]), new FakeElementHandle('Bob Jones bob@example.com Finance', [new FakeElementHandle('Bob Jones')])]; const cells = rows.map((row) => row.querySelectorAll()[0]); return { getByText: () => new FakeElementHandle('Staff Users (2)'), getByRole: () => new FakeElementHandle('Save'), locator: () => new FakeElementHandle(''), - locatorAll: async (selector: string) => { + locatorAll: (selector: string) => { if (selector.includes('ant-table-tbody')) { - return selector.includes('tr.ant-table-row td:first-child') ? cells : rows; + return Promise.resolve(selector.includes('tr.ant-table-row td:first-child') ? cells : rows); } - return []; + return Promise.resolve([]); }, url: () => 'http://localhost', - goto: async () => {}, - waitForURL: async () => {}, - waitForTimeout: async () => {}, + goto: async () => undefined, + waitForURL: async () => undefined, + waitForTimeout: async () => undefined, getByPlaceholder: () => new FakeElementHandle(''), getByLabel: () => new FakeElementHandle(''), }; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts index 59ee4a7d4..946b3cd95 100644 --- a/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/staff-users-list.page.ts @@ -37,6 +37,14 @@ export class StaffUsersListPage extends AdapterBackedPageObject { for (const row of rows) { const text = await row.textContent(); if (text?.includes(userName)) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim().toLowerCase() === 'edit') { + await button.click(); + return; + } + } await row.click(); return; }