From 65020a85bd631fa9752bfaa3d8600d23c12ef4e5 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Fri, 10 Jul 2026 16:37:28 -0400 Subject: [PATCH 01/15] add acceptance-api scenarios and implementations; add ability to pass headers to 'execute' cellix funciton --- .../src/clients/graphql-client.ts | 12 +- .../acceptance-api/package.json | 1 + .../contexts/community/notes/member-notes.ts | 30 ++++ .../questions/member-belongs-to-community.ts | 58 ++++++++ .../community/questions/member-by-id.ts | 70 +++++++++ .../principal-member-id-for-community.ts | 45 ++++++ .../step-definitions/create-member.steps.ts | 132 +++++++++++++++++ .../community/step-definitions/index.ts | 2 + .../step-definitions/update-member.steps.ts | 136 ++++++++++++++++++ .../contexts/community/tasks/create-member.ts | 49 +++++++ .../community/tasks/update-member-profile.ts | 48 +++++++ .../src/mock-application-services.ts | 22 ++- .../src/servers/api-graphql-test-server.ts | 9 +- .../src/shared/abilities/create-member.ts | 90 ++++++++++++ .../src/shared/abilities/index.ts | 1 + .../shared/abilities/update-member-profile.ts | 121 ++++++++++++++++ .../src/shared/graphql/member-operations.ts | 84 +++++++++++ .../acceptance-api/src/world.ts | 4 +- .../questions/member-belongs-to-community.ts | 10 ++ .../member-authorization-and-scope.feature | 28 ++++ .../community/member/member-lifecycle.feature | 94 ++++++++++++ pnpm-lock.yaml | 129 +++++++---------- pnpm-workspace.yaml | 6 +- 23 files changed, 1091 insertions(+), 90 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/principal-member-id-for-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-profile.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-profile.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature diff --git a/packages/cellix/serenity-framework/src/clients/graphql-client.ts b/packages/cellix/serenity-framework/src/clients/graphql-client.ts index 7aaa5076f..1f1bae4c0 100644 --- a/packages/cellix/serenity-framework/src/clients/graphql-client.ts +++ b/packages/cellix/serenity-framework/src/clients/graphql-client.ts @@ -27,6 +27,12 @@ export interface GraphQLClientOptions { fetch?: typeof fetch; } +/** Per-request options for {@link GraphQLClient.execute}. */ +export interface GraphQLExecuteOptions { + /** Additional headers merged with default client headers for this request. */ + headers?: Record; +} + /** * Serenity ability for executing GraphQL operations over HTTP. * @@ -64,14 +70,18 @@ export class GraphQLClient extends Ability { * * @param query GraphQL document text. * @param variables Variables supplied to the operation. + * @param options Per-request execution options. * @throws Error when the HTTP response is not OK or the GraphQL result contains errors. */ - async execute = Record>(query: string, variables: Record = {}): Promise> { + async execute = Record>(query: string, variables: Record = {}, options: GraphQLExecuteOptions = {}): Promise> { + const requestHeaders = options.headers ?? {}; + const response = await this.fetcher(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.resolveHeaders(), + ...requestHeaders, }, body: JSON.stringify({ query, variables }), }); diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 06e434cb3..4690c16b1 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -30,6 +30,7 @@ "@ocom/application-services": "workspace:*", "@ocom/context-spec": "workspace:*", "@ocom/data-sources-mongoose-models": "workspace:*", + "@ocom/event-handler": "workspace:*", "@ocom/graphql": "workspace:*", "@ocom/persistence": "workspace:*", "@ocom/service-apollo-server": "workspace:*", diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts new file mode 100644 index 000000000..674e71e5d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,30 @@ +// export interface MemberDetails { +// memberName: string; +// role?: string; +// email?: string; +// } + +export interface MemberProfileExpectation { + name?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string[]; + showInterests?: boolean; + showEmail?: boolean; + showProfile?: boolean; + showLocation?: boolean; + showProperties?: boolean; +} + +export interface MemberNotes { + communityIdsByName: Record; + lastMemberCommunityId: string; + lastMemberId: string; + lastMemberName: string; + lastExpectedMemberProfile?: MemberProfileExpectation; + lastMemberProfileEmail?: string; + lastExpectedMemberProfileEmail?: string; + lastMemberStatus: string; + lastValidationError: string; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts new file mode 100644 index 000000000..ae3272002 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts @@ -0,0 +1,58 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBERS_BY_COMMUNITY_QUERY } from '../../../shared/graphql/member-operations.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +interface MemberQueryShape { + id?: string; + memberName?: string; +} + +export class MemberBelongsToCommunity extends Question> { + constructor( + private readonly expectedMemberName: string, + private readonly communityId: string, + ) { + super(`whether member "${expectedMemberName}" belongs to community "${communityId}"`); + } + + static inCommunity(expectedMemberName: string, communityId: string): MemberBelongsToCommunity { + return new MemberBelongsToCommunity(expectedMemberName, communityId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const existsInApi = await this.readFromApi(actor); + if (existsInApi !== undefined) { + return existsInApi; + } + + const notedMemberName = await this.readNote(actor, 'lastMemberName'); + const notedCommunityId = await this.readNote(actor, 'lastMemberCommunityId'); + return notedMemberName === this.expectedMemberName && notedCommunityId === this.communityId; + } + + private async readFromApi(actor: AnswersQuestions & UsesAbilities): Promise { + if (!this.communityId) { + return undefined; + } + + try { + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(MEMBERS_BY_COMMUNITY_QUERY, { + communityId: this.communityId, + }); + const members = (response.data?.['membersByCommunityId'] as MemberQueryShape[] | undefined) ?? []; + return members.some((member) => String(member.memberName ?? '') === this.expectedMemberName); + } catch { + return undefined; + } + } + + private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof MemberNotes): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts new file mode 100644 index 000000000..958402c52 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts @@ -0,0 +1,70 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBER_BY_ID_QUERY } from '../../../shared/graphql/member-operations.ts'; + +export interface MemberByIdResult { + id?: string; + memberName?: string; + community?: { + id?: string; + }; + profile?: { + name?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string[]; + showInterests?: boolean; + showEmail?: boolean; + showProfile?: boolean; + showLocation?: boolean; + showProperties?: boolean; + }; +} + +export class MemberById extends Question> { + constructor(private readonly memberId: string) { + super(`member by id "${memberId}"`); + } + + static withId(memberId: string): MemberById { + return new MemberById(memberId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(MEMBER_BY_ID_QUERY, { + id: this.memberId, + }); + + const member = response.data?.['member'] as Record | undefined; + if (!member) { + return undefined; + } + + const profile = member['profile'] as Record | undefined; + const interests = Array.isArray(profile?.['interests']) ? (profile?.['interests'] as Array).map((value) => String(value)).filter((value) => value.length > 0) : undefined; + + return { + id: member['id'] !== undefined ? String(member['id']) : undefined, + memberName: member['memberName'] !== undefined ? String(member['memberName']) : undefined, + community: { + id: (member['community'] as Record | undefined)?.['id'] !== undefined ? String((member['community'] as Record)['id']) : undefined, + }, + profile: profile + ? { + ...(profile['name'] !== undefined ? { name: String(profile['name'] ?? '') } : {}), + ...(profile['email'] !== undefined ? { email: String(profile['email'] ?? '') } : {}), + ...(profile['bio'] !== undefined ? { bio: String(profile['bio'] ?? '') } : {}), + ...(profile['avatarDocumentId'] !== undefined ? { avatarDocumentId: String(profile['avatarDocumentId'] ?? '') } : {}), + ...(interests !== undefined ? { interests } : {}), + ...(profile['showInterests'] !== undefined ? { showInterests: Boolean(profile['showInterests']) } : {}), + ...(profile['showEmail'] !== undefined ? { showEmail: Boolean(profile['showEmail']) } : {}), + ...(profile['showProfile'] !== undefined ? { showProfile: Boolean(profile['showProfile']) } : {}), + ...(profile['showLocation'] !== undefined ? { showLocation: Boolean(profile['showLocation']) } : {}), + ...(profile['showProperties'] !== undefined ? { showProperties: Boolean(profile['showProperties']) } : {}), + } + : undefined, + }; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/principal-member-id-for-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/principal-member-id-for-community.ts new file mode 100644 index 000000000..b53c861e7 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/principal-member-id-for-community.ts @@ -0,0 +1,45 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBERS_FOR_CURRENT_END_USER_QUERY } from '../../../shared/graphql/member-operations.ts'; + +interface CurrentEndUserMemberShape { + id?: string; + community?: { + id?: string; + }; +} + +const PRINCIPAL_MEMBER_LOOKUP_MAX_ATTEMPTS = 20; +const PRINCIPAL_MEMBER_LOOKUP_DELAY_MS = 150; + +export class PrincipalMemberIdForCommunity extends Question> { + constructor(private readonly communityId: string) { + super(`principal member id for community "${communityId}"`); + } + + static inCommunity(communityId: string): PrincipalMemberIdForCommunity { + return new PrincipalMemberIdForCommunity(communityId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor); + + for (let attempt = 1; attempt <= PRINCIPAL_MEMBER_LOOKUP_MAX_ATTEMPTS; attempt++) { + const response = await graphql.execute(MEMBERS_FOR_CURRENT_END_USER_QUERY); + const members = (response.data?.['membersForCurrentEndUser'] as CurrentEndUserMemberShape[] | undefined) ?? []; + const principalMember = members.find((member) => String(member.community?.id ?? '') === String(this.communityId)); + + if (principalMember?.id) { + return String(principalMember.id); + } + + await wait(PRINCIPAL_MEMBER_LOOKUP_DELAY_MS); + } + + throw new Error(`No provisioned creator member found for community "${this.communityId}" after waiting ${PRINCIPAL_MEMBER_LOOKUP_MAX_ATTEMPTS * PRINCIPAL_MEMBER_LOOKUP_DELAY_MS}ms`); + } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts new file mode 100644 index 000000000..14a47aae3 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -0,0 +1,132 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; +import { CreateCommunity } from '../tasks/create-community.ts'; +import { CreateMember } from '../tasks/create-member.ts'; + +let lastActorName = actors.CommunityOwner.name; + +interface MemberDetails { + memberName: string; + role?: string; + email?: string; +} + +const seededEndUserLabelMap: Record = { + Charlie: 'CommunityMember', +}; + +Given('{word} is signed in as a community owner for member management', (actorName: string) => { + lastActorName = actorName; + actorCalled(actorName); +}); + +Given('{word} has a community named {string}', async (actorName: string, communityName: string) => { + lastActorName = actorName; + + const actor = actorCalled(actorName); + await actor.attemptsTo(CreateCommunity.withName(communityName)); + + // CreateCommunity task sets lastCommunityId; copy into communityIdsByName + const communityId = (await actor.answer(notes().get('lastCommunityId'))) as string; + const existing = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + + await actor.attemptsTo( + notes().set('communityIdsByName', { + ...existing, + [communityName]: communityId, + }), + ); + + // make actor a member of the community with a role that has canManageMembers permission +}); + +Given('{word} is an existing end-user account', async (label: string) => { + const actorKey = seededEndUserLabelMap[label]; + if (!actorKey) { + throw new Error(`No seeded end-user mapping for label "${label}"`); + } + + const seeded = actors[actorKey]; + await actorCalled(lastActorName).attemptsTo(notes().set(`endUser.externalId.${label}`, seeded.externalId), notes().set(`endUser.email.${label}`, seeded.email)); +}); + +When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + + const memberName = details.memberName?.trim() ?? ''; + if (!memberName) { + throw new Error('memberName is required'); + } + + // Resolve the community id by name from notes populated in the Given step + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + await actor.attemptsTo(notes().set('lastValidationError', undefined as unknown as string)); + await actor.attemptsTo( + CreateMember.with({ + memberName, + communityId, + ...(details.role ? { role: details.role } : {}), + }), + ); + + await actor.attemptsTo(notes().set('lastMemberCommunityId', communityId)); +}); + +Then('the member should be created successfully in {string}', async (communityName: string) => { + const actor = actorCalled(lastActorName); + + const status = await actor.answer(notes().get('lastMemberStatus')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const validationError = await actor.answer(notes().get('lastValidationError')); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + if (validationError) { + throw new Error(`Expected member creation to succeed for "${communityName}", but got validation error: "${validationError}"`); + } + + if (status !== 'SUCCESS') { + throw new Error(`Expected member status "SUCCESS" but got "${status}"`); + } + + if (!memberId) { + throw new Error(`Expected a created member id for "${communityName}" but none was recorded`); + } +}); + +Then('the member {string} should belong to {string}', async (expectedMemberName: string, communityName: string) => { + const actor = actorCalled(lastActorName); + + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const expectedCommunityId = communityIdsByName[communityName]; + if (!expectedCommunityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const lastMemberId = await actor.answer(notes().get('lastMemberId')); + + if (!lastMemberId) { + throw new Error('Expected a created member id but none was recorded'); + } + + const belongs = await actor.answer(MemberBelongsToCommunity.inCommunity(expectedMemberName, expectedCommunityId)); + if (!belongs) { + throw new Error(`Expected member "${expectedMemberName}" to belong to "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts index c04c54d61..46874774b 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,4 @@ // Community context step definitions import './create-community.steps.ts'; +import './create-member.steps.ts'; +import './update-member.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts new file mode 100644 index 000000000..7a64a8fe2 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts @@ -0,0 +1,136 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { MemberNotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { MemberById, type MemberByIdResult } from '../questions/member-by-id.ts'; +import { UpdateMember } from '../tasks/update-member-profile.ts'; + +let lastActorName = actors.CommunityOwner.name; + +interface MemberProfileUpdateDetails { + name?: string; + memberName?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string; + showInterests?: string; + showEmail?: string; + showProfile?: string; + showLocation?: string; + showProperties?: string; +} + +When('{word} updates member {string} in {string} with:', async (actorName: string, _memberName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const expectedProfile: MemberProfileExpectation = { + ...(details.name ? { name: details.name } : {}), + ...(details.memberName ? { name: details.memberName } : {}), + ...(details.email ? { email: details.email } : {}), + ...(details.bio ? { bio: details.bio } : {}), + ...(details.avatarDocumentId ? { avatarDocumentId: details.avatarDocumentId } : {}), + ...(details.interests + ? { + interests: details.interests + .split(',') + .map((interest) => interest.trim()) + .filter((interest) => interest.length > 0), + } + : {}), + ...(details.showInterests !== undefined ? { showInterests: details.showInterests.toLowerCase() === 'true' } : {}), + ...(details.showEmail !== undefined ? { showEmail: details.showEmail.toLowerCase() === 'true' } : {}), + ...(details.showProfile !== undefined ? { showProfile: details.showProfile.toLowerCase() === 'true' } : {}), + ...(details.showLocation !== undefined ? { showLocation: details.showLocation.toLowerCase() === 'true' } : {}), + ...(details.showProperties !== undefined ? { showProperties: details.showProperties.toLowerCase() === 'true' } : {}), + }; + + await actor.attemptsTo(notes().set('lastValidationError', undefined as unknown as string), notes().set('lastExpectedMemberProfile', expectedProfile)); + await actor.attemptsTo( + UpdateMember.with({ + communityId, + profile: expectedProfile, + }), + ); +}); + +Then('the member should be updated successfully in {string}', async (communityName: string) => { + const actor = actorCalled(lastActorName); + + const status = await actor.answer(notes().get('lastMemberStatus')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const validationError = await actor.answer(notes().get('lastValidationError')); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + if (validationError) { + throw new Error(`Expected member update to succeed for "${communityName}", but got validation error: "${validationError}"`); + } + + if (status !== 'SUCCESS') { + throw new Error(`Expected member status "SUCCESS" but got "${status}"`); + } + + if (!memberId) { + throw new Error(`Expected an updated member id for "${communityName}" but none was recorded`); + } + + const updatedMember = await actor.answer(MemberById.withId(memberId)); + if (!updatedMember) { + throw new Error(`Expected member "${memberId}" to exist after update, but no member was returned by API`); + } + + const expectedCommunityId = communityIdsByName[communityName]; + if (String(updatedMember.community?.id ?? '') !== String(expectedCommunityId)) { + throw new Error(`Expected updated member to belong to "${communityName}"`); + } + + const expectedProfile = await actor.answer(notes().get('lastExpectedMemberProfile')).catch(() => undefined); + if (!expectedProfile) { + return; + } + + const actualProfile = updatedMember.profile; + if (!actualProfile) { + throw new Error('Expected updated member profile to be returned by API, but profile was missing'); + } + + assertMemberProfileMatches(expectedProfile, actualProfile); +}); + +function assertMemberProfileMatches(expectedProfile: MemberProfileExpectation, actualProfile: NonNullable): void { + const comparableFields: Array> = ['name', 'email', 'bio', 'avatarDocumentId', 'showInterests', 'showEmail', 'showProfile', 'showLocation', 'showProperties']; + + for (const field of comparableFields) { + const expectedValue = expectedProfile[field]; + if (expectedValue === undefined) { + continue; + } + + const actualValue = actualProfile[field]; + if (actualValue !== expectedValue) { + throw new Error(`Expected profile ${field} to be "${String(expectedValue)}" but got "${String(actualValue ?? '')}"`); + } + } + + if (expectedProfile.interests !== undefined) { + const expectedInterests = JSON.stringify(expectedProfile.interests); + const actualInterests = JSON.stringify(actualProfile.interests ?? []); + if (actualInterests !== expectedInterests) { + throw new Error(`Expected profile interests ${expectedInterests} but got ${actualInterests}`); + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts new file mode 100644 index 000000000..5dac72300 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts @@ -0,0 +1,49 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { CreateMember as CreateMemberAbility, type CreateMemberDetails } from '../../../shared/abilities/create-member.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +interface CreateMemberTaskDetails { + memberName: string; + role?: string; + communityId?: string; +} + +export class CreateMember extends Task { + static withName(memberName: string) { + return new CreateMember({ memberName }); + } + + static with(details: CreateMemberTaskDetails) { + return new CreateMember(details); + } + + private constructor(private readonly details: CreateMemberTaskDetails) { + super(`creates a member named "${details.memberName}"`); + } + + async performAs(actor: Actor): Promise { + if (!this.details.communityId) { + throw new Error('communityId is required to create a member'); + } + + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.details.communityId)); + const createDetails: CreateMemberDetails = { + memberName: this.details.memberName, + communityId: this.details.communityId, + principalMemberId, + ...(this.details.role ? { role: this.details.role } : {}), + }; + const member = await CreateMemberAbility.as(actor).performAs(actor, createDetails); + + await actor.attemptsTo( + notes().set('lastMemberId', member.id ?? ''), + notes().set('lastMemberName', member.memberName), + notes().set('lastMemberProfileEmail', undefined), + notes().set('lastExpectedMemberProfileEmail', undefined), + notes().set('lastMemberStatus', 'SUCCESS'), + ); + } + + override toString = () => `creates a member named "${this.details.memberName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-profile.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-profile.ts new file mode 100644 index 000000000..54bf14696 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-profile.ts @@ -0,0 +1,48 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { type MemberProfileUpdate, UpdateMemberProfile as UpdateMemberProfileAbility } from '../../../shared/abilities/update-member-profile.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +interface UpdateMemberProfileTaskDetails { + profile: MemberProfileUpdate; + memberId?: string; + communityId?: string; +} + +export class UpdateMember extends Task { + static with(details: UpdateMemberProfileTaskDetails) { + return new UpdateMember(details); + } + + private constructor(private readonly details: UpdateMemberProfileTaskDetails) { + super('updates a member profile'); + } + + async performAs(actor: Actor): Promise { + const memberId = this.details.memberId ?? (await actor.answer(notes().get('lastMemberId'))); + if (!memberId) { + throw new Error('Cannot update member profile because no member id is available in notes'); + } + + const communityId = this.details.communityId ?? (await actor.answer(notes().get('lastMemberCommunityId'))); + if (!communityId) { + throw new Error('Cannot update member profile because no community id is available in notes'); + } + + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(communityId)); + const result = await UpdateMemberProfileAbility.as(actor).performAs(actor, { + memberId, + profile: this.details.profile, + communityId, + principalMemberId, + }); + + await actor.attemptsTo( + notes().set('lastMemberProfileEmail', result.profile?.email), + notes().set('lastExpectedMemberProfileEmail', this.details.profile.email), + notes().set('lastMemberStatus', 'SUCCESS'), + ); + } + + override toString = () => 'updates a member profile'; +} diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 7b162160a..877c805fc 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -1,6 +1,7 @@ import type { BlobUploadAuthorizationHeader, BlobUploadCommonResponse, CreateBlobAuthorizationHeaderRequest } from '@cellix/service-blob-storage'; import { type ApplicationServicesFactory, buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; +import { RegisterEventHandlers } from '@ocom/event-handler'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; @@ -19,6 +20,7 @@ type EndUserUpdateQueueTriggerMetadata = Parameters>; const communityCreationMessages: RecordedCommunityCreationMessage[] = []; +let eventHandlersRegistered = false; function createMockTokenValidation(): TokenValidation { return { @@ -105,12 +107,20 @@ function createRecordingQueueStorageService(): QueueStorageOperations { ); }, receiveFromEndUserUpdateQueue(payload: unknown, metadata?: EndUserUpdateQueueTriggerMetadata) { - return Promise.resolve({ + const message: EndUserUpdateQueueMessage = { id: metadata?.id ?? '', - ...(metadata?.popReceipt !== undefined ? { popReceipt: metadata.popReceipt } : {}), payload: payload as EndUserUpdatePayload, - ...(metadata?.dequeueCount !== undefined ? { dequeueCount: metadata.dequeueCount } : {}), - } satisfies EndUserUpdateQueueMessage); + }; + + if (metadata?.popReceipt != null) { + message.popReceipt = metadata.popReceipt; + } + + if (metadata?.dequeueCount != null) { + message.dequeueCount = metadata.dequeueCount; + } + + return Promise.resolve(message); }, peekAtEndUserUpdateQueue() { return Promise.resolve([]); @@ -120,6 +130,10 @@ function createRecordingQueueStorageService(): QueueStorageOperations { export function createMockApplicationServicesFactory(serviceMongoose: ServiceMongoose): ApplicationServicesFactory { const dataSourcesFactory = Persistence(serviceMongoose); + if (!eventHandlersRegistered) { + RegisterEventHandlers(dataSourcesFactory.withSystemPassport().domainDataSource); + eventHandlersRegistered = true; + } const blobStorageService = createNoOpBlobStorageService(); const clientOperationsService = createNoOpClientOperationsService(); const queueStorageService = createRecordingQueueStorageService(); diff --git a/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts b/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts index 5c3ce3677..3b600d72c 100644 --- a/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts +++ b/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts @@ -1,5 +1,5 @@ import { ApolloGraphQLTestServer, type TestServer } from '@cellix/serenity-framework/servers'; -import type { ApplicationServices } from '@ocom/application-services'; +import type { ApplicationServices, PrincipalHints } from '@ocom/application-services'; import { combinedSchema } from '@ocom/graphql'; import depthLimit from 'graphql-depth-limit'; import { applyMiddleware } from 'graphql-middleware'; @@ -20,7 +20,12 @@ class ApiGraphQLTestServer implements TestServer { validationRules: [depthLimit(10)], context: async ({ req }) => { this.applicationServicesFactory ??= createMockApplicationServicesFactory(mongooseTestServer.getService()); - const applicationServices = await this.applicationServicesFactory.forRequest(req.headers.authorization ?? undefined); + const requestHints: PrincipalHints = { + memberId: Array.isArray(req.headers['x-member-id']) ? req.headers['x-member-id'][0] : req.headers['x-member-id'], + communityId: Array.isArray(req.headers['x-community-id']) ? req.headers['x-community-id'][0] : req.headers['x-community-id'], + }; + const hints = requestHints.memberId || requestHints.communityId ? requestHints : undefined; + const applicationServices = await this.applicationServicesFactory.forRequest(req.headers.authorization ?? undefined, hints); if (!applicationServices) { throw new Error('ApplicationServicesFactory required for test server'); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts new file mode 100644 index 000000000..3b0f411c1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts @@ -0,0 +1,90 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { MEMBER_CREATE_MUTATION } from '../graphql/member-operations.ts'; + +/** Member details accepted by API verification flows. */ +export interface CreateMemberDetails { + memberName: string; + role?: string; + communityId: string; + principalMemberId: string; +} + +/** Result returned by the API member creation flow. */ +interface CreateMemberResult { + id?: string; + memberName: string; +} + +/** Handler that performs member creation for an API actor. */ +type CreateMemberHandler = (actor: Actor, details: CreateMemberDetails) => Promise; + +type ExecuteWithRequestOptions = = Record>( + query: string, + variables?: Record, + options?: { headers?: Record }, +) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; + +/** + * Serenity ability that creates members through the API verification server. + */ +export class CreateMember extends Ability { + constructor(private readonly handler: CreateMemberHandler) { + super(); + } + + static using(handler: CreateMemberHandler): CreateMember { + return new CreateMember(handler); + } + + async performAs(actor: Actor, details: CreateMemberDetails): Promise { + return await this.handler(actor, details); + } +} + +export function createMemberAbility(): CreateMember { + return CreateMember.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + if (!details.communityId) { + throw new Error('communityId is required to create a member'); + } + if (!details.principalMemberId) { + throw new Error('principalMemberId is required to create a member'); + } + + const response = await graphql.execute( + MEMBER_CREATE_MUTATION, + { + input: { + memberName: details.memberName, + communityId: details.communityId, + }, + }, + { + headers: { + 'x-community-id': details.communityId, + 'x-member-id': details.principalMemberId, + }, + }, + ); + + const mutationResult = response.data?.['memberCreate'] as Record | undefined; + const status = mutationResult?.['status'] as Record | undefined; + const member = mutationResult?.['member'] as Record | undefined; + + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? 'Failed to create member')); + } + + const memberId = String(member?.['id'] ?? ''); + const createdName = String(member?.['memberName'] ?? ''); + if (!memberId) { + throw new Error('API memberCreate returned a member without an id'); + } + + return { + id: memberId, + memberName: createdName || details.memberName, + }; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index 2ed5ecf3a..09852394f 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -1,2 +1,3 @@ export { createCommunityAbility } from './create-community.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; +export { updateMemberProfileAbility } from './update-member-profile.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-profile.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-profile.ts new file mode 100644 index 000000000..0943cc6e6 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-profile.ts @@ -0,0 +1,121 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { MEMBER_UPDATE_PROFILE_MUTATION } from '../graphql/member-operations.ts'; + +export interface MemberProfileUpdate { + name?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string[]; + showInterests?: boolean; + showEmail?: boolean; + showProfile?: boolean; + showLocation?: boolean; + showProperties?: boolean; +} + +interface UpdateMemberProfileDetails { + memberId: string; + profile: MemberProfileUpdate; + communityId: string; + principalMemberId: string; +} + +interface UpdateMemberProfileResult { + id?: string; + profile?: MemberProfileUpdate; +} + +type UpdateMemberProfileHandler = (actor: Actor, details: UpdateMemberProfileDetails) => Promise; + +type ExecuteWithRequestOptions = = Record>( + query: string, + variables?: Record, + options?: { headers?: Record }, +) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; + +export class UpdateMemberProfile extends Ability { + constructor(private readonly handler: UpdateMemberProfileHandler) { + super(); + } + + static using(handler: UpdateMemberProfileHandler): UpdateMemberProfile { + return new UpdateMemberProfile(handler); + } + + async performAs(actor: Actor, details: UpdateMemberProfileDetails): Promise { + return await this.handler(actor, details); + } +} + +export function updateMemberProfileAbility(): UpdateMemberProfile { + return UpdateMemberProfile.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + if (!details.communityId) { + throw new Error('communityId is required to update a member profile'); + } + if (!details.memberId) { + throw new Error('memberId is required to update a member profile'); + } + if (!details.profile || Object.keys(details.profile).length === 0) { + throw new Error('At least one profile field is required to update a member profile'); + } + if (!details.principalMemberId) { + throw new Error('principalMemberId is required to update a member profile'); + } + + const profileResponse = await graphql.execute( + MEMBER_UPDATE_PROFILE_MUTATION, + { + input: { + memberId: details.memberId, + profile: { + ...(details.profile.name !== undefined ? { name: details.profile.name } : {}), + ...(details.profile.email !== undefined ? { email: details.profile.email } : {}), + ...(details.profile.bio !== undefined ? { bio: details.profile.bio } : {}), + ...(details.profile.avatarDocumentId !== undefined ? { avatarDocumentId: details.profile.avatarDocumentId } : {}), + ...(details.profile.interests !== undefined ? { interests: details.profile.interests } : {}), + ...(details.profile.showInterests !== undefined ? { showInterests: details.profile.showInterests } : {}), + ...(details.profile.showEmail !== undefined ? { showEmail: details.profile.showEmail } : {}), + ...(details.profile.showProfile !== undefined ? { showProfile: details.profile.showProfile } : {}), + ...(details.profile.showLocation !== undefined ? { showLocation: details.profile.showLocation } : {}), + ...(details.profile.showProperties !== undefined ? { showProperties: details.profile.showProperties } : {}), + }, + }, + }, + { + headers: { + 'x-community-id': details.communityId, + 'x-member-id': details.principalMemberId, + }, + }, + ); + + const updateResult = profileResponse.data?.['memberUpdateProfile'] as Record | undefined; + const updateStatus = updateResult?.['status'] as Record | undefined; + if (updateStatus?.['success'] !== true) { + throw new Error(String(updateStatus?.['errorMessage'] ?? 'Failed to update member profile')); + } + + const updatedMember = updateResult?.['member'] as Record | undefined; + const profile = updatedMember?.['profile'] as Record | undefined; + const interests = Array.isArray(profile?.['interests']) ? (profile?.['interests'] as Array).map((value) => String(value)).filter((value) => value.length > 0) : undefined; + + return { + id: String(updatedMember?.['id'] ?? details.memberId), + profile: { + ...(profile?.['name'] !== undefined ? { name: String(profile['name'] ?? '') } : {}), + ...(profile?.['email'] !== undefined ? { email: String(profile['email'] ?? '') } : {}), + ...(profile?.['bio'] !== undefined ? { bio: String(profile['bio'] ?? '') } : {}), + ...(profile?.['avatarDocumentId'] !== undefined ? { avatarDocumentId: String(profile['avatarDocumentId'] ?? '') } : {}), + ...(interests !== undefined ? { interests } : {}), + ...(profile?.['showInterests'] !== undefined ? { showInterests: Boolean(profile['showInterests']) } : {}), + ...(profile?.['showEmail'] !== undefined ? { showEmail: Boolean(profile['showEmail']) } : {}), + ...(profile?.['showProfile'] !== undefined ? { showProfile: Boolean(profile['showProfile']) } : {}), + ...(profile?.['showLocation'] !== undefined ? { showLocation: Boolean(profile['showLocation']) } : {}), + ...(profile?.['showProperties'] !== undefined ? { showProperties: Boolean(profile['showProperties']) } : {}), + }, + }; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts new file mode 100644 index 000000000..98b73b810 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts @@ -0,0 +1,84 @@ +export const MEMBER_CREATE_MUTATION = ` + mutation MemberCreate($input: MemberCreateInput!) { + memberCreate(input: $input) { + status { + success + errorMessage + } + member { + id + memberName + } + } + } +`; + +export const MEMBER_UPDATE_PROFILE_MUTATION = ` + mutation MemberUpdateProfile($input: MemberUpdateProfileInput!) { + memberUpdateProfile(input: $input) { + status { + success + errorMessage + } + member { + id + profile { + name + email + bio + avatarDocumentId + interests + showInterests + showEmail + showProfile + showLocation + showProperties + } + } + } + } +`; + +export const MEMBERS_FOR_CURRENT_END_USER_QUERY = ` + query MembersForCurrentEndUser { + membersForCurrentEndUser { + id + community { + id + } + } + } +`; + +export const MEMBERS_BY_COMMUNITY_QUERY = ` + query MembersByCommunity($communityId: ObjectID!) { + membersByCommunityId(communityId: $communityId) { + id + memberName + } + } +`; + +export const MEMBER_BY_ID_QUERY = ` + query MemberById($id: ObjectID!) { + member(id: $id) { + id + memberName + community { + id + } + profile { + name + email + bio + avatarDocumentId + interests + showInterests + showEmail + showProfile + showLocation + showProperties + } + } + } +`; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index a19acf271..0e475c95c 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -4,7 +4,9 @@ import { SerenityCast } from '@cellix/serenity-framework/serenity'; import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; import { infrastructure } from './infrastructure.ts'; import { createCommunityAbility } from './shared/abilities/create-community.ts'; +import { createMemberAbility } from './shared/abilities/create-member.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; +import { updateMemberProfileAbility } from './shared/abilities/update-member-profile.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ infrastructure, @@ -16,7 +18,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility()], + abilities: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility(), () => createMemberAbility(), () => updateMemberProfileAbility()], }), }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts new file mode 100644 index 000000000..ab6a57111 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts @@ -0,0 +1,10 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; + +export const MemberBelongsToCommunity = (expectedMemberName: string, expectedCommunityName: string) => + Question.about(`whether member "${expectedMemberName}" belongs to "${expectedCommunityName}"`, async (actor) => { + const actualMemberName = await actor.answer(notes().get('lastMemberName')); + const actualCommunityName = await actor.answer(notes().get('lastMemberCommunityName')); + + return actualMemberName === expectedMemberName && actualCommunityName === expectedCommunityName; + }); diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature new file mode 100644 index 000000000..b859a468a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature @@ -0,0 +1,28 @@ +Feature: Community member authorization and scope boundaries + + As a community owner or administrator + I want member actions to respect authorization and community scope boundaries + So that only permitted users can act and shared end-user accounts remain correctly scoped per community + + Background: + Given Alice is signed in as a community owner for member management + And Alice has a community named "Green Oaks" + And Alice has a community named "Blue Harbor" + And Charlie is an existing end-user account + + # @authorization @unimplemented + # Scenario: Unauthorized actor cannot manage community members + # Given Evan is an authenticated end-user without member-management permission in "Green Oaks" + # When Evan attempts to delete member "Charlie Walker" from "Green Oaks" + # Then Evan should receive an authorization error for member management + # And member "Charlie Walker" should remain in "Green Oaks" + + # @unimplemented + # Scenario: A single end-user account can belong to multiple communities + # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # And a member "Charlie Walker" exists in "Blue Harbor" with role "resident" + # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + # And Alice associates end-user account "Charlie" to member "Charlie Walker" in "Blue Harbor" + # Then end-user account "Charlie" should be linked to a member in "Green Oaks" + # And end-user account "Charlie" should be linked to a member in "Blue Harbor" + # And each member association should remain scoped to its own community \ No newline at end of file diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature new file mode 100644 index 000000000..5d05cc407 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -0,0 +1,94 @@ +Feature: Community member lifecycle operations + + As a community owner or administrator + I want to create, update, associate accounts to, list, filter, and delete members within a specific community + So that member records remain accurate and user-to-community access stays correctly scoped + + Background: + Given Alice is signed in as a community owner for member management + And Alice has a community named "Green Oaks" + And Alice has a community named "Blue Harbor" + And Charlie is an existing end-user account + + Scenario: Create a member record in a community + When Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + Then the member should be created successfully in "Green Oaks" + And the member "Charlie Walker" should belong to "Green Oaks" + + # Scenario: Update all member profile fields within a community + # Given Alice creates a member in "Green Oaks" with: + # | memberName | Charlie Walker | + # When Alice updates member "Charlie Walker" in "Green Oaks" with: + # | name | Charles Walker | + # | email | charles.walker@example.com | + # | bio | Community gardener and volunteer | + # | avatarDocumentId | avatar-charles-001 | + # | interests | gardening,reading,volunteering | + # | showInterests | true | + # | showEmail | true | + # | showProfile | true | + # | showLocation | false | + # | showProperties | true | + # Then the member should be updated successfully in "Green Oaks" + + # @unimplemented + # Scenario: Associate an existing end-user account to a member + # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + # Then member "Charlie Walker" should be linked to end-user account "Charlie" + + # @unimplemented + # Scenario: List community members for owner and admin perspectives + # Given Bob is an authenticated community admin for "Green Oaks" + # And the following members exist in "Green Oaks": + # | memberName | role | + # | Charlie Walker | resident | + # | Dana Ortiz | board member | + # When Bob lists members for "Green Oaks" + # Then Bob should see 2 members for "Green Oaks" + # And Bob should not see members from "Blue Harbor" + + # @unimplemented + # Scenario: Filter community members by role + # Given the following members exist in "Green Oaks": + # | memberName | role | + # | Charlie Walker | resident | + # | Dana Ortiz | board member | + # | Erin Lawson | resident | + # When Alice filters members in "Green Oaks" by role "resident" + # Then Alice should see the following members in "Green Oaks": + # | memberName | + # | Charlie Walker | + # | Erin Lawson | + + # @unimplemented + # Scenario: Delete a member from a community + # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # When Alice deletes member "Charlie Walker" from "Green Oaks" + # Then the member should be deleted successfully from "Green Oaks" + # And member "Charlie Walker" should not appear in member listings for "Green Oaks" + + # @unimplemented + # Scenario: Update member name while preserving existing account association + # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # And member "Charlie Walker" is already linked to end-user account "Charlie" + # When Alice updates member "Charlie Walker" in "Green Oaks" with: + # | memberName | Charles Walker | + # Then the member should be updated successfully in "Green Oaks" + # And member "Charles Walker" should be linked to end-user account "Charlie" + + # @validation @unimplemented + # Scenario: Cannot create a member without a required name + # When Alice attempts to create a member in "Green Oaks" with: + # | memberName | | + # Then she should see a member error for "memberName" + # And no new member should be created in "Green Oaks" + + # @validation @unimplemented + # Scenario: Cannot associate the same end-user account twice to the same member + # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # And member "Charlie Walker" is already linked to end-user account "Charlie" + # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + # Then she should see a member error for "accountAssociation" + # And member "Charlie Walker" should remain linked to end-user account "Charlie" only once \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb196c84..ad12b03ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,7 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 + body-parser: 2.3.0 esbuild: 0.28.1 follow-redirects: ^1.16.0 vite: 8.0.16 @@ -136,7 +137,6 @@ overrides: diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 - protobufjs@7.5.4: 7.6.3 serve-handler>minimatch: 3.1.5 serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 @@ -161,12 +161,12 @@ overrides: playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ip-address: ^10.1.1 fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - js-yaml@4.1.1: 4.2.0 + js-yaml@>=4.0.0: 4.3.0 shell-quote@<1.8.4: 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 @@ -1335,6 +1335,9 @@ importers: '@ocom/data-sources-mongoose-models': specifier: workspace:* version: link:../../ocom/data-sources-mongoose-models + '@ocom/event-handler': + specifier: workspace:* + version: link:../../ocom/event-handler '@ocom/graphql': specifier: workspace:* version: link:../../ocom/graphql @@ -6175,9 +6178,6 @@ packages: '@protobufjs/inquire@1.1.1': resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -7875,12 +7875,8 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} bonjour-service@1.3.0: @@ -8379,6 +8375,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + continuation-local-storage@3.2.1: resolution: {integrity: sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==} @@ -9734,10 +9734,6 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -9746,6 +9742,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -10200,12 +10200,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsbi@4.3.2: @@ -12048,8 +12048,8 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -12122,10 +12122,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -13436,9 +13432,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -14445,7 +14441,7 @@ snapshots: '@apollo/utils.withrequired': 3.0.0 '@graphql-tools/schema': 10.0.30(graphql@16.12.0) async-retry: 1.3.3 - body-parser: 2.2.2(supports-color@8.1.1) + body-parser: 2.3.0 content-type: 1.0.5 cors: 2.8.5 finalhandler: 2.1.1(supports-color@8.1.1) @@ -16487,7 +16483,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.2 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -16933,7 +16929,7 @@ snapshots: '@docusaurus/utils-common': 3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 joi: 17.13.4 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 tslib: 2.8.1 transitivePeerDependencies: @@ -16958,7 +16954,7 @@ snapshots: globby: 11.1.0 gray-matter: 4.0.3 jiti: 2.6.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 micromatch: 4.0.8 p-queue: 6.6.2 @@ -17534,7 +17530,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 jose: 5.10.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 scuid: 1.1.0 tslib: 2.8.1 @@ -17631,7 +17627,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 yargs: 17.7.2 '@hapi/hoek@9.3.0': {} @@ -18073,7 +18069,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - protobufjs: 7.6.3 + protobufjs: 7.6.5 '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -18646,8 +18642,6 @@ snapshots: '@protobufjs/inquire@1.1.1': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -20625,34 +20619,17 @@ snapshots: bn.js@5.2.3: {} - body-parser@1.20.5: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - body-parser@2.2.2(supports-color@8.1.1): - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 on-finished: 2.4.1 qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -21192,6 +21169,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + continuation-local-storage@3.2.1: dependencies: async-listener: 0.6.10 @@ -21237,7 +21216,7 @@ snapshots: cosmiconfig@8.3.6(typescript@6.0.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -22019,7 +21998,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.5 + body-parser: 2.3.0 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -22526,7 +22505,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.14.2 + js-yaml: 3.15.0 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -22865,15 +22844,15 @@ snapshots: hyperdyperid@1.2.0: {} - iconv-lite@0.4.24: + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -23283,12 +23262,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -23416,7 +23395,7 @@ snapshots: fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimist: 1.2.8 oxc-resolver: 11.14.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picocolors: 1.1.1 @@ -25585,7 +25564,7 @@ snapshots: proto-list@1.2.4: {} - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -25593,7 +25572,6 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -25664,13 +25642,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -27162,9 +27133,9 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b48bb6c08..d62706a97 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -74,6 +74,7 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 + body-parser: 2.3.0 esbuild: 'catalog:' follow-redirects: ^1.16.0 vite: "catalog:" @@ -84,7 +85,6 @@ overrides: 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 - 'protobufjs@7.5.4': 7.6.3 'serve-handler>minimatch': 3.1.5 'serialize-javascript@6.0.2': 7.0.5 'serialize-javascript@7.0.4': 7.0.5 @@ -109,12 +109,12 @@ overrides: playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ip-address: ^10.1.1 fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - 'js-yaml@4.1.1': 4.2.0 + 'js-yaml@>=4.0.0': 4.3.0 'shell-quote@<1.8.4': 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 From 48b083d74f57a78cd95146bde99eafaaac5374f4 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Fri, 10 Jul 2026 16:42:01 -0400 Subject: [PATCH 02/15] .snyk file change --- .snyk | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.snyk b/.snyk index 1e83d06a5..676d192de 100644 --- a/.snyk +++ b/.snyk @@ -154,15 +154,6 @@ ignore: reason: 'No upgrade or patch available per Snyk; overridden to 17.13.4 which fixes the pnpm advisory. Accepting residual Snyk finding until a patched release is published.' expires: '2026-09-16T00:00:00.000Z' created: '2026-06-16T00:00:00.000Z' - 'SNYK-JS-JSYAML-17342520': - - '* > js-yaml@3.14.2': - reason: 'js-yaml 3.x→4.x is a breaking major change for all transitive consumers (gray-matter/Docusaurus chain). js-yaml@4.1.1 patched to 4.2.0 via pnpm override; 3.x path has no safe upgrade.' - expires: '2026-09-16T00:00:00.000Z' - created: '2026-06-16T00:00:00.000Z' - - '* > js-yaml': - reason: 'Transitive in Docusaurus build tooling (gray-matter pins js-yaml@3.x; fix is in 4.2.0, a major bump blocked by gray-matter). Build-time parsing of repository-controlled input only.' - expires: '2026-09-18T00:00:00.000Z' - created: '2026-06-18T00:00:00.000Z' 'SNYK-JS-OPENTELEMETRYCORE-17373280': - '* > @opentelemetry/core': reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' @@ -173,3 +164,13 @@ ignore: reason: 'Transitive in Docusaurus serve-handler for local static docs serving. The fixed brace-expansion 5.x line requires minimatch 10, whose named-export API is incompatible with serve-handler 6.1.7.' expires: '2026-09-30T00:00:00.000Z' created: '2026-06-30T00:00:00.000Z' + 'SNYK-JS-OPENTELEMETRYPROPAGATORJAEGER-17901201': + - '* > @opentelemetry/propagator-jaeger@<=1.30.1': + reason: 'The transitive dependency of @opentelemetry does not have a fixed upgrade path. Accepted temporarily until upgrade paths are made available.' + expires: '2026-07-18T00:00:00.000Z' + created: '2026-06-18T00:00:00.000Z' + 'SNYK-JS-JSYAML-17342520': + - '* > js-yaml@3.15.0': + reason: 'Transitive dependency in Docusaurus via gray-matter@4.0.3 which requires js-yaml 3.x API (safeLoad). Forcing js-yaml 4.x (where fix lands) breaks gray-matter at runtime and crashes docs build. Blocked until gray-matter publishes a release compatible with js-yaml 4.x. Not exploitable in current usage (build-time YAML front matter parsing of trusted repository files only).' + expires: '2026-10-10T00:00:00.000Z' + created: '2026-07-10T00:00:00.000Z' \ No newline at end of file From 33f3343ae7cdb366d574f4d099775288e6427e07 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Fri, 10 Jul 2026 17:04:03 -0400 Subject: [PATCH 03/15] add initial acceptance-ui and e2e steps --- .../contexts/community/notes/member-notes.ts | 7 + .../questions/member-created-flag.ts | 4 + .../step-definitions/create-member.steps.tsx | 118 +++++++++++++ .../community/step-definitions/index.ts | 1 + .../contexts/community/tasks/create-member.ts | 25 +++ .../src/shared/page-contracts.ts | 4 +- .../contexts/community/notes/member-notes.ts | 27 +++ .../questions/member-belongs-to-community.ts | 26 +++ .../questions/member-created-flag.ts | 4 + .../step-definitions/create-member.steps.ts | 134 +++++++++++++++ .../community/step-definitions/index.ts | 2 + .../step-definitions/update-member.steps.ts | 85 ++++++++++ .../support/community-admin-navigation.ts | 71 ++++++++ .../contexts/community/tasks/create-member.ts | 159 ++++++++++++++++++ .../community/tasks/update-member-profile.ts | 149 ++++++++++++++++ .../e2e-tests/src/shared/page-contracts.ts | 9 +- .../verification-shared/src/pages/index.ts | 2 + .../src/pages/member-create.page.ts | 27 +++ .../src/pages/member-profile.page.ts | 85 ++++++++++ 19 files changed, 937 insertions(+), 2 deletions(-) create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-created-flag.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-member.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/member-create.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts new file mode 100644 index 000000000..3edb36d38 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,7 @@ +export interface MemberUiNotes { + communityIdsByName: Record; + endUserExternalIdsByLabel: Record; + lastMemberCommunityName: string; + lastMemberName: string; + memberCreated: boolean; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-created-flag.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-created-flag.ts new file mode 100644 index 000000000..f0f90091d --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-created-flag.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; + +export const MemberCreatedFlag = () => Question.about('whether the member form was submitted', (actor) => actor.answer(notes().get('memberCreated'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx new file mode 100644 index 000000000..34daf7375 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -0,0 +1,118 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import React from 'react'; +import { MembersCreate } from '../../../../../../ocom/ui-community-route-admin/src/components/members-create.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; +import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; +import { MemberCreatedFlag } from '../questions/member-created-flag.ts'; +import { CreateMember } from '../tasks/create-member.ts'; + +const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; +reactGlobal.React ??= React; + +interface MemberDetails { + memberName?: string; +} + +let lastActorName = 'Alice'; + +const toCommunityId = (communityName: string): string => + communityName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +Given('{word} is signed in as a community owner for member management', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + + await actor.attemptsTo( + notes().set('communityIdsByName', {}), + notes().set('endUserExternalIdsByLabel', {}), + notes().set('memberCreated', false), + notes().set('lastMemberName', ''), + notes().set('lastMemberCommunityName', ''), + ); +}); + +Given('{word} has a community named {string}', async (actorName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + + const existing = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + + await actor.attemptsTo( + notes().set('communityIdsByName', { + ...existing, + [communityName]: toCommunityId(communityName), + }), + ); +}); + +Given('{word} is an existing end-user account', async (accountLabel: string) => { + const actor = actorCalled(lastActorName); + const existing = (await actor.answer(notes().get('endUserExternalIdsByLabel')).catch(() => ({}) as Record)) as Record; + + await actor.attemptsTo( + notes().set('endUserExternalIdsByLabel', { + ...existing, + [accountLabel]: `end-user-${accountLabel.trim().toLowerCase()}`, + }), + ); +}); + +When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + const memberName = details.memberName?.trim() ?? ''; + + if (!memberName) { + throw new Error('memberName is required'); + } + + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const onSave = async (values: { memberName?: string }): Promise => { + await actor.attemptsTo(notes().set('memberCreated', true), notes().set('lastMemberName', values.memberName?.trim() ?? ''), notes().set('lastMemberCommunityName', communityName)); + }; + + await actor.attemptsTo( + notes().set('memberCreated', false), + notes().set('lastMemberName', ''), + notes().set('lastMemberCommunityName', ''), + Render.component(, { wrapper: wrapOcomComponent() }), + CreateMember(memberName), + ); +}); + +Then('the member should be created successfully in {string}', async (communityName: string) => { + const actor = actorInTheSpotlight(); + const submitted = await actor.answer(MemberCreatedFlag()); + const actualCommunityName = await actor.answer(notes().get('lastMemberCommunityName')); + + if (!submitted) { + throw new Error(`Expected member creation to succeed for "${communityName}"`); + } + + if (actualCommunityName !== communityName) { + throw new Error(`Expected member to be created in "${communityName}" but got "${actualCommunityName}"`); + } +}); + +Then('the member {string} should belong to {string}', async (memberName: string, communityName: string) => { + const actor = actorInTheSpotlight(); + const belongs = await actor.answer(MemberBelongsToCommunity(memberName, communityName)); + + if (!belongs) { + throw new Error(`Expected member "${memberName}" to belong to "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index bc657d814..29f08f668 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,3 @@ // Community context step definitions import './create-community.steps.tsx'; +import './create-member.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-member.ts new file mode 100644 index 000000000..13105c850 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/create-member.ts @@ -0,0 +1,25 @@ +import { 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 { MemberCreatePage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiMemberCreatePage } from '../../../shared/page-contracts.ts'; + +/** Let the form's async `onFinish` callback settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +export const CreateMember = (memberName: string): Task => + Task.where( + `#actor creates a member named "${memberName}"`, + new TaskStep(`#actor fills the member name "${memberName}" and submits`, async (actor) => { + const page: AcceptanceUiMemberCreatePage = new MemberCreatePage(new DomPageAdapter(RenderInDom.as(actor).container)); + + await page.fillMemberName(memberName); + await page.clickCreateMember(); + + await flushPendingReactWork(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index b7512789f..e109df9ed 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -1,5 +1,7 @@ -import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage, MemberCreatePage } from '@ocom-verification/verification-shared/pages'; export type AcceptanceUiHomePage = Pick; export type AcceptanceUiCommunityPage = Pick; + +export type AcceptanceUiMemberCreatePage = Pick; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts new file mode 100644 index 000000000..9b8ba42e4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,27 @@ +export interface MemberProfileExpectation { + name?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string[]; + showInterests?: boolean; + showEmail?: boolean; + showProfile?: boolean; + showLocation?: boolean; + showProperties?: boolean; +} + +export interface MemberE2ENotes { + communityIdsByName: Record; + endUserEmailsByLabel: Record; + endUserExternalIdsByLabel: Record; + lastMemberCommunityId: string | null; + lastMemberId: string | null; + lastMemberName: string; + lastExpectedMemberProfile?: MemberProfileExpectation; + lastUpdatedMemberProfile?: MemberProfileExpectation; + lastMemberStatus: string | null; + memberUpdated: boolean; + memberCreated: boolean; + errorMessage: string | null; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts new file mode 100644 index 000000000..079f8483e --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts @@ -0,0 +1,26 @@ +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { gotoCommunityMembersListPage } from '../support/community-admin-navigation.ts'; + +export const MemberBelongsToCommunity = (memberName: string, communityName: string) => + Question.about(`whether member "${memberName}" belongs to community "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const expectedCommunityId = communityIdsByName[communityName]; + if (!expectedCommunityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const route = await gotoCommunityMembersListPage(page, communityName); + if (route.communityId !== expectedCommunityId) { + return false; + } + + const memberCell = page.getByRole('cell', { name: memberName, exact: true }).first(); + await memberCell.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined); + + return memberCell.isVisible().catch(() => false); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts new file mode 100644 index 000000000..1ab7dd553 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const MemberCreatedFlag = () => Question.about('whether the member was created', (actor) => actor.answer(notes().get('memberCreated'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts new file mode 100644 index 000000000..51eb733b1 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts @@ -0,0 +1,134 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import { clearKnownQueueMessages } from '../../../shared/support/queue-storage.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; +import { MemberCreatedFlag } from '../questions/member-created-flag.ts'; +import { CreateCommunity } from '../tasks/create-community.ts'; +import { CreateMember } from '../tasks/create-member.ts'; + +interface MemberDetails { + memberName?: string; +} + +const seededEndUserLabelMap: Record = { + Charlie: 'CommunityMember', +}; + +let lastActorName = actors.CommunityOwner.name; + +Given('{word} is signed in as a community owner for member management', async (actorName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + + await clearKnownQueueMessages(); + await actor.attemptsTo(LogInWithOAuth2(actors.CommunityOwner.email)); +}); + +Given('{word} has a community named {string}', async (actorName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + + await actor.attemptsTo(CreateCommunity(communityName)); + + const communityId = (await actor.answer(notes().get('communityId'))) as string | null; + if (!communityId) { + throw new Error(`Expected created community id for "${communityName}" but none was recorded`); + } + + const existingCommunityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + await actor.attemptsTo( + notes().set('communityIdsByName', { + ...existingCommunityIdsByName, + [communityName]: communityId, + }), + ); +}); + +Given('{word} is an existing end-user account', async (label: string) => { + const actorKey = seededEndUserLabelMap[label]; + if (!actorKey) { + throw new Error(`No seeded end-user mapping for label "${label}"`); + } + + const seeded = actors[actorKey]; + const actor = actorCalled(lastActorName); + const existingExternalIds = (await actor.answer(notes().get('endUserExternalIdsByLabel')).catch(() => ({}) as Record)) as Record; + const existingEmails = (await actor.answer(notes().get('endUserEmailsByLabel')).catch(() => ({}) as Record)) as Record; + + await actor.attemptsTo( + notes().set('endUserExternalIdsByLabel', { + ...existingExternalIds, + [label]: seeded.externalId, + }), + notes().set('endUserEmailsByLabel', { + ...existingEmails, + [label]: seeded.email, + }), + ); +}); + +When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + + const details = GherkinDataTable.from(dataTable).rowsHash(); + const memberName = details.memberName?.trim() ?? ''; + if (!memberName) { + throw new Error('memberName is required'); + } + + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', null), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', null), + ); + + await actor.attemptsTo(CreateMember(communityName, memberName)); +}); + +Then('the member should be created successfully in {string}', async (communityName: string) => { + const actor = actorCalled(lastActorName); + + const memberCreated = await actor.answer(MemberCreatedFlag()); + const validationError = await actor.answer(notes().get('errorMessage')).catch(() => null); + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const expectedCommunityId = communityIdsByName[communityName]; + const actualCommunityId = (await actor.answer(notes().get('lastMemberCommunityId')).catch(() => null)) as string | null; + const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; + + if (!expectedCommunityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + if (validationError) { + throw new Error(`Expected member creation to succeed for "${communityName}", but got: "${validationError}"`); + } + + if (!memberCreated) { + throw new Error(`Expected member creation to succeed for "${communityName}"`); + } + + if (!memberId) { + throw new Error(`Expected a created member id for "${communityName}" but none was recorded`); + } + + if (actualCommunityId !== expectedCommunityId) { + throw new Error(`Expected created member communityId "${expectedCommunityId}" but got "${actualCommunityId ?? 'null'}"`); + } +}); + +Then('the member {string} should belong to {string}', async (memberName: string, communityName: string) => { + const actor = actorCalled(lastActorName); + const belongs = await actor.answer(MemberBelongsToCommunity(memberName, communityName)); + + if (!belongs) { + throw new Error(`Expected member "${memberName}" to belong to "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index c04c54d61..46874774b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,4 @@ // Community context step definitions import './create-community.steps.ts'; +import './create-member.steps.ts'; +import './update-member.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts new file mode 100644 index 000000000..563bdccb6 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts @@ -0,0 +1,85 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { UpdateMemberProfile } from '../tasks/update-member-profile.ts'; + +let lastActorName = actors.CommunityOwner.name; + +type MemberProfileUpdateDetails = Partial>; + +const parseBoolean = (value: string): boolean => value.trim().toLowerCase() === 'true'; + +When('{word} updates member {string} in {string} with:', async (actorName: string, memberName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const expectedProfile: MemberProfileExpectation = { + ...(details.name ? { name: details.name } : {}), + ...(details.memberName ? { name: details.memberName } : {}), + ...(details.email ? { email: details.email } : {}), + ...(details.bio ? { bio: details.bio } : {}), + ...(details.avatarDocumentId ? { avatarDocumentId: details.avatarDocumentId } : {}), + ...(details.interests + ? { + interests: details.interests + .split(',') + .map((interest) => interest.trim()) + .filter((interest) => interest.length > 0), + } + : {}), + ...(details.showInterests !== undefined ? { showInterests: parseBoolean(details.showInterests) } : {}), + ...(details.showEmail !== undefined ? { showEmail: parseBoolean(details.showEmail) } : {}), + ...(details.showProfile !== undefined ? { showProfile: parseBoolean(details.showProfile) } : {}), + ...(details.showLocation !== undefined ? { showLocation: parseBoolean(details.showLocation) } : {}), + ...(details.showProperties !== undefined ? { showProperties: parseBoolean(details.showProperties) } : {}), + }; + + await actor.attemptsTo(notes().set('lastMemberStatus', null), notes().set('memberUpdated', false), notes().set('errorMessage', null)); + + await actor.attemptsTo( + UpdateMemberProfile({ + communityName, + memberName, + profile: expectedProfile, + }), + ); +}); + +Then('the member should be updated successfully in {string}', async (communityName: string) => { + const actor = actorCalled(lastActorName); + + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const status = (await actor.answer(notes().get('lastMemberStatus')).catch(() => null)) as string | null; + const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; + const memberUpdated = (await actor.answer(notes().get('memberUpdated')).catch(() => false)) as boolean; + const updateError = (await actor.answer(notes().get('errorMessage')).catch(() => null)) as string | null; + + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + if (updateError) { + throw new Error(`Expected member update to succeed for "${communityName}", but got: "${updateError}"`); + } + + if (!memberUpdated) { + throw new Error(`Expected member update to succeed for "${communityName}"`); + } + + if (status !== 'SUCCESS') { + throw new Error(`Expected member update status "SUCCESS" but got "${status ?? 'null'}"`); + } + + if (!memberId) { + throw new Error(`Expected an updated member id for "${communityName}" but none was recorded`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts new file mode 100644 index 000000000..a2fe87b25 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts @@ -0,0 +1,71 @@ +import type { Page } from 'playwright'; + +interface CommunityAdminRoute { + communityId: string; + memberId: string; +} + +const communityAdminUrlPattern = /\/community\/([^/]+)\/admin\/([^/?#]+)/; + +const parseCommunityAdminRoute = (url: string): CommunityAdminRoute => { + const matched = url.match(communityAdminUrlPattern); + if (!matched) { + throw new Error(`Expected admin route URL, but got "${url}"`); + } + + return { + communityId: matched[1] ?? '', + memberId: matched[2] ?? '', + }; +}; + +const communityTableRow = (page: Page, communityName: string) => + page + .getByRole('row', { + name: new RegExp(communityName, 'i'), + }) + .first(); + +const openAdminPortalFromCommunityRow = async (page: Page, communityName: string): Promise => { + const row = communityTableRow(page, communityName); + await row.waitFor({ state: 'visible', timeout: 15_000 }); + + await row.getByRole('button', { name: /Admin Portals/i }).click(); + + const visibleDropdown = page.locator('.ant-dropdown:visible').last(); + await visibleDropdown.waitFor({ state: 'visible', timeout: 10_000 }); + + await visibleDropdown + .getByRole('button') + .first() + .click() + .catch(async () => { + await visibleDropdown.getByRole('menuitem').first().click(); + }); +}; + +const openCommunityAdminPortal = async (page: Page, communityName: string): Promise => { + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + await openAdminPortalFromCommunityRow(page, communityName); + + await page.waitForURL(/\/community\/[^/]+\/admin\/[^/?#]+(?:\/)?(?:\?.*)?$/, { timeout: 15_000 }); + return parseCommunityAdminRoute(page.url()); +}; + +export const gotoCommunityMembersCreatePage = async (page: Page, communityName: string): Promise => { + const route = await openCommunityAdminPortal(page, communityName); + await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members/create`, { + waitUntil: 'networkidle', + }); + + return route; +}; + +export const gotoCommunityMembersListPage = async (page: Page, communityName: string): Promise => { + const route = await openCommunityAdminPortal(page, communityName); + await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members`, { + waitUntil: 'networkidle', + }); + + return route; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts new file mode 100644 index 000000000..29699eb62 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts @@ -0,0 +1,159 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberCreatePage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import type { E2EMemberCreatePage } from '../../../shared/page-contracts.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { gotoCommunityMembersCreatePage } from '../support/community-admin-navigation.ts'; + +const createMemberOperationName = 'AdminMembersCreateContainerMemberCreate'; + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +type MemberCreateGraphqlPayload = GraphqlPayload<{ + memberCreate?: { + status?: { + success?: boolean; + errorMessage?: string | null; + }; + member?: { + id?: string | null; + memberName?: string | null; + } | null; + }; +}>; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => { + if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { + return false; + } + + return response.request().postData()?.includes(operationName) ?? false; +}; + +const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; '); + +export const CreateMember = (communityName: string, memberName: string) => + Interaction.where(the`#actor creates member "${memberName}" in "${communityName}" via UI`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + + const { communityId } = await gotoCommunityMembersCreatePage(page, communityName); + + const adapter = new PlaywrightPageAdapter(page); + const memberCreatePage: E2EMemberCreatePage = new MemberCreatePage(adapter); + + await memberCreatePage.fillMemberName(memberName); + + const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createMemberOperationName), { timeout: 15_000 }).catch(() => null); + await memberCreatePage.clickCreateMember(); + + await memberCreatePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + const hasValidationError = await memberCreatePage.firstValidationError.isVisible().catch(() => false); + if (hasValidationError) { + const validationMessage = await memberCreatePage.firstValidationError.textContent(); + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', validationMessage || 'Validation error'), + ); + return; + } + + const mutationResponse = await createMutationResponse; + if (!mutationResponse) { + const message = `${createMemberOperationName} did not return a GraphQL response`; + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', message), + ); + throw new Error(message); + } + + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberCreateGraphqlPayload | MemberCreateGraphqlPayload[] | null, (data) => Boolean(data?.memberCreate)); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.memberCreate; + const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; + const createdMemberName = mutationResult?.member?.memberName ?? null; + + if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true || (createdMemberName !== null && createdMemberName !== memberName)) { + const message = + mutationError || + (mutationResult?.status?.success !== true + ? `${createMemberOperationName} did not report success: ${JSON.stringify(payload)}` + : createdMemberName !== memberName + ? `Expected created member name "${memberName}" but GraphQL returned "${createdMemberName ?? 'null'}"` + : `Member create GraphQL request failed with HTTP ${mutationResponse.status()}`); + + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', message), + ); + throw new Error(message); + } + + const memberId = mutationResult?.member?.id ?? null; + if (!memberId) { + const message = `${createMemberOperationName} succeeded but returned no member id`; + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', message), + ); + throw new Error(message); + } + + await page + .waitForURL(new RegExp(`/community/${communityId}/admin/[^/]+/members/[^/?#]+(?:/.*)?(?:\\?.*)?$`), { + timeout: 15_000, + }) + .catch(() => undefined); + await memberCreatePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + const hasErrorToast = await memberCreatePage.errorToast.isVisible().catch(() => false); + if (hasErrorToast) { + const errorText = await memberCreatePage.errorToast.textContent(); + const message = errorText || 'Member creation failed'; + await actor.attemptsTo( + notes().set('lastMemberId', null), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', false), + notes().set('errorMessage', message), + ); + throw new Error(message); + } + + await actor.attemptsTo( + notes().set('lastMemberId', memberId), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', memberName), + notes().set('memberCreated', true), + notes().set('errorMessage', null), + ); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts new file mode 100644 index 000000000..f69c3fc83 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts @@ -0,0 +1,149 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberProfilePage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import type { E2EMemberProfilePage } from '../../../shared/page-contracts.ts'; +import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { gotoCommunityMembersListPage } from '../support/community-admin-navigation.ts'; + +interface UpdateMemberProfileDetails { + communityName: string; + memberName: string; + profile: MemberProfileExpectation; +} + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +const memberUpdateProfileOperationName = 'SharedMemberProfileContainerMemberUpdateProfile'; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => { + if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { + return false; + } + + return response.request().postData()?.includes(operationName) ?? false; +}; + +const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; '); + +const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +type MemberUpdateProfileMutationPayload = GraphqlPayload<{ + memberUpdateProfile?: { + status?: { + success?: boolean; + errorMessage?: string | null; + }; + member?: { + id?: string | null; + } | null; + }; +}>; + +export const UpdateMemberProfile = (details: UpdateMemberProfileDetails) => + Interaction.where(the`#actor updates member profile for "${details.memberName}" in "${details.communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const memberProfilePage: E2EMemberProfilePage = new MemberProfilePage(new PlaywrightPageAdapter(page)); + let memberId: string | null = null; + let communityId: string | null = null; + + try { + const route = await gotoCommunityMembersListPage(page, details.communityName); + communityId = route.communityId; + memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; + if (!memberId) { + throw new Error('Cannot update member profile because no member id is available in notes'); + } + + await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members/${memberId}/profile`, { + waitUntil: 'networkidle', + }); + + await memberProfilePage.clickEditProfile(); + + if (details.profile.name !== undefined) await memberProfilePage.fillDisplayName(details.profile.name); + if (details.profile.email !== undefined) await memberProfilePage.fillEmail(details.profile.email); + if (details.profile.bio !== undefined) await memberProfilePage.fillBio(details.profile.bio); + if (details.profile.showInterests !== undefined) await memberProfilePage.setShowInterests(details.profile.showInterests); + if (details.profile.showEmail !== undefined) await memberProfilePage.setShowEmail(details.profile.showEmail); + if (details.profile.showProfile !== undefined) await memberProfilePage.setShowProfile(details.profile.showProfile); + if (details.profile.showLocation !== undefined) await memberProfilePage.setShowLocation(details.profile.showLocation); + if (details.profile.showProperties !== undefined) await memberProfilePage.setShowProperties(details.profile.showProperties); + + const mutationResponsePromise = page.waitForResponse(hasGraphqlOperation(memberUpdateProfileOperationName), { timeout: 15_000 }).catch(() => null); + await memberProfilePage.clickSaveProfile(); + + await memberProfilePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + if (await memberProfilePage.firstValidationError.isVisible().catch(() => false)) { + throw new Error((await memberProfilePage.firstValidationError.textContent()) || 'Validation error'); + } + + const mutationResponse = await mutationResponsePromise; + + if (!mutationResponse) { + throw new Error(`${memberUpdateProfileOperationName} did not return a GraphQL response`); + } + + const mutationPayload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberUpdateProfileMutationPayload | MemberUpdateProfileMutationPayload[] | null, (data) => + Boolean(data?.memberUpdateProfile), + ); + const mutationError = mutationPayload?.data?.memberUpdateProfile?.status?.errorMessage ?? graphqlErrors(mutationPayload); + const mutationResult = mutationPayload?.data?.memberUpdateProfile; + const updatedMember = mutationResult?.member; + + if (!mutationResponse.ok() || mutationResult?.status?.success !== true || !updatedMember) { + throw new Error( + mutationError || + (mutationResult?.status?.success !== true + ? `${memberUpdateProfileOperationName} did not report success: ${JSON.stringify(mutationPayload)}` + : `Member update GraphQL request failed with HTTP ${mutationResponse.status()}`), + ); + } + + await memberProfilePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + if (await memberProfilePage.errorToast.isVisible().catch(() => false)) { + throw new Error((await memberProfilePage.errorToast.textContent()) || 'Member profile update failed'); + } + + await actor.attemptsTo( + notes().set('lastMemberId', updatedMember.id ?? memberId), + notes().set('lastMemberCommunityId', route.communityId), + notes().set('lastMemberName', details.memberName), + notes().set('lastUpdatedMemberProfile', details.profile), + notes().set('lastMemberStatus', 'SUCCESS'), + notes().set('memberUpdated', true), + notes().set('errorMessage', null), + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await actor.attemptsTo( + notes().set('lastMemberId', memberId), + notes().set('lastMemberCommunityId', communityId), + notes().set('lastMemberName', details.memberName), + notes().set('lastMemberStatus', 'FAILED'), + notes().set('memberUpdated', false), + notes().set('errorMessage', message), + ); + + if (error instanceof Error) { + throw error; + } + + throw new Error(message); + } + }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index 677ab56e7..c27a52ab5 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -1,5 +1,12 @@ -import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage, MemberCreatePage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; export type E2EHomePage = Pick; export type E2ECommunityPage = Pick; + +export type E2EMemberCreatePage = Pick; + +export type E2EMemberProfilePage = Pick< + MemberProfilePage, + 'clickEditProfile' | 'clickSaveProfile' | 'fillDisplayName' | 'fillEmail' | 'fillBio' | 'setShowInterests' | 'setShowEmail' | 'setShowProfile' | 'setShowLocation' | 'setShowProperties' | 'firstValidationError' | 'errorToast' +>; diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 63aa501c8..171f44825 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,2 +1,4 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; +export { MemberCreatePage } from './member-create.page.ts'; +export { MemberProfilePage } from './member-profile.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts new file mode 100644 index 000000000..d21e23c56 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts @@ -0,0 +1,27 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +export class MemberCreatePage extends AdapterBackedPageObject { + get memberNameInput(): ElementHandle { + return this.adapter.getByPlaceholder('Member Name'); + } + + get createMemberButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create Member/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + async fillMemberName(value: string): Promise { + await this.memberNameInput.fill(value); + } + + async clickCreateMember(): Promise { + await this.createMemberButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts new file mode 100644 index 000000000..37e9efd98 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts @@ -0,0 +1,85 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +export class MemberProfilePage extends AdapterBackedPageObject { + get displayNameInput(): ElementHandle { + return this.adapter.getByPlaceholder('Display Name'); + } + + get emailInput(): ElementHandle { + return this.adapter.getByPlaceholder('Email'); + } + + get bioInput(): ElementHandle { + return this.adapter.getByPlaceholder('Bio'); + } + + get editProfileButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Edit Profile/i }); + } + + get saveProfileButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Save Profile/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + async clickEditProfile(): Promise { + await this.editProfileButton.click(); + } + + async clickSaveProfile(): Promise { + await this.saveProfileButton.click(); + } + + async fillDisplayName(value: string): Promise { + await this.displayNameInput.fill(value); + } + + async fillEmail(value: string): Promise { + await this.emailInput.fill(value); + } + + async fillBio(value: string): Promise { + await this.bioInput.fill(value); + } + + async setShowInterests(value: boolean): Promise { + await this.setSwitchAtIndex(0, value); + } + + async setShowEmail(value: boolean): Promise { + await this.setSwitchAtIndex(1, value); + } + + async setShowProfile(value: boolean): Promise { + await this.setSwitchAtIndex(2, value); + } + + async setShowLocation(value: boolean): Promise { + await this.setSwitchAtIndex(3, value); + } + + async setShowProperties(value: boolean): Promise { + await this.setSwitchAtIndex(4, value); + } + + private async setSwitchAtIndex(index: number, expectedChecked: boolean): Promise { + const switches = await this.adapter.locatorAll('button[role="switch"]'); + const target = switches[index]; + if (!target) { + throw new Error(`Expected switch at index ${index} but none was found`); + } + + const checkedState = await target.getAttribute('aria-checked'); + const isChecked = checkedState === 'true'; + if (isChecked !== expectedChecked) { + await target.click(); + } + } +} From 188048c9f8872cd53e4dc8da7726f3822c86be7b Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Mon, 13 Jul 2026 21:15:01 -0400 Subject: [PATCH 04/15] add scenario implementations for updating member profiles, assigning end-users to members, removing members, and validation; refactor existing scenario implementations --- .../contexts/community/notes/member-notes.ts | 3 + .../questions/end-user-id-in-community.ts | 27 ++++ .../questions/member-account-linked.ts | 20 +++ .../questions/member-belongs-to-community.ts | 58 ------- .../community/questions/member-by-id.ts | 13 ++ .../questions/member-listed-in-community.ts | 23 +++ .../community/questions/member-status.ts | 32 ++++ .../assign-member-account.steps.ts | 41 +++++ .../step-definitions/create-member.steps.ts | 90 ++++------- .../community/step-definitions/index.ts | 2 + .../step-definitions/remove-member.steps.ts | 40 +++++ .../community/tasks/assign-member-account.ts | 26 ++++ .../contexts/community/tasks/create-member.ts | 3 +- .../contexts/community/tasks/remove-member.ts | 27 ++++ .../shared/abilities/assign-member-account.ts | 48 ++++++ .../src/shared/abilities/create-community.ts | 6 +- .../src/shared/abilities/index.ts | 1 + .../src/shared/abilities/remove-member.ts | 41 +++++ .../shared/graphql/community-operations.ts | 8 + .../src/shared/graphql/member-operations.ts | 39 +++++ .../acceptance-api/src/world.ts | 11 +- .../contexts/community/notes/member-notes.ts | 26 +++- .../questions/member-account-linked.ts | 9 ++ .../questions/member-belongs-to-community.ts | 10 -- .../questions/member-listed-in-community.ts | 10 ++ .../questions/member-profile-updated.ts | 6 + .../questions/member-removed-flag.ts | 4 + .../assign-member-account.steps.tsx | 69 +++++++++ .../step-definitions/create-member.steps.tsx | 135 ++++++++-------- .../community/step-definitions/index.ts | 3 + .../step-definitions/remove-member.steps.tsx | 64 ++++++++ .../step-definitions/update-member.steps.tsx | 62 ++++++++ .../community/tasks/assign-member-account.ts | 20 +++ .../contexts/community/tasks/remove-member.ts | 16 ++ .../community/tasks/update-member-profile.ts | 33 ++++ .../src/shared/page-contracts.ts | 11 +- .../contexts/community/notes/member-notes.ts | 3 + .../questions/member-account-linked.ts | 14 ++ .../questions/member-belongs-to-community.ts | 26 ---- .../questions/member-error-message.ts | 4 + .../questions/member-listed-in-community.ts | 12 ++ .../questions/member-removed-flag.ts | 4 + .../assign-member-account.steps.ts | 42 +++++ .../step-definitions/create-member.steps.ts | 88 +++-------- .../community/step-definitions/index.ts | 2 + .../step-definitions/remove-member.steps.ts | 27 ++++ .../step-definitions/update-member.steps.ts | 32 +--- .../support/community-admin-navigation.ts | 71 --------- .../community/tasks/assign-member-account.ts | 74 +++++++++ .../contexts/community/tasks/create-member.ts | 138 +++++++---------- .../contexts/community/tasks/remove-member.ts | 63 ++++++++ .../community/tasks/update-member-profile.ts | 146 ++++++++---------- .../e2e-tests/src/shared/page-contracts.ts | 6 +- .../verification-shared/src/pages/index.ts | 2 + .../src/pages/member-accounts.page.ts | 25 +++ .../src/pages/member-create.page.ts | 2 +- .../src/pages/member-list.page.ts | 24 +++ .../member-authorization-and-scope.feature | 28 ---- .../community/member/member-lifecycle.feature | 143 ++++++++++------- 59 files changed, 1380 insertions(+), 633 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/end-user-id-in-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-listed-in-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/assign-member-account.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/assign-member-account.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-listed-in-community.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-profile-updated.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-removed-flag.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-member.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/assign-member-account.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/remove-member.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-profile.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-error-message.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/member-list.page.ts delete mode 100644 packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts index 674e71e5d..f826ea023 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -19,9 +19,12 @@ export interface MemberProfileExpectation { export interface MemberNotes { communityIdsByName: Record; + endUserIdsByLabel: Record; lastMemberCommunityId: string; lastMemberId: string; lastMemberName: string; + lastMemberRemoved: boolean; + lastLinkedEndUserId: string; lastExpectedMemberProfile?: MemberProfileExpectation; lastMemberProfileEmail?: string; lastExpectedMemberProfileEmail?: string; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/end-user-id-in-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/end-user-id-in-community.ts new file mode 100644 index 000000000..640c4a7d2 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/end-user-id-in-community.ts @@ -0,0 +1,27 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { END_USERS_BY_COMMUNITY_QUERY } from '../../../shared/graphql/member-operations.ts'; + +export class EndUserIdInCommunity extends Question> { + constructor( + private readonly communityId: string, + private readonly displayName: string, + ) { + super(`end user "${displayName}" in community "${communityId}"`); + } + + static withDisplayName(communityId: string, displayName: string): EndUserIdInCommunity { + return new EndUserIdInCommunity(communityId, displayName); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(END_USERS_BY_COMMUNITY_QUERY, { communityId: this.communityId }); + const endUsers = (response.data?.['endUsersByCommunityId'] as Array> | undefined) ?? []; + const endUser = endUsers.find((candidate) => String(candidate['displayName'] ?? '') === this.displayName); + if (!endUser?.['id']) { + throw new Error(`End user "${this.displayName}" is not available in community "${this.communityId}"`); + } + return String(endUser['id']); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts new file mode 100644 index 000000000..79fde94ba --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,20 @@ +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MemberById } from './member-by-id.ts'; + +export class MemberAccountLinked extends Question> { + constructor( + private readonly memberId: string, + private readonly endUserId: string, + ) { + super(`whether member "${memberId}" is linked to end user "${endUserId}"`); + } + + static for(memberId: string, endUserId: string): MemberAccountLinked { + return new MemberAccountLinked(memberId, endUserId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const member = await actor.answer(MemberById.withId(this.memberId)); + return member?.accounts?.some((account) => account.user?.id === this.endUserId) ?? false; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts deleted file mode 100644 index ae3272002..000000000 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-belongs-to-community.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; -import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import { MEMBERS_BY_COMMUNITY_QUERY } from '../../../shared/graphql/member-operations.ts'; -import type { MemberNotes } from '../notes/member-notes.ts'; - -interface MemberQueryShape { - id?: string; - memberName?: string; -} - -export class MemberBelongsToCommunity extends Question> { - constructor( - private readonly expectedMemberName: string, - private readonly communityId: string, - ) { - super(`whether member "${expectedMemberName}" belongs to community "${communityId}"`); - } - - static inCommunity(expectedMemberName: string, communityId: string): MemberBelongsToCommunity { - return new MemberBelongsToCommunity(expectedMemberName, communityId); - } - - override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { - const existsInApi = await this.readFromApi(actor); - if (existsInApi !== undefined) { - return existsInApi; - } - - const notedMemberName = await this.readNote(actor, 'lastMemberName'); - const notedCommunityId = await this.readNote(actor, 'lastMemberCommunityId'); - return notedMemberName === this.expectedMemberName && notedCommunityId === this.communityId; - } - - private async readFromApi(actor: AnswersQuestions & UsesAbilities): Promise { - if (!this.communityId) { - return undefined; - } - - try { - const graphql = GraphQLClient.as(actor as unknown as Actor); - const response = await graphql.execute(MEMBERS_BY_COMMUNITY_QUERY, { - communityId: this.communityId, - }); - const members = (response.data?.['membersByCommunityId'] as MemberQueryShape[] | undefined) ?? []; - return members.some((member) => String(member.memberName ?? '') === this.expectedMemberName); - } catch { - return undefined; - } - } - - private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof MemberNotes): Promise { - try { - return await actor.answer(notes>().get(key)); - } catch { - return undefined; - } - } -} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts index 958402c52..5165e588a 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts @@ -8,6 +8,11 @@ export interface MemberByIdResult { community?: { id?: string; }; + accounts?: Array<{ + user?: { + id?: string; + }; + }>; profile?: { name?: string; email?: string; @@ -43,6 +48,13 @@ export class MemberById extends Question> } const profile = member['profile'] as Record | undefined; + const accounts = Array.isArray(member['accounts']) + ? (member['accounts'] as Array>).map((account) => ({ + user: { + id: (account['user'] as Record | undefined)?.['id'] !== undefined ? String((account['user'] as Record)['id']) : undefined, + }, + })) + : undefined; const interests = Array.isArray(profile?.['interests']) ? (profile?.['interests'] as Array).map((value) => String(value)).filter((value) => value.length > 0) : undefined; return { @@ -51,6 +63,7 @@ export class MemberById extends Question> community: { id: (member['community'] as Record | undefined)?.['id'] !== undefined ? String((member['community'] as Record)['id']) : undefined, }, + accounts, profile: profile ? { ...(profile['name'] !== undefined ? { name: String(profile['name'] ?? '') } : {}), diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-listed-in-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-listed-in-community.ts new file mode 100644 index 000000000..10ac006fc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-listed-in-community.ts @@ -0,0 +1,23 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBERS_BY_COMMUNITY_QUERY } from '../../../shared/graphql/member-operations.ts'; + +export class MemberListedInCommunity extends Question> { + static named(memberName: string, communityId: string): MemberListedInCommunity { + return new MemberListedInCommunity(memberName, communityId); + } + + private constructor( + private readonly memberName: string, + private readonly communityId: string, + ) { + super(`whether member "${memberName}" is listed in community "${communityId}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor); + const response = await graphql.execute(MEMBERS_BY_COMMUNITY_QUERY, { communityId: this.communityId }); + const members = response.data?.['membersByCommunityId']; + return Array.isArray(members) && members.some((member) => (member as Record)['memberName'] === this.memberName); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts new file mode 100644 index 000000000..1097efd46 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts @@ -0,0 +1,32 @@ +import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +export class MemberStatus extends Question> { + constructor() { + super('member status'); + } + + static of(): MemberStatus { + return new MemberStatus(); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const notedStatus = await this.readNote(actor, 'lastMemberStatus'); + if (!notedStatus) { + throw new Error('No member status found in actor notes. Did the actor create a member first?'); + } + return notedStatus; + } + + override toString(): string { + return 'the member status'; + } + + private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof MemberNotes): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts new file mode 100644 index 000000000..84f392333 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -0,0 +1,41 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { EndUserIdInCommunity } from '../questions/end-user-id-in-community.ts'; +import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; + +const accountDisplayNames: Record = { + Charlie: `${actors.CommunityOwner.givenName} ${actors.CommunityOwner.familyName}`, +}; + +Given('end-user account {string} is available for assignment in {string}', async (accountLabel: string, communityName: string) => { + const actor = actorInTheSpotlight(); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + const displayName = accountDisplayNames[accountLabel]; + if (!communityId || !displayName) { + throw new Error(`Unknown community or end-user account label: "${communityName}" / "${accountLabel}"`); + } + const endUserId = await actor.answer(EndUserIdInCommunity.withDisplayName(communityId, displayName)); + const idsByLabel = await actor.answer(notes().get('endUserIdsByLabel')).catch(() => ({}) as Record); + await actor.attemptsTo(notes().set('endUserIdsByLabel', { ...idsByLabel, [accountLabel]: endUserId })); +}); + +When('{word} associates end-user account {string} to member {string} in {string}', async (actorName: string, accountLabel: string, _memberName: string, communityName: string) => { + const actor = actorCalled(actorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); + const memberId = await actor.answer(notes().get('lastMemberId')); + await actor.attemptsTo(AssignMemberAccount.to({ communityId: communityIds[communityName] ?? '', memberId, endUserId: endUserIds[accountLabel] ?? '' })); +}); + +Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const memberId = await actor.answer(notes().get('lastMemberId')); + const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); + if (!(await actor.answer(MemberAccountLinked.for(memberId, endUserIds[accountLabel] ?? '')))) { + throw new Error(`Expected member "${memberId}" to be linked to end-user account "${accountLabel}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts index 14a47aae3..19fc05c4b 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -3,22 +3,18 @@ import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; -import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; +import { MemberStatus } from '../questions/member-status.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; import { CreateMember } from '../tasks/create-member.ts'; let lastActorName = actors.CommunityOwner.name; interface MemberDetails { - memberName: string; + memberName?: string; role?: string; email?: string; } -const seededEndUserLabelMap: Record = { - Charlie: 'CommunityMember', -}; - Given('{word} is signed in as a community owner for member management', (actorName: string) => { lastActorName = actorName; actorCalled(actorName); @@ -30,7 +26,6 @@ Given('{word} has a community named {string}', async (actorName: string, communi const actor = actorCalled(actorName); await actor.attemptsTo(CreateCommunity.withName(communityName)); - // CreateCommunity task sets lastCommunityId; copy into communityIdsByName const communityId = (await actor.answer(notes().get('lastCommunityId'))) as string; const existing = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); @@ -40,18 +35,6 @@ Given('{word} has a community named {string}', async (actorName: string, communi [communityName]: communityId, }), ); - - // make actor a member of the community with a role that has canManageMembers permission -}); - -Given('{word} is an existing end-user account', async (label: string) => { - const actorKey = seededEndUserLabelMap[label]; - if (!actorKey) { - throw new Error(`No seeded end-user mapping for label "${label}"`); - } - - const seeded = actors[actorKey]; - await actorCalled(lastActorName).attemptsTo(notes().set(`endUser.externalId.${label}`, seeded.externalId), notes().set(`endUser.email.${label}`, seeded.email)); }); When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { @@ -61,11 +44,7 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm const details = GherkinDataTable.from(dataTable).rowsHash(); const memberName = details.memberName?.trim() ?? ''; - if (!memberName) { - throw new Error('memberName is required'); - } - // Resolve the community id by name from notes populated in the Given step const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); const communityId = communityIdsByName[communityName]; @@ -73,7 +52,6 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); } - await actor.attemptsTo(notes().set('lastValidationError', undefined as unknown as string)); await actor.attemptsTo( CreateMember.with({ memberName, @@ -81,52 +59,52 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm ...(details.role ? { role: details.role } : {}), }), ); - - await actor.attemptsTo(notes().set('lastMemberCommunityId', communityId)); }); -Then('the member should be created successfully in {string}', async (communityName: string) => { - const actor = actorCalled(lastActorName); - - const status = await actor.answer(notes().get('lastMemberStatus')); - const memberId = await actor.answer(notes().get('lastMemberId')); - const validationError = await actor.answer(notes().get('lastValidationError')); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - - if (!communityIdsByName[communityName]) { +When('{word} attempts to create a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + const communityId = communityIdsByName[communityName]; + if (!communityId) { throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); } - if (validationError) { - throw new Error(`Expected member creation to succeed for "${communityName}", but got validation error: "${validationError}"`); + await actor.attemptsTo( + notes().set('lastMemberId', undefined as unknown as string), + notes().set('lastMemberStatus', undefined as unknown as string), + notes().set('lastValidationError', undefined as unknown as string), + ); + + try { + await actor.attemptsTo(CreateMember.with({ memberName: details.memberName?.trim() ?? '', communityId })); + } catch (error) { + await actor.attemptsTo(notes().set('lastValidationError', error instanceof Error ? error.message : String(error))); } +}); +Then('the member should be created successfully in {string}', async (_communityName: string) => { + const actor = actorCalled(lastActorName); + const status = await actor.answer(MemberStatus.of()); if (status !== 'SUCCESS') { throw new Error(`Expected member status "SUCCESS" but got "${status}"`); } - - if (!memberId) { - throw new Error(`Expected a created member id for "${communityName}" but none was recorded`); - } }); -Then('the member {string} should belong to {string}', async (expectedMemberName: string, communityName: string) => { +Then('she should see a member error for {string}', async (fieldName: string) => { const actor = actorCalled(lastActorName); - - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - const expectedCommunityId = communityIdsByName[communityName]; - if (!expectedCommunityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - const lastMemberId = await actor.answer(notes().get('lastMemberId')); - - if (!lastMemberId) { - throw new Error('Expected a created member id but none was recorded'); + const error = await actor.answer(notes().get('lastValidationError')).catch(() => ''); + if (!error || !/cannot be empty|required|missing|invalid|must not be empty|too short|too long/i.test(error)) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); } +}); - const belongs = await actor.answer(MemberBelongsToCommunity.inCommunity(expectedMemberName, expectedCommunityId)); - if (!belongs) { - throw new Error(`Expected member "${expectedMemberName}" to belong to "${communityName}"`); +Then('no new member should be created in {string}', async (_communityName: string) => { + const actor = actorCalled(lastActorName); + const memberId = await actor.answer(notes().get('lastMemberId')).catch(() => ''); + const validationError = await actor.answer(notes().get('lastValidationError')).catch(() => ''); + if (memberId || !validationError) { + throw new Error('Expected member creation to be blocked by validation'); } }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts index 46874774b..1d0991a2b 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts @@ -1,4 +1,6 @@ // Community context step definitions import './create-community.steps.ts'; import './create-member.steps.ts'; +import './remove-member.steps.ts'; +import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts new file mode 100644 index 000000000..d1812914e --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts @@ -0,0 +1,40 @@ +import { Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; +import { RemoveMember } from '../tasks/remove-member.ts'; + +let lastActorName = actors.CommunityOwner.name; + +When('{word} removes member {string} from {string}', async (actorName: string, _memberName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const communityId = communityIds[communityName]; + if (!communityId || !memberId) { + throw new Error(`Missing member or community state for removal from "${communityName}"`); + } + await actor.attemptsTo(notes().set('lastMemberRemoved', false), RemoveMember.withId(memberId, communityId)); +}); + +Then('the member should be removed successfully from {string}', async (_communityName: string) => { + const actor = actorCalled(lastActorName); + const removed = await actor.answer(notes().get('lastMemberRemoved')); + if (!removed) { + throw new Error('Expected member removal to succeed'); + } +}); + +Then('member {string} should not appear in member listings for {string}', async (memberName: string, communityName: string) => { + const actor = actorCalled(lastActorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}"`); + } + if (await actor.answer(MemberListedInCommunity.named(memberName, communityId))) { + throw new Error(`Expected member "${memberName}" not to appear in listings for "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/assign-member-account.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/assign-member-account.ts new file mode 100644 index 000000000..87a5833f9 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/assign-member-account.ts @@ -0,0 +1,26 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { AssignMemberAccount as AssignMemberAccountAbility } from '../../../shared/abilities/assign-member-account.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +interface AssignmentDetails { + communityId: string; + memberId: string; + endUserId: string; +} + +export class AssignMemberAccount extends Task { + static to(details: AssignmentDetails): AssignMemberAccount { + return new AssignMemberAccount(details); + } + + private constructor(private readonly details: AssignmentDetails) { + super('assigns an end-user account to a member'); + } + + override async performAs(actor: Actor): Promise { + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.details.communityId)); + await AssignMemberAccountAbility.as(actor).performAs(actor, { ...this.details, principalMemberId }); + await actor.attemptsTo(notes().set('lastLinkedEndUserId', this.details.endUserId)); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts index 5dac72300..de7b05628 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts @@ -38,9 +38,8 @@ export class CreateMember extends Task { await actor.attemptsTo( notes().set('lastMemberId', member.id ?? ''), + notes().set('lastMemberCommunityId', this.details.communityId), notes().set('lastMemberName', member.memberName), - notes().set('lastMemberProfileEmail', undefined), - notes().set('lastExpectedMemberProfileEmail', undefined), notes().set('lastMemberStatus', 'SUCCESS'), ); } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts new file mode 100644 index 000000000..e774531c9 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts @@ -0,0 +1,27 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { RemoveMember as RemoveMemberAbility } from '../../../shared/abilities/remove-member.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +export class RemoveMember extends Task { + static withId(memberId: string, communityId: string): RemoveMember { + return new RemoveMember(memberId, communityId); + } + + private constructor( + private readonly memberId: string, + private readonly communityId: string, + ) { + super(`removes member "${memberId}"`); + } + + async performAs(actor: Actor): Promise { + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.communityId)); + await RemoveMemberAbility.as(actor).performAs(actor, { + memberId: this.memberId, + communityId: this.communityId, + principalMemberId, + }); + await actor.attemptsTo(notes().set('lastMemberRemoved', true), notes().set('lastMemberStatus', 'REMOVED')); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-member-account.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-member-account.ts new file mode 100644 index 000000000..f7f95020b --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-member-account.ts @@ -0,0 +1,48 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { MEMBER_CREATE_ACCOUNT_MUTATION } from '../graphql/member-operations.ts'; + +interface AssignMemberAccountDetails { + communityId: string; + principalMemberId: string; + memberId: string; + endUserId: string; +} + +type AssignMemberAccountHandler = (actor: Actor, details: AssignMemberAccountDetails) => Promise; + +type ExecuteWithRequestOptions = = Record>( + query: string, + variables?: Record, + options?: { headers?: Record }, +) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; + +export class AssignMemberAccount extends Ability { + constructor(private readonly handler: AssignMemberAccountHandler) { + super(); + } + + static using(handler: AssignMemberAccountHandler): AssignMemberAccount { + return new AssignMemberAccount(handler); + } + + async performAs(actor: Actor, details: AssignMemberAccountDetails): Promise { + await this.handler(actor, details); + } +} + +export function assignMemberAccountAbility(): AssignMemberAccount { + return AssignMemberAccount.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + const response = await graphql.execute( + MEMBER_CREATE_ACCOUNT_MUTATION, + { input: { memberId: details.memberId, endUserId: details.endUserId } }, + { headers: { 'x-community-id': details.communityId, 'x-member-id': details.principalMemberId } }, + ); + const result = response.data?.['memberCreateAccount'] as Record | undefined; + const status = result?.['status'] as Record | undefined; + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? 'Failed to assign member account')); + } + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts index 3881c69d1..2106eed6a 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts @@ -1,6 +1,6 @@ import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; import { Ability, type Actor } from '@serenity-js/core'; -import { COMMUNITY_CREATE_MUTATION, GET_COMMUNITY_QUERY } from '../graphql/community-operations.ts'; +import { COMMUNITY_CREATE_MUTATION, CURRENT_END_USER_QUERY, GET_COMMUNITY_QUERY } from '../graphql/community-operations.ts'; /** Community details accepted by API verification flows. */ interface CreateCommunityDetails { @@ -36,6 +36,10 @@ export class CreateCommunity extends Ability { export function createCommunityAbility(): CreateCommunity { return CreateCommunity.using(async (actor, details) => { const graphql = GraphQLClient.as(actor); + const currentEndUserResponse = await graphql.execute(CURRENT_END_USER_QUERY); + if (!(currentEndUserResponse.data['currentEndUserAndCreateIfNotExists'] as Record | undefined)?.['id']) { + throw new Error('API did not establish the authenticated end user before community creation'); + } const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { input: { name: details.name }, }); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index 09852394f..e00d9f743 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -1,3 +1,4 @@ export { createCommunityAbility } from './create-community.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; +export { removeMemberAbility } from './remove-member.ts'; export { updateMemberProfileAbility } from './update-member-profile.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts new file mode 100644 index 000000000..957196cf7 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts @@ -0,0 +1,41 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { REMOVE_MEMBER_MUTATION } from '../graphql/member-operations.ts'; + +interface RemoveMemberDetails { + memberId: string; + communityId: string; + principalMemberId: string; +} + +type ExecuteWithRequestOptions = = Record>( + query: string, + variables?: Record, + options?: { headers?: Record }, +) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; + +export class RemoveMember extends Ability { + constructor(private readonly handler: (actor: Actor, details: RemoveMemberDetails) => Promise) { + super(); + } + + static using(handler: (actor: Actor, details: RemoveMemberDetails) => Promise): RemoveMember { + return new RemoveMember(handler); + } + + async performAs(actor: Actor, details: RemoveMemberDetails): Promise { + await this.handler(actor, details); + } +} + +export function removeMemberAbility(): RemoveMember { + return RemoveMember.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + const response = await graphql.execute(REMOVE_MEMBER_MUTATION, { input: { memberId: details.memberId } }, { headers: { 'x-community-id': details.communityId, 'x-member-id': details.principalMemberId } }); + const result = response.data?.['removeMember'] as Record | undefined; + const status = result?.['status'] as Record | undefined; + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? response.errors?.map((error) => error.message).join('; ') ?? 'Failed to remove member')); + } + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts index f239508d5..b25cfb1c7 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts @@ -13,6 +13,14 @@ export const COMMUNITY_CREATE_MUTATION = ` } `; +export const CURRENT_END_USER_QUERY = ` + query CurrentEndUserAndCreateIfNotExists { + currentEndUserAndCreateIfNotExists { + id + } + } +`; + export const GET_COMMUNITY_QUERY = ` query CommunityById($id: ObjectID!) { communityById(id: $id) { diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts index 98b73b810..561a43d50 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts @@ -13,6 +13,17 @@ export const MEMBER_CREATE_MUTATION = ` } `; +export const REMOVE_MEMBER_MUTATION = ` + mutation RemoveMember($input: RemoveMemberInput!) { + removeMember(input: $input) { + status { + success + errorMessage + } + } + } +`; + export const MEMBER_UPDATE_PROFILE_MUTATION = ` mutation MemberUpdateProfile($input: MemberUpdateProfileInput!) { memberUpdateProfile(input: $input) { @@ -39,6 +50,29 @@ export const MEMBER_UPDATE_PROFILE_MUTATION = ` } `; +export const MEMBER_CREATE_ACCOUNT_MUTATION = ` + mutation MemberCreateAccount($input: MemberCreateAccountInput!) { + memberCreateAccount(input: $input) { + status { + success + errorMessage + } + member { + id + } + } + } +`; + +export const END_USERS_BY_COMMUNITY_QUERY = ` + query EndUsersByCommunity($communityId: ObjectID!) { + endUsersByCommunityId(communityId: $communityId) { + id + displayName + } + } +`; + export const MEMBERS_FOR_CURRENT_END_USER_QUERY = ` query MembersForCurrentEndUser { membersForCurrentEndUser { @@ -67,6 +101,11 @@ export const MEMBER_BY_ID_QUERY = ` community { id } + accounts { + user { + id + } + } profile { name email diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index 0e475c95c..b0263d73d 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -3,9 +3,11 @@ import type { ApiInfrastructureState } from '@cellix/serenity-framework/infrastr import { SerenityCast } from '@cellix/serenity-framework/serenity'; import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; import { infrastructure } from './infrastructure.ts'; +import { assignMemberAccountAbility } from './shared/abilities/assign-member-account.ts'; import { createCommunityAbility } from './shared/abilities/create-community.ts'; import { createMemberAbility } from './shared/abilities/create-member.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; +import { removeMemberAbility } from './shared/abilities/remove-member.ts'; import { updateMemberProfileAbility } from './shared/abilities/update-member-profile.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ @@ -18,7 +20,14 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility(), () => createMemberAbility(), () => updateMemberProfileAbility()], + abilities: [ + () => createGraphQLClientAbility(graphqlUrl(state)), + () => createCommunityAbility(), + () => createMemberAbility(), + () => assignMemberAccountAbility(), + () => updateMemberProfileAbility(), + () => removeMemberAbility(), + ], }), }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts index 3edb36d38..261370a91 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -1,7 +1,25 @@ export interface MemberUiNotes { - communityIdsByName: Record; - endUserExternalIdsByLabel: Record; - lastMemberCommunityName: string; - lastMemberName: string; + endUserIdsByLabel: Record; + lastMemberId: string; + lastLinkedEndUserId: string; memberCreated: boolean; + memberRemoved: boolean; + memberValidationError: string; + memberAccountLinked: boolean; + memberUpdated: boolean; + updatedMemberProfile: MemberProfileFormValues; + expectedMemberProfile: MemberProfileExpectation; } + +export interface MemberProfileFormValues { + name: string; + email: string; + bio: string; + showInterests: boolean; + showEmail: boolean; + showProfile: boolean; + showLocation: boolean; + showProperties: boolean; +} + +export type MemberProfileExpectation = MemberProfileFormValues; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts new file mode 100644 index 000000000..cd7e85bc4 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,9 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; + +export const MemberAccountLinked = (endUserId: string) => + Question.about('whether the member account was linked', async (actor) => { + const linked = await actor.answer(notes().get('memberAccountLinked')); + const linkedEndUserId = await actor.answer(notes().get('lastLinkedEndUserId')); + return linked && linkedEndUserId === endUserId; + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts deleted file mode 100644 index ab6a57111..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-belongs-to-community.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { notes, Question } from '@serenity-js/core'; -import type { MemberUiNotes } from '../notes/member-notes.ts'; - -export const MemberBelongsToCommunity = (expectedMemberName: string, expectedCommunityName: string) => - Question.about(`whether member "${expectedMemberName}" belongs to "${expectedCommunityName}"`, async (actor) => { - const actualMemberName = await actor.answer(notes().get('lastMemberName')); - const actualCommunityName = await actor.answer(notes().get('lastMemberCommunityName')); - - return actualMemberName === expectedMemberName && actualCommunityName === expectedCommunityName; - }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-listed-in-community.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-listed-in-community.ts new file mode 100644 index 000000000..ad94e0fc0 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-listed-in-community.ts @@ -0,0 +1,10 @@ +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { Question } from '@serenity-js/core'; + +export const MemberListedInCommunity = (memberName: string) => + Question.about(`whether member "${memberName}" appears in the rendered member list`, async (actor) => { + const page = new MemberListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + return await page.memberName(memberName).isVisible(); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-profile-updated.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-profile-updated.ts new file mode 100644 index 000000000..033809a80 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-profile-updated.ts @@ -0,0 +1,6 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberProfileFormValues, MemberUiNotes } from '../notes/member-notes.ts'; + +export const UpdatedMemberProfile = () => Question.about('the submitted member profile', (actor) => actor.answer(notes().get('updatedMemberProfile')) as Promise); + +export const MemberUpdatedFlag = () => Question.about('whether the member profile was updated', (actor) => actor.answer(notes().get('memberUpdated'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-removed-flag.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-removed-flag.ts new file mode 100644 index 000000000..d451cd9f4 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-removed-flag.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; + +export const MemberRemovedFlag = () => Question.about('whether the member was removed', (actor) => actor.answer(notes().get('memberRemoved'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx new file mode 100644 index 000000000..59bbd9eeb --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx @@ -0,0 +1,69 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors, END_USER_IDS } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import React from 'react'; +import { MembersAccountsAdd } from '../../../../../../ocom/ui-community-route-admin/src/components/members-accounts-add.tsx'; +import type { AdminMembersAccountsAddContainerEndUserFieldsFragment, MemberCreateAccountInput } from '../../../../../../ocom/ui-community-route-admin/src/generated.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; +import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; + +const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; +reactGlobal.React ??= React; + +const accountDetails: Record = { + Charlie: { + id: END_USER_IDS.communityOwner, + displayName: `${actors.CommunityOwner.givenName} ${actors.CommunityOwner.familyName}`, + email: actors.CommunityOwner.email, + }, +}; + +Given('end-user account {string} is available for assignment in {string}', async (accountLabel: string, _communityName: string) => { + const actor = actorInTheSpotlight(); + const account = accountDetails[accountLabel]; + if (!account) { + throw new Error(`Unknown end-user account label "${accountLabel}"`); + } + const memberId = await actor.answer(notes().get('lastMemberId')); + const endUser: AdminMembersAccountsAddContainerEndUserFieldsFragment = { + id: account.id, + displayName: account.displayName, + personalInformation: { contactInformation: { email: account.email } }, + }; + const onSave = async (values: MemberCreateAccountInput): Promise => { + await actor.attemptsTo(notes().set('memberAccountLinked', values.memberId === memberId && values.endUserId === account.id), notes().set('lastLinkedEndUserId', String(values.endUserId))); + }; + await actor.attemptsTo( + notes().set('endUserIdsByLabel', { [accountLabel]: account.id }), + notes().set('memberAccountLinked', false), + notes().set('lastLinkedEndUserId', ''), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + ); +}); + +When('{word} associates end-user account {string} to member {string} in {string}', async (actorName: string, accountLabel: string, _memberName: string, _communityName: string) => { + const account = accountDetails[accountLabel]; + if (!account) { + throw new Error(`Unknown end-user account label "${accountLabel}"`); + } + await actorCalled(actorName).attemptsTo(AssignMemberAccount()); +}); + +Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); + if (!(await actor.answer(MemberAccountLinked(endUserIds[accountLabel] ?? '')))) { + throw new Error(`Expected the member to be linked to end-user account "${accountLabel}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx index 34daf7375..030b8955d 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -1,12 +1,16 @@ import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; -import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { MemberCreatePage } from '@ocom-verification/verification-shared/pages'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import React from 'react'; import { MembersCreate } from '../../../../../../ocom/ui-community-route-admin/src/components/members-create.tsx'; +import { MemberProfile } from '../../../../../../ocom/ui-community-shared/src/components/member-profile.tsx'; +import type { SharedMemberProfileContainerMemberFieldsFragment } from '../../../../../../ocom/ui-community-shared/src/generated.tsx'; import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; -import type { MemberUiNotes } from '../notes/member-notes.ts'; -import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; +import type { AcceptanceUiMemberCreatePage } from '../../../shared/page-contracts.ts'; +import type { MemberProfileFormValues, MemberUiNotes } from '../notes/member-notes.ts'; import { MemberCreatedFlag } from '../questions/member-created-flag.ts'; import { CreateMember } from '../tasks/create-member.ts'; @@ -17,56 +21,19 @@ interface MemberDetails { memberName?: string; } -let lastActorName = 'Alice'; - -const toCommunityId = (communityName: string): string => - communityName - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - Given('{word} is signed in as a community owner for member management', async (actorName: string) => { - lastActorName = actorName; const actor = actorCalled(actorName); - await actor.attemptsTo( - notes().set('communityIdsByName', {}), - notes().set('endUserExternalIdsByLabel', {}), - notes().set('memberCreated', false), - notes().set('lastMemberName', ''), - notes().set('lastMemberCommunityName', ''), - ); -}); - -Given('{word} has a community named {string}', async (actorName: string, communityName: string) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - - const existing = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const onSave = async (): Promise => { + await actor.attemptsTo(notes().set('memberCreated', true)); + }; - await actor.attemptsTo( - notes().set('communityIdsByName', { - ...existing, - [communityName]: toCommunityId(communityName), - }), - ); + await actor.attemptsTo(notes().set('memberCreated', false), Render.component(, { wrapper: wrapOcomComponent() })); }); -Given('{word} is an existing end-user account', async (accountLabel: string) => { - const actor = actorCalled(lastActorName); - const existing = (await actor.answer(notes().get('endUserExternalIdsByLabel')).catch(() => ({}) as Record)) as Record; +Given('{word} has a community named {string}', (_actorName: string, _communityName: string) => undefined); - await actor.attemptsTo( - notes().set('endUserExternalIdsByLabel', { - ...existing, - [accountLabel]: `end-user-${accountLabel.trim().toLowerCase()}`, - }), - ); -}); - -When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { - lastActorName = actorName; +When('{word} creates a member in {string} with:', async (actorName: string, _communityName: string, dataTable: DataTable) => { const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); const memberName = details.memberName?.trim() ?? ''; @@ -75,44 +42,68 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm throw new Error('memberName is required'); } - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - - if (!communityIdsByName[communityName]) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + await actor.attemptsTo(CreateMember(memberName)); + await actor.attemptsTo(notes().set('lastMemberId', 'member-charlie-walker')); - const onSave = async (values: { memberName?: string }): Promise => { - await actor.attemptsTo(notes().set('memberCreated', true), notes().set('lastMemberName', values.memberName?.trim() ?? ''), notes().set('lastMemberCommunityName', communityName)); + const onProfileSave = async (profile: MemberProfileFormValues): Promise => { + await actor.attemptsTo(notes().set('memberUpdated', true), notes().set('updatedMemberProfile', profile)); + return true; + }; + const profileData: SharedMemberProfileContainerMemberFieldsFragment = { + id: 'member-charlie-walker', + memberName, + profile: { + name: '', + email: '', + bio: '', + showInterests: false, + showEmail: false, + showProfile: false, + showLocation: false, + showProperties: false, + }, + createdAt: new Date(), + updatedAt: new Date(), }; await actor.attemptsTo( - notes().set('memberCreated', false), - notes().set('lastMemberName', ''), - notes().set('lastMemberCommunityName', ''), - Render.component(, { wrapper: wrapOcomComponent() }), - CreateMember(memberName), + notes().set('memberUpdated', false), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), ); }); -Then('the member should be created successfully in {string}', async (communityName: string) => { - const actor = actorInTheSpotlight(); - const submitted = await actor.answer(MemberCreatedFlag()); - const actualCommunityName = await actor.answer(notes().get('lastMemberCommunityName')); - +Then('the member should be created successfully in {string}', async (_communityName: string) => { + const submitted = await actorInTheSpotlight().answer(MemberCreatedFlag()); if (!submitted) { - throw new Error(`Expected member creation to succeed for "${communityName}"`); + throw new Error('Expected member form to be submitted'); } +}); - if (actualCommunityName !== communityName) { - throw new Error(`Expected member to be created in "${communityName}" but got "${actualCommunityName}"`); - } +When('{word} attempts to create a member in {string} with:', async (actorName: string, _communityName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actor.attemptsTo(notes().set('memberCreated', false), notes().set('memberValidationError', ''), CreateMember(details.memberName?.trim() ?? '')); + const page: AcceptanceUiMemberCreatePage = new MemberCreatePage(new DomPageAdapter(RenderInDom.as(actor).container)); + await actor.attemptsTo(notes().set('memberValidationError', (await page.firstValidationError.textContent()) ?? '')); }); -Then('the member {string} should belong to {string}', async (memberName: string, communityName: string) => { - const actor = actorInTheSpotlight(); - const belongs = await actor.answer(MemberBelongsToCommunity(memberName, communityName)); +Then('she should see a member error for {string}', async (fieldName: string) => { + const error = await actorInTheSpotlight().answer(notes().get('memberValidationError')); + if (!error || !/cannot be empty|required|missing|invalid|must not be empty|please input/i.test(error)) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); + } +}); - if (!belongs) { - throw new Error(`Expected member "${memberName}" to belong to "${communityName}"`); +Then('no new member should be created in {string}', async (_communityName: string) => { + if (await actorInTheSpotlight().answer(MemberCreatedFlag())) { + throw new Error('Expected no member to be created, but the form was submitted'); } }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index 29f08f668..81eabfb40 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -1,3 +1,6 @@ // Community context step definitions import './create-community.steps.tsx'; import './create-member.steps.tsx'; +import './remove-member.steps.tsx'; +import './assign-member-account.steps.tsx'; +import './update-member.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx new file mode 100644 index 000000000..9fac2004b --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx @@ -0,0 +1,64 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { MemberList } from '../../../../../../ocom/ui-community-route-admin/src/components/members-list.tsx'; +import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../../../ocom/ui-community-route-admin/src/generated.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; +import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; +import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; +import { RemoveMember } from '../tasks/remove-member.ts'; + +When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { + const actor = actorCalled(actorName); + const memberId = await actor.answer(notes().get('lastMemberId')); + const member: AdminMemberListContainerMemberFieldsFragment = { + id: memberId, + memberName, + isAdmin: false, + accounts: [], + profile: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const onRemoveMember = async (removedMemberId: string): Promise => { + await actor.attemptsTo(notes().set('memberRemoved', removedMemberId === memberId)); + return removedMemberId === memberId; + }; + + await actor.attemptsTo( + notes().set('memberRemoved', false), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + RemoveMember(memberName), + ); + + if (await actor.answer(MemberRemovedFlag())) { + await actor.attemptsTo( + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + ); + } +}); + +Then('the member should be removed successfully from {string}', async (_communityName: string) => { + if (!(await actorInTheSpotlight().answer(MemberRemovedFlag()))) { + throw new Error('Expected member removal callback to succeed'); + } +}); + +Then('member {string} should not appear in member listings for {string}', async (memberName: string, _communityName: string) => { + if (await actorInTheSpotlight().answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the rendered member list`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-member.steps.tsx new file mode 100644 index 000000000..d53833ce2 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-member.steps.tsx @@ -0,0 +1,62 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberProfileExpectation, MemberProfileFormValues, MemberUiNotes } from '../notes/member-notes.ts'; +import { MemberUpdatedFlag, UpdatedMemberProfile } from '../questions/member-profile-updated.ts'; +import { UpdateMemberProfile } from '../tasks/update-member-profile.ts'; + +interface MemberProfileUpdateDetails { + name?: string; + email?: string; + bio?: string; + avatarDocumentId?: string; + interests?: string; + showInterests?: string; + showEmail?: string; + showProfile?: string; + showLocation?: string; + showProperties?: string; +} + +When('{word} updates member {string} in {string} with:', async (actorName: string, _memberName: string, _communityName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + const profile = toMemberProfileExpectation(GherkinDataTable.from(dataTable).rowsHash()); + + await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('expectedMemberProfile', profile), UpdateMemberProfile(profile)); +}); + +Then('the member should be updated successfully in {string}', async (_communityName: string) => { + const actor = actorInTheSpotlight(); + const updated = await actor.answer(MemberUpdatedFlag()); + if (!updated) { + throw new Error('Expected member profile form to be submitted'); + } + + assertProfileMatches(await actor.answer(notes().get('expectedMemberProfile')), await actor.answer(UpdatedMemberProfile())); +}); + +function toMemberProfileExpectation(details: MemberProfileUpdateDetails): MemberProfileExpectation { + return { + name: details.name ?? '', + email: details.email ?? '', + bio: details.bio ?? '', + showInterests: toBoolean(details.showInterests), + showEmail: toBoolean(details.showEmail), + showProfile: toBoolean(details.showProfile), + showLocation: toBoolean(details.showLocation), + showProperties: toBoolean(details.showProperties), + }; +} + +function toBoolean(value: string | undefined): boolean { + return value?.trim().toLowerCase() === 'true'; +} + +function assertProfileMatches(expected: MemberProfileExpectation, actual: MemberProfileFormValues): void { + for (const [field, expectedValue] of Object.entries(expected)) { + const actualValue = actual[field as keyof MemberProfileFormValues]; + if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { + throw new Error(`Expected profile ${field} to be ${JSON.stringify(expectedValue)}, but got ${JSON.stringify(actualValue)}`); + } + } +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/assign-member-account.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/assign-member-account.ts new file mode 100644 index 000000000..e96e237a8 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/assign-member-account.ts @@ -0,0 +1,20 @@ +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiMemberAccountsPage } from '../../../shared/page-contracts.ts'; + +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +export const AssignMemberAccount = (): Task => + Task.where( + '#actor assigns the selected end-user account to a member', + new TaskStep('#actor adds the selected member account', async () => { + const page: AcceptanceUiMemberAccountsPage = new MemberAccountsPage(new DomPageAdapter(document.body)); + await page.clickAddMemberAccount(); + await flushPendingReactWork(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/remove-member.ts new file mode 100644 index 000000000..ffe7bd493 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/remove-member.ts @@ -0,0 +1,16 @@ +import { 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 { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiMemberListPage } from '../../../shared/page-contracts.ts'; + +export const RemoveMember = (memberName: string): Task => + Task.where( + '#actor removes the member', + new TaskStep('#actor clicks Remove for the member', async (actor) => { + const page: AcceptanceUiMemberListPage = new MemberListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + await page.clickRemoveMember(memberName); + await new Promise((resolve) => setTimeout(resolve, 0)); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-profile.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-profile.ts new file mode 100644 index 000000000..30ebaab40 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-profile.ts @@ -0,0 +1,33 @@ +import { 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 { MemberProfilePage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiMemberProfilePage } from '../../../shared/page-contracts.ts'; +import type { MemberProfileFormValues } from '../notes/member-notes.ts'; + +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +export const UpdateMemberProfile = (profile: MemberProfileFormValues): Task => + Task.where( + '#actor updates the member profile', + new TaskStep('#actor edits and saves the member profile', async (actor) => { + const page: AcceptanceUiMemberProfilePage = new MemberProfilePage(new DomPageAdapter(RenderInDom.as(actor).container)); + + await page.clickEditProfile(); + await page.fillDisplayName(profile.name); + await page.fillEmail(profile.email); + await page.fillBio(profile.bio); + await page.setShowInterests(profile.showInterests); + await page.setShowEmail(profile.showEmail); + await page.setShowProfile(profile.showProfile); + await page.setShowLocation(profile.showLocation); + await page.setShowProperties(profile.showProperties); + await page.clickSaveProfile(); + + await flushPendingReactWork(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index e109df9ed..bd6234f38 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -1,7 +1,16 @@ -import type { CommunityPage, HomePage, MemberCreatePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage, MemberAccountsPage, MemberCreatePage, MemberListPage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; export type AcceptanceUiHomePage = Pick; export type AcceptanceUiCommunityPage = Pick; export type AcceptanceUiMemberCreatePage = Pick; + +export type AcceptanceUiMemberListPage = Pick; + +export type AcceptanceUiMemberAccountsPage = Pick; + +export type AcceptanceUiMemberProfilePage = Pick< + MemberProfilePage, + 'clickEditProfile' | 'clickSaveProfile' | 'fillDisplayName' | 'fillEmail' | 'fillBio' | 'setShowInterests' | 'setShowEmail' | 'setShowProfile' | 'setShowLocation' | 'setShowProperties' +>; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts index 9b8ba42e4..7eace2e24 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -14,6 +14,7 @@ export interface MemberProfileExpectation { export interface MemberE2ENotes { communityIdsByName: Record; endUserEmailsByLabel: Record; + endUserDisplayNamesByLabel: Record; endUserExternalIdsByLabel: Record; lastMemberCommunityId: string | null; lastMemberId: string | null; @@ -23,5 +24,7 @@ export interface MemberE2ENotes { lastMemberStatus: string | null; memberUpdated: boolean; memberCreated: boolean; + memberRemoved: boolean; + memberAccountLinked: boolean; errorMessage: string | null; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts new file mode 100644 index 000000000..5a509b1a6 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,14 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Question } from '@serenity-js/core'; +import type { E2EMemberAccountsPage } from '../../../shared/page-contracts.ts'; + +export const MemberAccountLinked = (email: string) => + Question.about(`whether the member account list contains "${email}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const memberAccountsPage: E2EMemberAccountsPage = new MemberAccountsPage(new PlaywrightPageAdapter(page)); + await memberAccountsPage.linkedAccountEmail(email).waitFor({ state: 'visible', timeout: 15_000 }); + return await memberAccountsPage.linkedAccountEmail(email).isVisible(); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts deleted file mode 100644 index 079f8483e..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-belongs-to-community.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { type Actor, notes, Question } from '@serenity-js/core'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { gotoCommunityMembersListPage } from '../support/community-admin-navigation.ts'; - -export const MemberBelongsToCommunity = (memberName: string, communityName: string) => - Question.about(`whether member "${memberName}" belongs to community "${communityName}"`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - const expectedCommunityId = communityIdsByName[communityName]; - if (!expectedCommunityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - const route = await gotoCommunityMembersListPage(page, communityName); - if (route.communityId !== expectedCommunityId) { - return false; - } - - const memberCell = page.getByRole('cell', { name: memberName, exact: true }).first(); - await memberCell.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined); - - return memberCell.isVisible().catch(() => false); - }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-error-message.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-error-message.ts new file mode 100644 index 000000000..83576feb6 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-error-message.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const MemberErrorMessage = () => Question.about('the member validation error', (actor) => actor.answer(notes().get('errorMessage'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts new file mode 100644 index 000000000..42b45307b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts @@ -0,0 +1,12 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Question } from '@serenity-js/core'; + +export const MemberListedInCommunity = (memberName: string) => + Question.about(`whether member "${memberName}" appears in the browser member list`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const memberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); + return await memberListPage.memberName(memberName).isVisible(); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts new file mode 100644 index 000000000..4b204ea0a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts @@ -0,0 +1,4 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const MemberRemovedFlag = () => Question.about('whether the member was removed', (actor) => actor.answer(notes().get('memberRemoved'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts new file mode 100644 index 000000000..84cc63802 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -0,0 +1,42 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; + +const accountDetails: Record = { + Charlie: { + displayName: `${actors.CommunityOwner.givenName} ${actors.CommunityOwner.familyName}`, + email: actors.CommunityOwner.email, + externalId: actors.CommunityOwner.externalId, + }, +}; + +Given('end-user account {string} is available for assignment in {string}', async (accountLabel: string, _communityName: string) => { + const actor = actorInTheSpotlight(); + const account = accountDetails[accountLabel]; + if (!account) { + throw new Error(`Unknown end-user account label "${accountLabel}"`); + } + await actor.attemptsTo( + notes().set('endUserDisplayNamesByLabel', { [accountLabel]: account.displayName }), + notes().set('endUserEmailsByLabel', { [accountLabel]: account.email }), + notes().set('endUserExternalIdsByLabel', { [accountLabel]: account.externalId }), + notes().set('memberAccountLinked', false), + ); +}); + +When('{word} associates end-user account {string} to member {string} in {string}', async (actorName: string, accountLabel: string, _memberName: string, communityName: string) => { + const actor = actorCalled(actorName); + const displayNames = await actor.answer(notes().get('endUserDisplayNamesByLabel')); + await actor.attemptsTo(AssignMemberAccount(communityName, displayNames[accountLabel] ?? '')); +}); + +Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const emails = await actor.answer(notes().get('endUserEmailsByLabel')); + if (!(await actor.answer(MemberAccountLinked(emails[accountLabel] ?? '')))) { + throw new Error(`Expected the member account list to contain end-user account "${accountLabel}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts index 51eb733b1..fc826090c 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts @@ -6,8 +6,8 @@ import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; import { clearKnownQueueMessages } from '../../../shared/support/queue-storage.ts'; import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { MemberBelongsToCommunity } from '../questions/member-belongs-to-community.ts'; import { MemberCreatedFlag } from '../questions/member-created-flag.ts'; +import { MemberErrorMessage } from '../questions/member-error-message.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; import { CreateMember } from '../tasks/create-member.ts'; @@ -15,10 +15,6 @@ interface MemberDetails { memberName?: string; } -const seededEndUserLabelMap: Record = { - Charlie: 'CommunityMember', -}; - let lastActorName = actors.CommunityOwner.name; Given('{word} is signed in as a community owner for member management', async (actorName: string) => { @@ -49,29 +45,6 @@ Given('{word} has a community named {string}', async (actorName: string, communi ); }); -Given('{word} is an existing end-user account', async (label: string) => { - const actorKey = seededEndUserLabelMap[label]; - if (!actorKey) { - throw new Error(`No seeded end-user mapping for label "${label}"`); - } - - const seeded = actors[actorKey]; - const actor = actorCalled(lastActorName); - const existingExternalIds = (await actor.answer(notes().get('endUserExternalIdsByLabel')).catch(() => ({}) as Record)) as Record; - const existingEmails = (await actor.answer(notes().get('endUserEmailsByLabel')).catch(() => ({}) as Record)) as Record; - - await actor.attemptsTo( - notes().set('endUserExternalIdsByLabel', { - ...existingExternalIds, - [label]: seeded.externalId, - }), - notes().set('endUserEmailsByLabel', { - ...existingEmails, - [label]: seeded.email, - }), - ); -}); - When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); @@ -82,53 +55,40 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm throw new Error('memberName is required'); } - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', null), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', null), - ); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', null)); await actor.attemptsTo(CreateMember(communityName, memberName)); }); -Then('the member should be created successfully in {string}', async (communityName: string) => { +Then('the member should be created successfully in {string}', async (_communityName: string) => { const actor = actorCalled(lastActorName); - - const memberCreated = await actor.answer(MemberCreatedFlag()); - const validationError = await actor.answer(notes().get('errorMessage')).catch(() => null); - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - const expectedCommunityId = communityIdsByName[communityName]; - const actualCommunityId = (await actor.answer(notes().get('lastMemberCommunityId')).catch(() => null)) as string | null; - const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; - - if (!expectedCommunityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - if (validationError) { - throw new Error(`Expected member creation to succeed for "${communityName}", but got: "${validationError}"`); - } - - if (!memberCreated) { - throw new Error(`Expected member creation to succeed for "${communityName}"`); + const created = await actor.answer(MemberCreatedFlag()); + if (!created) { + throw new Error('Expected member creation to succeed'); } +}); - if (!memberId) { - throw new Error(`Expected a created member id for "${communityName}" but none was recorded`); +When('{word} attempts to create a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', null)); + try { + await actor.attemptsTo(CreateMember(communityName, details.memberName?.trim() ?? '')); + } catch (error) { + await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error))); } +}); - if (actualCommunityId !== expectedCommunityId) { - throw new Error(`Expected created member communityId "${expectedCommunityId}" but got "${actualCommunityId ?? 'null'}"`); +Then('she should see a member error for {string}', async (fieldName: string) => { + const error = await actorCalled(lastActorName).answer(MemberErrorMessage()); + if (!error || !/cannot be empty|required|missing|invalid|must not be empty|please input/i.test(error)) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); } }); -Then('the member {string} should belong to {string}', async (memberName: string, communityName: string) => { - const actor = actorCalled(lastActorName); - const belongs = await actor.answer(MemberBelongsToCommunity(memberName, communityName)); - - if (!belongs) { - throw new Error(`Expected member "${memberName}" to belong to "${communityName}"`); +Then('no new member should be created in {string}', async (_communityName: string) => { + if (await actorCalled(lastActorName).answer(MemberCreatedFlag())) { + throw new Error('Expected no member to be created, but one was created'); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index 46874774b..1d0991a2b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -1,4 +1,6 @@ // Community context step definitions import './create-community.steps.ts'; import './create-member.steps.ts'; +import './remove-member.steps.ts'; +import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts new file mode 100644 index 000000000..0be54c5b9 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -0,0 +1,27 @@ +import { Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; +import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; +import { RemoveMember } from '../tasks/remove-member.ts'; + +let lastActorName = actors.CommunityOwner.name; + +When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('memberRemoved', false), RemoveMember(communityName, memberName)); +}); + +Then('the member should be removed successfully from {string}', async (_communityName: string) => { + if (!(await actorCalled(lastActorName).answer(MemberRemovedFlag()))) { + throw new Error('Expected member removal to succeed'); + } +}); + +Then('member {string} should not appear in member listings for {string}', async (memberName: string, _communityName: string) => { + if (await actorCalled(lastActorName).answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the browser member list`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts index 563bdccb6..e0d08704b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts @@ -43,7 +43,7 @@ When('{word} updates member {string} in {string} with:', async (actorName: strin ...(details.showProperties !== undefined ? { showProperties: parseBoolean(details.showProperties) } : {}), }; - await actor.attemptsTo(notes().set('lastMemberStatus', null), notes().set('memberUpdated', false), notes().set('errorMessage', null)); + await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', null)); await actor.attemptsTo( UpdateMemberProfile({ @@ -54,32 +54,10 @@ When('{word} updates member {string} in {string} with:', async (actorName: strin ); }); -Then('the member should be updated successfully in {string}', async (communityName: string) => { +Then('the member should be updated successfully in {string}', async (_communityName: string) => { const actor = actorCalled(lastActorName); - - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - const status = (await actor.answer(notes().get('lastMemberStatus')).catch(() => null)) as string | null; - const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; - const memberUpdated = (await actor.answer(notes().get('memberUpdated')).catch(() => false)) as boolean; - const updateError = (await actor.answer(notes().get('errorMessage')).catch(() => null)) as string | null; - - if (!communityIdsByName[communityName]) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - if (updateError) { - throw new Error(`Expected member update to succeed for "${communityName}", but got: "${updateError}"`); - } - - if (!memberUpdated) { - throw new Error(`Expected member update to succeed for "${communityName}"`); - } - - if (status !== 'SUCCESS') { - throw new Error(`Expected member update status "SUCCESS" but got "${status ?? 'null'}"`); - } - - if (!memberId) { - throw new Error(`Expected an updated member id for "${communityName}" but none was recorded`); + const updated = await actor.answer(notes().get('memberUpdated')); + if (!updated) { + throw new Error('Expected member update to succeed'); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts deleted file mode 100644 index a2fe87b25..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/support/community-admin-navigation.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Page } from 'playwright'; - -interface CommunityAdminRoute { - communityId: string; - memberId: string; -} - -const communityAdminUrlPattern = /\/community\/([^/]+)\/admin\/([^/?#]+)/; - -const parseCommunityAdminRoute = (url: string): CommunityAdminRoute => { - const matched = url.match(communityAdminUrlPattern); - if (!matched) { - throw new Error(`Expected admin route URL, but got "${url}"`); - } - - return { - communityId: matched[1] ?? '', - memberId: matched[2] ?? '', - }; -}; - -const communityTableRow = (page: Page, communityName: string) => - page - .getByRole('row', { - name: new RegExp(communityName, 'i'), - }) - .first(); - -const openAdminPortalFromCommunityRow = async (page: Page, communityName: string): Promise => { - const row = communityTableRow(page, communityName); - await row.waitFor({ state: 'visible', timeout: 15_000 }); - - await row.getByRole('button', { name: /Admin Portals/i }).click(); - - const visibleDropdown = page.locator('.ant-dropdown:visible').last(); - await visibleDropdown.waitFor({ state: 'visible', timeout: 10_000 }); - - await visibleDropdown - .getByRole('button') - .first() - .click() - .catch(async () => { - await visibleDropdown.getByRole('menuitem').first().click(); - }); -}; - -const openCommunityAdminPortal = async (page: Page, communityName: string): Promise => { - await page.goto('/community/accounts', { waitUntil: 'networkidle' }); - await openAdminPortalFromCommunityRow(page, communityName); - - await page.waitForURL(/\/community\/[^/]+\/admin\/[^/?#]+(?:\/)?(?:\?.*)?$/, { timeout: 15_000 }); - return parseCommunityAdminRoute(page.url()); -}; - -export const gotoCommunityMembersCreatePage = async (page: Page, communityName: string): Promise => { - const route = await openCommunityAdminPortal(page, communityName); - await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members/create`, { - waitUntil: 'networkidle', - }); - - return route; -}; - -export const gotoCommunityMembersListPage = async (page: Page, communityName: string): Promise => { - const route = await openCommunityAdminPortal(page, communityName); - await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members`, { - waitUntil: 'networkidle', - }); - - return route; -}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts new file mode 100644 index 000000000..509272ed0 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts @@ -0,0 +1,74 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import type { E2EMemberAccountsPage } from '../../../shared/page-contracts.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +const endUsersOperationName = 'AdminMembersAccountsAddContainerEndUsersByCommunity'; +const createAccountOperationName = 'AdminMembersAccountsAddContainerMemberCreateAccount'; +const memberAccountsOperationName = 'AdminMembersAccountsListContainerMember'; + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +type CreateAccountPayload = GraphqlPayload<{ + memberCreateAccount?: { + status?: { success?: boolean; errorMessage?: string | null }; + member?: { id?: string | null } | null; + }; +}>; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); + +const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +export const AssignMemberAccount = (communityName: string, displayName: string) => + Interaction.where(the`#actor assigns end-user account "${displayName}" in "${communityName}" via UI`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + const memberId = await actor.answer(notes().get('lastMemberId')); + const adminMemberId = new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]; + if (!communityId || !memberId || !adminMemberId) { + throw new Error(`Missing community, member, or administrator state for account assignment in "${communityName}"`); + } + + const endUsersResponse = page.waitForResponse(hasGraphqlOperation(endUsersOperationName), { timeout: 15_000 }); + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/${encodeURIComponent(memberId)}/accounts/add`, { waitUntil: 'networkidle' }); + await endUsersResponse; + + const memberAccountsPage: E2EMemberAccountsPage = new MemberAccountsPage(new PlaywrightPageAdapter(page)); + await memberAccountsPage.selectEndUser(displayName); + + const mutationResponse = page.waitForResponse(hasGraphqlOperation(createAccountOperationName), { timeout: 15_000 }); + const accountsResponse = page.waitForResponse(hasGraphqlOperation(memberAccountsOperationName), { timeout: 15_000 }); + await memberAccountsPage.clickAddMemberAccount(); + + const response = await mutationResponse; + const payload = selectGraphqlPayload((await response.json()) as CreateAccountPayload | CreateAccountPayload[], (data) => Boolean(data?.memberCreateAccount)); + const result = payload?.data?.memberCreateAccount; + if (!response.ok() || payload?.errors?.length || result?.status?.success !== true || result.member?.id !== memberId) { + const message = + result?.status?.errorMessage ?? + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; ') ?? + 'Member account assignment failed'; + await actor.attemptsTo(notes().set('memberAccountLinked', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await accountsResponse; + await actor.attemptsTo(notes().set('memberAccountLinked', true), notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts index 29699eb62..72c4867e5 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts @@ -5,9 +5,9 @@ import { type Actor, Interaction, notes, the } from '@serenity-js/core'; import type { Response } from 'playwright'; import type { E2EMemberCreatePage } from '../../../shared/page-contracts.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { gotoCommunityMembersCreatePage } from '../support/community-admin-navigation.ts'; const createMemberOperationName = 'AdminMembersCreateContainerMemberCreate'; +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; type GraphqlPayload = { data?: TData; @@ -27,6 +27,14 @@ type MemberCreateGraphqlPayload = GraphqlPayload<{ }; }>; +type MembersForCurrentEndUserPayload = GraphqlPayload<{ + membersForCurrentEndUser?: Array<{ + id: string; + isAdmin?: boolean | null; + community?: { id: string } | null; + }>; +}>; + const hasGraphqlOperation = (operationName: string) => (response: Response) => { if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { return false; @@ -53,8 +61,23 @@ export const CreateMember = (communityName: string, memberName: string) => Interaction.where(the`#actor creates member "${memberName}" in "${communityName}" via UI`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const communityId = communityIdsByName[communityName] ?? null; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } - const { communityId } = await gotoCommunityMembersCreatePage(page, communityName); + // get owner member id + const membersResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membersPayload = selectGraphqlPayload((await (await membersResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); + const adminMemberId = membersPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!adminMemberId) { + throw new Error(`No administrator membership found for community "${communityId}"`); + } + + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/create`, { waitUntil: 'networkidle' }); + await page.getByPlaceholder('Member Name').first().waitFor({ state: 'visible', timeout: 15_000 }); const adapter = new PlaywrightPageAdapter(page); const memberCreatePage: E2EMemberCreatePage = new MemberCreatePage(adapter); @@ -65,95 +88,52 @@ export const CreateMember = (communityName: string, memberName: string) => await memberCreatePage.clickCreateMember(); await memberCreatePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - const hasValidationError = await memberCreatePage.firstValidationError.isVisible().catch(() => false); - if (hasValidationError) { - const validationMessage = await memberCreatePage.firstValidationError.textContent(); - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', validationMessage || 'Validation error'), - ); + const validationError = await memberCreatePage.firstValidationError.isVisible().catch(() => false); + if (validationError) { + const errorText = await memberCreatePage.firstValidationError.textContent(); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', errorText || 'Validation error')); return; } const mutationResponse = await createMutationResponse; - if (!mutationResponse) { - const message = `${createMemberOperationName} did not return a GraphQL response`; - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', message), - ); - throw new Error(message); - } - - const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberCreateGraphqlPayload | MemberCreateGraphqlPayload[] | null, (data) => Boolean(data?.memberCreate)); - const graphqlError = graphqlErrors(payload); - const mutationResult = payload?.data?.memberCreate; - const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - const createdMemberName = mutationResult?.member?.memberName ?? null; - - if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true || (createdMemberName !== null && createdMemberName !== memberName)) { - const message = - mutationError || - (mutationResult?.status?.success !== true - ? `${createMemberOperationName} did not report success: ${JSON.stringify(payload)}` - : createdMemberName !== memberName - ? `Expected created member name "${memberName}" but GraphQL returned "${createdMemberName ?? 'null'}"` - : `Member create GraphQL request failed with HTTP ${mutationResponse.status()}`); - - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', message), - ); - throw new Error(message); - } - - const memberId = mutationResult?.member?.id ?? null; - if (!memberId) { - const message = `${createMemberOperationName} succeeded but returned no member id`; - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', message), - ); - throw new Error(message); + if (mutationResponse) { + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberCreateGraphqlPayload | MemberCreateGraphqlPayload[] | null, (data) => Boolean(data?.memberCreate)); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.memberCreate; + const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; + const createdName = mutationResult?.member?.memberName ?? null; + + if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== memberName)) { + const message = + mutationError || + (mutationResult?.status?.success !== true + ? `${createMemberOperationName} did not report success: ${JSON.stringify(payload)}` + : createdName !== memberName + ? `Expected created member name "${memberName}" but GraphQL returned "${createdName ?? 'null'}"` + : `Member create GraphQL request failed with HTTP ${mutationResponse.status()}`); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const memberId = mutationResult?.member?.id ?? null; + if (!memberId) { + const message = `${createMemberOperationName} succeeded but returned no member id`; + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('lastMemberId', memberId)); } - await page - .waitForURL(new RegExp(`/community/${communityId}/admin/[^/]+/members/[^/?#]+(?:/.*)?(?:\\?.*)?$`), { - timeout: 15_000, - }) - .catch(() => undefined); + await page.waitForURL(new RegExp(`/community/${communityId}/admin/[^/]+/members/[^/?#]+(?:/.*)?(?:\\?.*)?$`), { timeout: 15_000 }).catch(() => undefined); await memberCreatePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); const hasErrorToast = await memberCreatePage.errorToast.isVisible().catch(() => false); if (hasErrorToast) { const errorText = await memberCreatePage.errorToast.textContent(); const message = errorText || 'Member creation failed'; - await actor.attemptsTo( - notes().set('lastMemberId', null), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', false), - notes().set('errorMessage', message), - ); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); throw new Error(message); } - await actor.attemptsTo( - notes().set('lastMemberId', memberId), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', memberName), - notes().set('memberCreated', true), - notes().set('errorMessage', null), - ); + await actor.attemptsTo(notes().set('memberCreated', true), notes().set('errorMessage', null)); }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts new file mode 100644 index 000000000..2cc1a774d --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts @@ -0,0 +1,63 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import type { E2EMemberListPage } from '../../../shared/page-contracts.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +const membersOperationName = 'AdminMemberListContainerMembers'; +const removeMemberOperationName = 'AdminMemberListContainerRemoveMember'; + +type RemoveMemberPayload = { + data?: { removeMember?: { status?: { success?: boolean; errorMessage?: string | null } } }; + errors?: Array<{ message?: string }>; +}; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); + +const selectPayload = (payload: RemoveMemberPayload | RemoveMemberPayload[] | null): RemoveMemberPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + return payload.find((item) => item.data?.removeMember) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +export const RemoveMember = (communityName: string, memberName: string) => + Interaction.where(the`#actor removes member "${memberName}" from "${communityName}" via UI`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + const adminMemberId = new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]; + if (!communityId || !adminMemberId) { + throw new Error(`Missing community or administrator state for removal from "${communityName}"`); + } + + const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 15_000 }); + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members`, { waitUntil: 'networkidle' }); + await membersResponse; + + const memberListPage: E2EMemberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); + await memberListPage.memberName(memberName).waitFor({ state: 'visible', timeout: 15_000 }); + const mutationResponse = page.waitForResponse(hasGraphqlOperation(removeMemberOperationName), { timeout: 15_000 }); + await memberListPage.clickRemoveMember(memberName); + + const response = await mutationResponse; + const payload = selectPayload((await response.json().catch(() => null)) as RemoveMemberPayload | RemoveMemberPayload[] | null); + const result = payload?.data?.removeMember; + if (!response.ok() || payload?.errors?.length || result?.status?.success !== true) { + const message = + result?.status?.errorMessage ?? + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; ') ?? + 'Member removal failed'; + await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await memberListPage.memberName(memberName).waitFor({ state: 'hidden', timeout: 15_000 }); + await actor.attemptsTo(notes().set('memberRemoved', true), notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts index f69c3fc83..9d0dca612 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts @@ -5,7 +5,6 @@ import { type Actor, Interaction, notes, the } from '@serenity-js/core'; import type { Response } from 'playwright'; import type { E2EMemberProfilePage } from '../../../shared/page-contracts.ts'; import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; -import { gotoCommunityMembersListPage } from '../support/community-admin-navigation.ts'; interface UpdateMemberProfileDetails { communityName: string; @@ -19,6 +18,7 @@ type GraphqlPayload = { }; const memberUpdateProfileOperationName = 'SharedMemberProfileContainerMemberUpdateProfile'; +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; const hasGraphqlOperation = (operationName: string) => (response: Response) => { if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { @@ -54,96 +54,86 @@ type MemberUpdateProfileMutationPayload = GraphqlPayload<{ }; }>; +type MembersForCurrentEndUserPayload = GraphqlPayload<{ + membersForCurrentEndUser?: Array<{ + id: string; + isAdmin?: boolean | null; + community?: { id: string } | null; + }>; +}>; + export const UpdateMemberProfile = (details: UpdateMemberProfileDetails) => Interaction.where(the`#actor updates member profile for "${details.memberName}" in "${details.communityName}"`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); const memberProfilePage: E2EMemberProfilePage = new MemberProfilePage(new PlaywrightPageAdapter(page)); - let memberId: string | null = null; - let communityId: string | null = null; - - try { - const route = await gotoCommunityMembersListPage(page, details.communityName); - communityId = route.communityId; - memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; - if (!memberId) { - throw new Error('Cannot update member profile because no member id is available in notes'); - } - - await page.goto(`/community/${route.communityId}/admin/${route.memberId}/members/${memberId}/profile`, { - waitUntil: 'networkidle', - }); - - await memberProfilePage.clickEditProfile(); - - if (details.profile.name !== undefined) await memberProfilePage.fillDisplayName(details.profile.name); - if (details.profile.email !== undefined) await memberProfilePage.fillEmail(details.profile.email); - if (details.profile.bio !== undefined) await memberProfilePage.fillBio(details.profile.bio); - if (details.profile.showInterests !== undefined) await memberProfilePage.setShowInterests(details.profile.showInterests); - if (details.profile.showEmail !== undefined) await memberProfilePage.setShowEmail(details.profile.showEmail); - if (details.profile.showProfile !== undefined) await memberProfilePage.setShowProfile(details.profile.showProfile); - if (details.profile.showLocation !== undefined) await memberProfilePage.setShowLocation(details.profile.showLocation); - if (details.profile.showProperties !== undefined) await memberProfilePage.setShowProperties(details.profile.showProperties); - - const mutationResponsePromise = page.waitForResponse(hasGraphqlOperation(memberUpdateProfileOperationName), { timeout: 15_000 }).catch(() => null); - await memberProfilePage.clickSaveProfile(); + const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; + const communityId = communityIdsByName[details.communityName] ?? null; + if (!communityId) { + throw new Error(`Unknown community "${details.communityName}". Ensure it was created in setup.`); + } - await memberProfilePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - if (await memberProfilePage.firstValidationError.isVisible().catch(() => false)) { - throw new Error((await memberProfilePage.firstValidationError.textContent()) || 'Validation error'); - } + const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; + if (!memberId) { + throw new Error('Cannot update member profile because no member id is available in notes'); + } - const mutationResponse = await mutationResponsePromise; + // get owner member id + const membersResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membersPayload = selectGraphqlPayload((await (await membersResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); + const adminMemberId = membersPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!adminMemberId) { + throw new Error(`No administrator membership found for community "${communityId}"`); + } - if (!mutationResponse) { - throw new Error(`${memberUpdateProfileOperationName} did not return a GraphQL response`); - } + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/${encodeURIComponent(memberId)}/profile`, { waitUntil: 'networkidle' }); + await memberProfilePage.clickEditProfile(); + + if (details.profile.name !== undefined) await memberProfilePage.fillDisplayName(details.profile.name); + if (details.profile.email !== undefined) await memberProfilePage.fillEmail(details.profile.email); + if (details.profile.bio !== undefined) await memberProfilePage.fillBio(details.profile.bio); + if (details.profile.showInterests !== undefined) await memberProfilePage.setShowInterests(details.profile.showInterests); + if (details.profile.showEmail !== undefined) await memberProfilePage.setShowEmail(details.profile.showEmail); + if (details.profile.showProfile !== undefined) await memberProfilePage.setShowProfile(details.profile.showProfile); + if (details.profile.showLocation !== undefined) await memberProfilePage.setShowLocation(details.profile.showLocation); + if (details.profile.showProperties !== undefined) await memberProfilePage.setShowProperties(details.profile.showProperties); + + const mutationResponsePromise = page.waitForResponse(hasGraphqlOperation(memberUpdateProfileOperationName), { timeout: 15_000 }).catch(() => null); + await memberProfilePage.clickSaveProfile(); + + await memberProfilePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + const validationError = await memberProfilePage.firstValidationError.isVisible().catch(() => false); + if (validationError) { + const errorText = await memberProfilePage.firstValidationError.textContent(); + await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', errorText || 'Validation error')); + return; + } - const mutationPayload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberUpdateProfileMutationPayload | MemberUpdateProfileMutationPayload[] | null, (data) => - Boolean(data?.memberUpdateProfile), - ); - const mutationError = mutationPayload?.data?.memberUpdateProfile?.status?.errorMessage ?? graphqlErrors(mutationPayload); - const mutationResult = mutationPayload?.data?.memberUpdateProfile; - const updatedMember = mutationResult?.member; + const mutationResponse = await mutationResponsePromise; + if (mutationResponse) { + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberUpdateProfileMutationPayload | MemberUpdateProfileMutationPayload[] | null, (data) => Boolean(data?.memberUpdateProfile)); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.memberUpdateProfile; + const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - if (!mutationResponse.ok() || mutationResult?.status?.success !== true || !updatedMember) { - throw new Error( + if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true) { + const message = mutationError || - (mutationResult?.status?.success !== true - ? `${memberUpdateProfileOperationName} did not report success: ${JSON.stringify(mutationPayload)}` - : `Member update GraphQL request failed with HTTP ${mutationResponse.status()}`), - ); - } - - await memberProfilePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); - if (await memberProfilePage.errorToast.isVisible().catch(() => false)) { - throw new Error((await memberProfilePage.errorToast.textContent()) || 'Member profile update failed'); - } - - await actor.attemptsTo( - notes().set('lastMemberId', updatedMember.id ?? memberId), - notes().set('lastMemberCommunityId', route.communityId), - notes().set('lastMemberName', details.memberName), - notes().set('lastUpdatedMemberProfile', details.profile), - notes().set('lastMemberStatus', 'SUCCESS'), - notes().set('memberUpdated', true), - notes().set('errorMessage', null), - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - await actor.attemptsTo( - notes().set('lastMemberId', memberId), - notes().set('lastMemberCommunityId', communityId), - notes().set('lastMemberName', details.memberName), - notes().set('lastMemberStatus', 'FAILED'), - notes().set('memberUpdated', false), - notes().set('errorMessage', message), - ); - - if (error instanceof Error) { - throw error; + (mutationResult?.status?.success !== true ? `${memberUpdateProfileOperationName} did not report success: ${JSON.stringify(payload)}` : `Member update GraphQL request failed with HTTP ${mutationResponse.status()}`); + await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', message)); + throw new Error(message); } + } + await memberProfilePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + const hasErrorToast = await memberProfilePage.errorToast.isVisible().catch(() => false); + if (hasErrorToast) { + const errorText = await memberProfilePage.errorToast.textContent(); + const message = errorText || 'Member profile update failed'; + await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', message)); throw new Error(message); } + + await actor.attemptsTo(notes().set('memberUpdated', true), notes().set('errorMessage', null)); }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index c27a52ab5..827396297 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -1,4 +1,4 @@ -import type { CommunityPage, HomePage, MemberCreatePage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage, MemberAccountsPage, MemberCreatePage, MemberListPage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; export type E2EHomePage = Pick; @@ -6,6 +6,10 @@ export type E2ECommunityPage = Pick; +export type E2EMemberListPage = Pick; + +export type E2EMemberAccountsPage = Pick; + export type E2EMemberProfilePage = Pick< MemberProfilePage, 'clickEditProfile' | 'clickSaveProfile' | 'fillDisplayName' | 'fillEmail' | 'fillBio' | 'setShowInterests' | 'setShowEmail' | 'setShowProfile' | 'setShowLocation' | 'setShowProperties' | 'firstValidationError' | 'errorToast' diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 171f44825..253c07ead 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,4 +1,6 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; +export { MemberAccountsPage } from './member-accounts.page.ts'; export { MemberCreatePage } from './member-create.page.ts'; +export { MemberListPage } from './member-list.page.ts'; export { MemberProfilePage } from './member-profile.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts new file mode 100644 index 000000000..08d8fd464 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts @@ -0,0 +1,25 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +export class MemberAccountsPage extends AdapterBackedPageObject { + get endUserSelect(): ElementHandle { + return this.adapter.getByLabel('End User'); + } + + get addMemberAccountButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Add Member Account/i }); + } + + linkedAccountEmail(email: string): ElementHandle { + return this.adapter.getByText(email); + } + + async selectEndUser(displayName: string): Promise { + await this.endUserSelect.click(); + await this.endUserSelect.fill(displayName); + await this.adapter.getByText(displayName, { selector: '.ant-select-item-option-content' }).click(); + } + + async clickAddMemberAccount(): Promise { + await this.addMemberAccountButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts index d21e23c56..ad5032b9c 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts @@ -2,7 +2,7 @@ import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-fr export class MemberCreatePage extends AdapterBackedPageObject { get memberNameInput(): ElementHandle { - return this.adapter.getByPlaceholder('Member Name'); + return this.adapter.getByLabel('Member Name'); } get createMemberButton(): ElementHandle { diff --git a/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts new file mode 100644 index 000000000..94610c7ed --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts @@ -0,0 +1,24 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +export class MemberListPage extends AdapterBackedPageObject { + memberName(name: string): ElementHandle { + return this.adapter.getByText(name); + } + + async clickRemoveMember(name: string): Promise { + const row = this.adapter.getByRole('row', { name: new RegExp(name) }); + const buttons = await row.querySelectorAll('button'); + for (const button of buttons) { + if ((await button.textContent())?.trim() === 'Remove') { + await button.click(); + return; + } + } + const removeButton = this.adapter.getByRole('button', { name: /^Remove$/i }); + if (await removeButton.isVisible()) { + await removeButton.click(); + return; + } + throw new Error(`Remove action not found for member "${name}"`); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature deleted file mode 100644 index b859a468a..000000000 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-authorization-and-scope.feature +++ /dev/null @@ -1,28 +0,0 @@ -Feature: Community member authorization and scope boundaries - - As a community owner or administrator - I want member actions to respect authorization and community scope boundaries - So that only permitted users can act and shared end-user accounts remain correctly scoped per community - - Background: - Given Alice is signed in as a community owner for member management - And Alice has a community named "Green Oaks" - And Alice has a community named "Blue Harbor" - And Charlie is an existing end-user account - - # @authorization @unimplemented - # Scenario: Unauthorized actor cannot manage community members - # Given Evan is an authenticated end-user without member-management permission in "Green Oaks" - # When Evan attempts to delete member "Charlie Walker" from "Green Oaks" - # Then Evan should receive an authorization error for member management - # And member "Charlie Walker" should remain in "Green Oaks" - - # @unimplemented - # Scenario: A single end-user account can belong to multiple communities - # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" - # And a member "Charlie Walker" exists in "Blue Harbor" with role "resident" - # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" - # And Alice associates end-user account "Charlie" to member "Charlie Walker" in "Blue Harbor" - # Then end-user account "Charlie" should be linked to a member in "Green Oaks" - # And end-user account "Charlie" should be linked to a member in "Blue Harbor" - # And each member association should remain scoped to its own community \ No newline at end of file diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 5d05cc407..79649aa31 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -1,94 +1,131 @@ -Feature: Community member lifecycle operations +Feature: Community member management As a community owner or administrator - I want to create, update, associate accounts to, list, filter, and delete members within a specific community - So that member records remain accurate and user-to-community access stays correctly scoped + I want to manage members within a specific community + So that member records and account access remain accurate and correctly scoped Background: Given Alice is signed in as a community owner for member management And Alice has a community named "Green Oaks" And Alice has a community named "Blue Harbor" - And Charlie is an existing end-user account Scenario: Create a member record in a community When Alice creates a member in "Green Oaks" with: | memberName | Charlie Walker | Then the member should be created successfully in "Green Oaks" - And the member "Charlie Walker" should belong to "Green Oaks" - # Scenario: Update all member profile fields within a community + Scenario: Update all member profile fields within a community + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + When Alice updates member "Charlie Walker" in "Green Oaks" with: + | name | Charles Walker | + | email | charles.walker@example.com | + | bio | Community gardener and volunteer | + | avatarDocumentId | avatar-charles-001 | + | interests | gardening,reading,volunteering | + | showInterests | true | + | showEmail | true | + | showProfile | true | + | showLocation | false | + | showProperties | true | + Then the member should be updated successfully in "Green Oaks" + + Scenario: Assign a community end-user account to a member + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + And end-user account "Charlie" is available for assignment in "Green Oaks" + When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + Then member "Charlie Walker" should be linked to end-user account "Charlie" + + # @unimplemented + # Scenario: Update a member role with a role from the same community # Given Alice creates a member in "Green Oaks" with: # | memberName | Charlie Walker | - # When Alice updates member "Charlie Walker" in "Green Oaks" with: - # | name | Charles Walker | - # | email | charles.walker@example.com | - # | bio | Community gardener and volunteer | - # | avatarDocumentId | avatar-charles-001 | - # | interests | gardening,reading,volunteering | - # | showInterests | true | - # | showEmail | true | - # | showProfile | true | - # | showLocation | false | - # | showProperties | true | - # Then the member should be updated successfully in "Green Oaks" + # And a role "board member" exists in "Green Oaks" + # When Alice changes member "Charlie Walker" in "Green Oaks" to role "board member" + # Then member "Charlie Walker" should have role "board member" in "Green Oaks" - # @unimplemented - # Scenario: Associate an existing end-user account to a member - # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" - # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" - # Then member "Charlie Walker" should be linked to end-user account "Charlie" + # @validation @unimplemented + # Scenario: Cannot assign a role from another community + # Given Alice creates a member in "Green Oaks" with: + # | memberName | Charlie Walker | + # And a role "harbor board member" exists in "Blue Harbor" + # When Alice attempts to change member "Charlie Walker" in "Green Oaks" to role "harbor board member" + # Then she should see a member error for "role" + # And member "Charlie Walker" should not have role "harbor board member" in "Green Oaks" # @unimplemented - # Scenario: List community members for owner and admin perspectives + # Scenario: List members as a community administrator # Given Bob is an authenticated community admin for "Green Oaks" # And the following members exist in "Green Oaks": # | memberName | role | # | Charlie Walker | resident | # | Dana Ortiz | board member | + # And Alice creates a member in "Blue Harbor" with: + # | memberName | Erin Lawson | # When Bob lists members for "Green Oaks" - # Then Bob should see 2 members for "Green Oaks" - # And Bob should not see members from "Blue Harbor" + # Then Bob should see the following members in "Green Oaks": + # | memberName | + # | Charlie Walker | + # | Dana Ortiz | + # And Bob should not see member "Erin Lawson" from "Blue Harbor" # @unimplemented - # Scenario: Filter community members by role + # Scenario: Filter the community member list by member name # Given the following members exist in "Green Oaks": # | memberName | role | # | Charlie Walker | resident | # | Dana Ortiz | board member | - # | Erin Lawson | resident | - # When Alice filters members in "Green Oaks" by role "resident" + # | Charlotte Webb | resident | + # When Alice searches the member list in "Green Oaks" for "Charlie" # Then Alice should see the following members in "Green Oaks": # | memberName | # | Charlie Walker | - # | Erin Lawson | + # And Alice should not see member "Dana Ortiz" in the filtered results - # @unimplemented - # Scenario: Delete a member from a community - # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" - # When Alice deletes member "Charlie Walker" from "Green Oaks" - # Then the member should be deleted successfully from "Green Oaks" - # And member "Charlie Walker" should not appear in member listings for "Green Oaks" - - # @unimplemented - # Scenario: Update member name while preserving existing account association - # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" - # And member "Charlie Walker" is already linked to end-user account "Charlie" - # When Alice updates member "Charlie Walker" in "Green Oaks" with: - # | memberName | Charles Walker | - # Then the member should be updated successfully in "Green Oaks" - # And member "Charles Walker" should be linked to end-user account "Charlie" + Scenario: Remove a member from a community + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + When Alice removes member "Charlie Walker" from "Green Oaks" + Then the member should be removed successfully from "Green Oaks" + And member "Charlie Walker" should not appear in member listings for "Green Oaks" - # @validation @unimplemented - # Scenario: Cannot create a member without a required name - # When Alice attempts to create a member in "Green Oaks" with: - # | memberName | | - # Then she should see a member error for "memberName" - # And no new member should be created in "Green Oaks" + @validation + Scenario: Cannot create a member without a required name + When Alice attempts to create a member in "Green Oaks" with: + | memberName | | + Then she should see a member error for "memberName" + And no new member should be created in "Green Oaks" # @validation @unimplemented # Scenario: Cannot associate the same end-user account twice to the same member - # Given a member "Charlie Walker" exists in "Green Oaks" with role "resident" + # Given Alice creates a member in "Green Oaks" with: + # | memberName | Charlie Walker | + # And end-user account "Charlie" is available for assignment in "Green Oaks" # And member "Charlie Walker" is already linked to end-user account "Charlie" # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" # Then she should see a member error for "accountAssociation" - # And member "Charlie Walker" should remain linked to end-user account "Charlie" only once \ No newline at end of file + # And member "Charlie Walker" should remain linked to end-user account "Charlie" only once + + # @authorization @unimplemented + # Scenario: A member without management permission cannot remove another member + # Given Alice creates a member in "Green Oaks" with: + # | memberName | Charlie Walker | + # And Evan is signed in as a member without member-management permission in "Green Oaks" + # When Evan attempts to remove member "Charlie Walker" from "Green Oaks" + # Then Evan should receive an authorization error for member management + # And member "Charlie Walker" should remain in "Green Oaks" + + # @unimplemented + # Scenario: A single end-user account can belong to multiple communities + # Given Alice creates a member in "Green Oaks" with: + # | memberName | Charlie Walker | + # And Alice creates a member in "Blue Harbor" with: + # | memberName | Charles Walker | + # And end-user account "Charlie" is available for assignment in "Green Oaks" + # And end-user account "Charlie" is available for assignment in "Blue Harbor" + # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + # And Alice associates end-user account "Charlie" to member "Charles Walker" in "Blue Harbor" + # Then end-user account "Charlie" should be linked to member "Charlie Walker" in "Green Oaks" + # And end-user account "Charlie" should be linked to member "Charles Walker" in "Blue Harbor" + # And each member association should remain scoped to its own community \ No newline at end of file From 5e8e546b1abddc88dde53d200d62d043503fcbc5 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Mon, 13 Jul 2026 22:13:01 -0400 Subject: [PATCH 05/15] added scenarios implementations for testing member and end user permissions --- apps/ui-community/mock-oidc.users.json | 12 ++++ .../contexts/community/notes/member-notes.ts | 3 + .../questions/member-account-linked.ts | 18 ++++++ .../assign-member-account.steps.ts | 27 ++++++++- .../step-definitions/create-member.steps.ts | 3 +- .../step-definitions/remove-member.steps.ts | 57 ++++++++++++++++++- .../contexts/community/tasks/remove-member.ts | 4 +- .../src/mock-application-services.ts | 8 +-- .../src/shared/abilities/remove-member.ts | 13 ++++- .../contexts/community/notes/member-notes.ts | 2 + .../questions/member-account-linked.ts | 2 + .../assign-member-account.steps.tsx | 28 ++++++++- .../step-definitions/create-member.steps.tsx | 3 +- .../step-definitions/remove-member.steps.tsx | 50 +++++++++++++++- .../contexts/community/notes/member-notes.ts | 2 + .../questions/member-account-linked.ts | 5 +- .../questions/member-listed-in-community.ts | 5 +- .../assign-member-account.steps.ts | 25 +++++++- .../step-definitions/create-member.steps.ts | 3 +- .../step-definitions/remove-member.steps.ts | 55 +++++++++++++++++- .../community/tasks/assign-member-account.ts | 5 +- .../contexts/community/tasks/remove-member.ts | 23 ++++++-- .../src/shared/abilities/oauth2-login.ts | 15 +++++ .../community/member/member-lifecycle.feature | 34 +++++------ 24 files changed, 356 insertions(+), 46 deletions(-) diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index f5518cba9..8724234f1 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -34,5 +34,17 @@ "family_name": "Owner", "tid": "test-tenant-id" } + }, + { + "username": "member@test.example", + "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee02", + "password": "password", + "oidcConfigName": "end-user", + "claims": { + "email": "member@test.example", + "given_name": "Test", + "family_name": "Member", + "tid": "test-tenant-id" + } } ] diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts index f826ea023..4c0899082 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -30,4 +30,7 @@ export interface MemberNotes { lastExpectedMemberProfileEmail?: string; lastMemberStatus: string; lastValidationError: string; + lastAuthorizationError: string; + principalMemberId: string; + authorizationToken: string; } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts index 79fde94ba..b1c8e7050 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts @@ -18,3 +18,21 @@ export class MemberAccountLinked extends Question> { return member?.accounts?.some((account) => account.user?.id === this.endUserId) ?? false; } } + +export class MemberAccountCount extends Question> { + constructor( + private readonly memberId: string, + private readonly endUserId: string, + ) { + super(`the number of links from member "${memberId}" to end user "${endUserId}"`); + } + + static for(memberId: string, endUserId: string): MemberAccountCount { + return new MemberAccountCount(memberId, endUserId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const member = await actor.answer(MemberById.withId(this.memberId)); + return member?.accounts?.filter((account) => account.user?.id === this.endUserId).length ?? 0; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts index 84f392333..57f08046e 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -3,7 +3,7 @@ import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; import { EndUserIdInCommunity } from '../questions/end-user-id-in-community.ts'; -import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { MemberAccountCount, MemberAccountLinked } from '../questions/member-account-linked.ts'; import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; const accountDisplayNames: Record = { @@ -28,7 +28,20 @@ When('{word} associates end-user account {string} to member {string} in {string} const communityIds = await actor.answer(notes().get('communityIdsByName')); const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); const memberId = await actor.answer(notes().get('lastMemberId')); - await actor.attemptsTo(AssignMemberAccount.to({ communityId: communityIds[communityName] ?? '', memberId, endUserId: endUserIds[accountLabel] ?? '' })); + await actor.attemptsTo(notes().set('lastValidationError', undefined as unknown as string)); + try { + await actor.attemptsTo(AssignMemberAccount.to({ communityId: communityIds[communityName] ?? '', memberId, endUserId: endUserIds[accountLabel] ?? '' })); + } catch (error) { + await actor.attemptsTo(notes().set('lastValidationError', error instanceof Error ? error.message : String(error))); + } +}); + +Given('member {string} is already linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const communityId = await actor.answer(notes().get('lastMemberCommunityId')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); + await actor.attemptsTo(AssignMemberAccount.to({ communityId, memberId, endUserId: endUserIds[accountLabel] ?? '' })); }); Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { @@ -39,3 +52,13 @@ Then('member {string} should be linked to end-user account {string}', async (_me throw new Error(`Expected member "${memberId}" to be linked to end-user account "${accountLabel}"`); } }); + +Then('member {string} should remain linked to end-user account {string} only once', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const memberId = await actor.answer(notes().get('lastMemberId')); + const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); + const count = await actor.answer(MemberAccountCount.for(memberId, endUserIds[accountLabel] ?? '')); + if (count !== 1) { + throw new Error(`Expected one account association for "${accountLabel}" but found ${count}`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts index 19fc05c4b..90b1207d6 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -95,7 +95,8 @@ Then('the member should be created successfully in {string}', async (_communityN Then('she should see a member error for {string}', async (fieldName: string) => { const actor = actorCalled(lastActorName); const error = await actor.answer(notes().get('lastValidationError')).catch(() => ''); - if (!error || !/cannot be empty|required|missing|invalid|must not be empty|too short|too long/i.test(error)) { + const isFieldMentioned = error.toLowerCase().includes(fieldName.toLowerCase()); + if (!error || (!isFieldMentioned && !/cannot be empty|required|missing|invalid|must not be empty|too short|too long|already associated/i.test(error))) { throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); } }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts index d1812914e..6697b8d20 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts @@ -1,11 +1,35 @@ -import { Then, When } from '@cucumber/cucumber'; +import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; -import { actorCalled, notes } from '@serenity-js/core'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; let lastActorName = actors.CommunityOwner.name; +let managementOwnerActorName = actors.CommunityOwner.name; + +Given('{word} is signed in without membership in {string}', async (actorName: string, communityName: string) => { + const owner = actorInTheSpotlight(); + managementOwnerActorName = owner.name; + const communityIds = await owner.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + const targetMemberId = await owner.answer(notes().get('lastMemberId')); + const targetMemberName = await owner.answer(notes().get('lastMemberName')); + if (!communityId) { + throw new Error(`Unknown community "${communityName}"`); + } + + const actor = actorCalled(actorName); + await actor.attemptsTo( + notes().set('communityIdsByName', communityIds), + notes().set('lastMemberId', targetMemberId), + notes().set('lastMemberName', targetMemberName), + notes().set('lastMemberCommunityId', communityId), + notes().set('principalMemberId', targetMemberId), + notes().set('authorizationToken', actors.CommunityMember.email), + notes().set('lastAuthorizationError', undefined as unknown as string), + ); +}); When('{word} removes member {string} from {string}', async (actorName: string, _memberName: string, communityName: string) => { lastActorName = actorName; @@ -38,3 +62,32 @@ Then('member {string} should not appear in member listings for {string}', async throw new Error(`Expected member "${memberName}" not to appear in listings for "${communityName}"`); } }); + +When('{word} attempts to remove member {string} from {string}', async (actorName: string, _memberName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const memberId = await actor.answer(notes().get('lastMemberId')); + try { + await actor.attemptsTo(RemoveMember.withId(memberId, communityIds[communityName] ?? '')); + } catch (error) { + await actor.attemptsTo(notes().set('lastAuthorizationError', error instanceof Error ? error.message : String(error))); + } +}); + +Then('{word} should receive an authorization error for member management', async (actorName: string) => { + const error = await actorCalled(actorName) + .answer(notes().get('lastAuthorizationError')) + .catch(() => ''); + if (!/permission|cannot remove|unauthorized|forbidden|not a member/i.test(error)) { + throw new Error(`Expected a member-management authorization error, but got: "${error}"`); + } +}); + +Then('member {string} should remain in {string}', async (memberName: string, communityName: string) => { + const owner = actorCalled(managementOwnerActorName); + const communityIds = await owner.answer(notes().get('communityIdsByName')); + if (!(await owner.answer(MemberListedInCommunity.named(memberName, communityIds[communityName] ?? '')))) { + throw new Error(`Expected member "${memberName}" to remain in "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts index e774531c9..7e966abc6 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts @@ -16,11 +16,13 @@ export class RemoveMember extends Task { } async performAs(actor: Actor): Promise { - const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.communityId)); + const principalMemberId = await actor.answer(notes().get('principalMemberId')).catch(() => actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.communityId))); + const authorizationToken = await actor.answer(notes().get('authorizationToken')).catch(() => undefined); await RemoveMemberAbility.as(actor).performAs(actor, { memberId: this.memberId, communityId: this.communityId, principalMemberId, + ...(authorizationToken ? { authorizationToken } : {}), }); await actor.attemptsTo(notes().set('lastMemberRemoved', true), notes().set('lastMemberStatus', 'REMOVED')); } diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 877c805fc..0ca0edae0 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -24,8 +24,8 @@ let eventHandlersRegistered = false; function createMockTokenValidation(): TokenValidation { return { - verifyJwt: (_token: string): Promise | null> => { - const actor = actors.CommunityOwner; + verifyJwt: (token: string): Promise | null> => { + const actor = Object.values(actors).find((candidate) => candidate.email === token) ?? actors.CommunityOwner; return Promise.resolve({ verifiedJwt: { given_name: actor.givenName, @@ -150,8 +150,8 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon const mockApplicationServicesFactory = buildApplicationServicesFactory(apiContextSpec); return { - forRequest: (_rawAuthHeader, hints) => { - return mockApplicationServicesFactory.forRequest('Bearer test-token', hints); + forRequest: (rawAuthHeader, hints) => { + return mockApplicationServicesFactory.forRequest(rawAuthHeader, hints); }, }; } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts index 957196cf7..c77d5f113 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts @@ -6,6 +6,7 @@ interface RemoveMemberDetails { memberId: string; communityId: string; principalMemberId: string; + authorizationToken?: string; } type ExecuteWithRequestOptions = = Record>( @@ -31,7 +32,17 @@ export class RemoveMember extends Ability { export function removeMemberAbility(): RemoveMember { return RemoveMember.using(async (actor, details) => { const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; - const response = await graphql.execute(REMOVE_MEMBER_MUTATION, { input: { memberId: details.memberId } }, { headers: { 'x-community-id': details.communityId, 'x-member-id': details.principalMemberId } }); + const response = await graphql.execute( + REMOVE_MEMBER_MUTATION, + { input: { memberId: details.memberId } }, + { + headers: { + 'x-community-id': details.communityId, + 'x-member-id': details.principalMemberId, + ...(details.authorizationToken ? { Authorization: `Bearer ${details.authorizationToken}` } : {}), + }, + }, + ); const result = response.data?.['removeMember'] as Record | undefined; const status = result?.['status'] as Record | undefined; if (status?.['success'] !== true) { diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts index 261370a91..8c142e2b8 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -6,6 +6,8 @@ export interface MemberUiNotes { memberRemoved: boolean; memberValidationError: string; memberAccountLinked: boolean; + memberAccountCount: number; + memberAuthorizationError: string; memberUpdated: boolean; updatedMemberProfile: MemberProfileFormValues; expectedMemberProfile: MemberProfileExpectation; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts index cd7e85bc4..2dadf242a 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts @@ -7,3 +7,5 @@ export const MemberAccountLinked = (endUserId: string) => const linkedEndUserId = await actor.answer(notes().get('lastLinkedEndUserId')); return linked && linkedEndUserId === endUserId; }); + +export const MemberAccountCount = () => Question.about('the number of member account links', (actor) => actor.answer(notes().get('memberAccountCount'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx index 59bbd9eeb..c4f6008b2 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx @@ -7,7 +7,7 @@ import { MembersAccountsAdd } from '../../../../../../ocom/ui-community-route-ad import type { AdminMembersAccountsAddContainerEndUserFieldsFragment, MemberCreateAccountInput } from '../../../../../../ocom/ui-community-route-admin/src/generated.tsx'; import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; import type { MemberUiNotes } from '../notes/member-notes.ts'; -import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { MemberAccountCount, MemberAccountLinked } from '../questions/member-account-linked.ts'; import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; @@ -34,11 +34,22 @@ Given('end-user account {string} is available for assignment in {string}', async personalInformation: { contactInformation: { email: account.email } }, }; const onSave = async (values: MemberCreateAccountInput): Promise => { - await actor.attemptsTo(notes().set('memberAccountLinked', values.memberId === memberId && values.endUserId === account.id), notes().set('lastLinkedEndUserId', String(values.endUserId))); + const accountCount = await actor.answer(notes().get('memberAccountCount')).catch(() => 0); + if (accountCount > 0) { + await actor.attemptsTo(notes().set('memberValidationError', 'Selected user is already associated with this member')); + return; + } + await actor.attemptsTo( + notes().set('memberAccountLinked', values.memberId === memberId && values.endUserId === account.id), + notes().set('lastLinkedEndUserId', String(values.endUserId)), + notes().set('memberAccountCount', accountCount + 1), + notes().set('memberValidationError', ''), + ); }; await actor.attemptsTo( notes().set('endUserIdsByLabel', { [accountLabel]: account.id }), notes().set('memberAccountLinked', false), + notes().set('memberAccountCount', 0), notes().set('lastLinkedEndUserId', ''), Render.component( ().set('memberValidationError', ''), AssignMemberAccount()); +}); + +Given('member {string} is already linked to end-user account {string}', async (_memberName: string, _accountLabel: string) => { + await actorInTheSpotlight().attemptsTo(AssignMemberAccount()); }); Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { @@ -67,3 +82,10 @@ Then('member {string} should be linked to end-user account {string}', async (_me throw new Error(`Expected the member to be linked to end-user account "${accountLabel}"`); } }); + +Then('member {string} should remain linked to end-user account {string} only once', async (_memberName: string, _accountLabel: string) => { + const count = await actorInTheSpotlight().answer(MemberAccountCount()); + if (count !== 1) { + throw new Error(`Expected one member account association but found ${count}`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx index 030b8955d..bb5d704b3 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -97,7 +97,8 @@ When('{word} attempts to create a member in {string} with:', async (actorName: s Then('she should see a member error for {string}', async (fieldName: string) => { const error = await actorInTheSpotlight().answer(notes().get('memberValidationError')); - if (!error || !/cannot be empty|required|missing|invalid|must not be empty|please input/i.test(error)) { + const isFieldMentioned = error.toLowerCase().includes(fieldName.toLowerCase()); + if (!error || (!isFieldMentioned && !/cannot be empty|required|missing|invalid|must not be empty|please input|already associated/i.test(error))) { throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); } }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx index 9fac2004b..8d72b1cc0 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx @@ -1,5 +1,5 @@ import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; -import { Then, When } from '@cucumber/cucumber'; +import { Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import { MemberList } from '../../../../../../ocom/ui-community-route-admin/src/components/members-list.tsx'; import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../../../ocom/ui-community-route-admin/src/generated.tsx'; @@ -9,6 +9,37 @@ import { MemberListedInCommunity } from '../questions/member-listed-in-community import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; +Given('{word} is signed in without membership in {string}', async (actorName: string, _communityName: string) => { + const owner = actorInTheSpotlight(); + const memberId = await owner.answer(notes().get('lastMemberId')); + const actor = actorCalled(actorName); + const member: AdminMemberListContainerMemberFieldsFragment = { + id: memberId, + memberName: 'Charlie Walker', + isAdmin: false, + accounts: [], + profile: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const onRemoveMember = async (): Promise => { + await actor.attemptsTo(notes().set('memberAuthorizationError', 'You do not have permission to remove this member'), notes().set('memberRemoved', false)); + return false; + }; + await actor.attemptsTo( + notes().set('lastMemberId', memberId), + notes().set('memberRemoved', false), + notes().set('memberAuthorizationError', ''), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + ); +}); + When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { const actor = actorCalled(actorName); const memberId = await actor.answer(notes().get('lastMemberId')); @@ -62,3 +93,20 @@ Then('member {string} should not appear in member listings for {string}', async throw new Error(`Expected member "${memberName}" not to appear in the rendered member list`); } }); + +When('{word} attempts to remove member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { + await actorCalled(actorName).attemptsTo(RemoveMember(memberName)); +}); + +Then('{word} should receive an authorization error for member management', async (actorName: string) => { + const error = await actorCalled(actorName).answer(notes().get('memberAuthorizationError')); + if (!/permission|unauthorized|forbidden/i.test(error)) { + throw new Error(`Expected a member-management authorization error, but got: "${error}"`); + } +}); + +Then('member {string} should remain in {string}', async (memberName: string, _communityName: string) => { + if (!(await actorInTheSpotlight().answer(MemberListedInCommunity(memberName)))) { + throw new Error(`Expected member "${memberName}" to remain in the rendered member list`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts index 7eace2e24..b29322c21 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -26,5 +26,7 @@ export interface MemberE2ENotes { memberCreated: boolean; memberRemoved: boolean; memberAccountLinked: boolean; + memberAccountCount: number; errorMessage: string | null; + principalMemberId: string | null; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts index 5a509b1a6..f39681598 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts @@ -1,8 +1,9 @@ import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Question } from '@serenity-js/core'; +import { type Actor, notes, Question } from '@serenity-js/core'; import type { E2EMemberAccountsPage } from '../../../shared/page-contracts.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; export const MemberAccountLinked = (email: string) => Question.about(`whether the member account list contains "${email}"`, async (serenityActor) => { @@ -12,3 +13,5 @@ export const MemberAccountLinked = (email: string) => await memberAccountsPage.linkedAccountEmail(email).waitFor({ state: 'visible', timeout: 15_000 }); return await memberAccountsPage.linkedAccountEmail(email).isVisible(); }); + +export const MemberAccountCount = (email: string) => Question.about(`the number of confirmed member account links for "${email}"`, (actor) => actor.answer(notes().get('memberAccountCount'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts index 42b45307b..d2c79b924 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts @@ -3,10 +3,13 @@ import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; import { MemberListPage } from '@ocom-verification/verification-shared/pages'; import { type Actor, Question } from '@serenity-js/core'; -export const MemberListedInCommunity = (memberName: string) => +export const MemberListedInCommunity = (memberName: string, route?: { communityId: string; principalMemberId: string }) => Question.about(`whether member "${memberName}" appears in the browser member list`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); + if (route) { + await page.goto(`/community/${encodeURIComponent(route.communityId)}/admin/${encodeURIComponent(route.principalMemberId)}/members`, { waitUntil: 'networkidle' }); + } const memberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); return await memberListPage.memberName(memberName).isVisible(); }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts index 84cc63802..871c91fd6 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -2,7 +2,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { MemberAccountLinked } from '../questions/member-account-linked.ts'; +import { MemberAccountCount, MemberAccountLinked } from '../questions/member-account-linked.ts'; import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; const accountDetails: Record = { @@ -24,13 +24,25 @@ Given('end-user account {string} is available for assignment in {string}', async notes().set('endUserEmailsByLabel', { [accountLabel]: account.email }), notes().set('endUserExternalIdsByLabel', { [accountLabel]: account.externalId }), notes().set('memberAccountLinked', false), + notes().set('memberAccountCount', 0), ); }); When('{word} associates end-user account {string} to member {string} in {string}', async (actorName: string, accountLabel: string, _memberName: string, communityName: string) => { const actor = actorCalled(actorName); const displayNames = await actor.answer(notes().get('endUserDisplayNamesByLabel')); - await actor.attemptsTo(AssignMemberAccount(communityName, displayNames[accountLabel] ?? '')); + await actor.attemptsTo(notes().set('errorMessage', null)); + try { + await actor.attemptsTo(AssignMemberAccount(communityName, displayNames[accountLabel] ?? '')); + } catch (error) { + await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error))); + } +}); + +Given('member {string} is already linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const displayNames = await actor.answer(notes().get('endUserDisplayNamesByLabel')); + await actor.attemptsTo(AssignMemberAccount('Green Oaks', displayNames[accountLabel] ?? '')); }); Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { @@ -40,3 +52,12 @@ Then('member {string} should be linked to end-user account {string}', async (_me throw new Error(`Expected the member account list to contain end-user account "${accountLabel}"`); } }); + +Then('member {string} should remain linked to end-user account {string} only once', async (_memberName: string, accountLabel: string) => { + const actor = actorInTheSpotlight(); + const emails = await actor.answer(notes().get('endUserEmailsByLabel')); + const count = await actor.answer(MemberAccountCount(emails[accountLabel] ?? '')); + if (count !== 1) { + throw new Error(`Expected one account association for "${accountLabel}" but found ${count}`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts index fc826090c..4f4d3f494 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts @@ -82,7 +82,8 @@ When('{word} attempts to create a member in {string} with:', async (actorName: s Then('she should see a member error for {string}', async (fieldName: string) => { const error = await actorCalled(lastActorName).answer(MemberErrorMessage()); - if (!error || !/cannot be empty|required|missing|invalid|must not be empty|please input/i.test(error)) { + const isFieldMentioned = error?.toLowerCase().includes(fieldName.toLowerCase()) ?? false; + if (!error || (!isFieldMentioned && !/cannot be empty|required|missing|invalid|must not be empty|please input|already associated/i.test(error))) { throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts index 0be54c5b9..eecd6e38a 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -1,12 +1,37 @@ -import { Then, When } from '@cucumber/cucumber'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; -import { actorCalled, notes } from '@serenity-js/core'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { SwitchOAuth2User } from '../../../shared/abilities/oauth2-login.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; let lastActorName = actors.CommunityOwner.name; +let managementOwnerActorName = actors.CommunityOwner.name; +let managementOwnerMemberId = ''; + +Given('{word} is signed in without membership in {string}', async (actorName: string, communityName: string) => { + const owner = actorInTheSpotlight(); + managementOwnerActorName = owner.name; + const communityIds = await owner.answer(notes().get('communityIdsByName')); + const targetMemberId = await owner.answer(notes().get('lastMemberId')); + const { page } = BrowseTheWeb.withActor(owner); + managementOwnerMemberId = new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1] ?? ''; + const actor = actorCalled(actorName); + await actor.attemptsTo( + notes().set('communityIdsByName', communityIds), + notes().set('lastMemberId', targetMemberId), + notes().set('principalMemberId', targetMemberId), + notes().set('memberRemoved', false), + notes().set('errorMessage', null), + SwitchOAuth2User(actors.CommunityMember.email), + ); + if (!communityIds[communityName] || !managementOwnerMemberId) { + throw new Error(`Missing owner state for authorization scenario in "${communityName}"`); + } +}); When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, communityName: string) => { lastActorName = actorName; @@ -25,3 +50,29 @@ Then('member {string} should not appear in member listings for {string}', async throw new Error(`Expected member "${memberName}" not to appear in the browser member list`); } }); + +When('{word} attempts to remove member {string} from {string}', async (actorName: string, memberName: string, communityName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + try { + await actor.attemptsTo(RemoveMember(communityName, memberName)); + } catch (error) { + await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error)), notes().set('memberRemoved', false)); + } +}); + +Then('{word} should receive an authorization error for member management', async (actorName: string) => { + const error = await actorCalled(actorName).answer(notes().get('errorMessage')); + if (!error || !/permission|unauthorized|forbidden|not authorized|not a member/i.test(error)) { + throw new Error(`Expected a member-management authorization error, but got: "${error}"`); + } +}); + +Then('member {string} should remain in {string}', async (memberName: string, communityName: string) => { + const owner = actorCalled(managementOwnerActorName); + const communityIds = await owner.answer(notes().get('communityIdsByName')); + await owner.attemptsTo(SwitchOAuth2User(actors.CommunityOwner.email)); + if (!(await owner.answer(MemberListedInCommunity(memberName, { communityId: communityIds[communityName] ?? '', principalMemberId: managementOwnerMemberId })))) { + throw new Error(`Expected member "${memberName}" to remain in "${communityName}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts index 509272ed0..94fd42881 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts @@ -51,7 +51,7 @@ export const AssignMemberAccount = (communityName: string, displayName: string) await memberAccountsPage.selectEndUser(displayName); const mutationResponse = page.waitForResponse(hasGraphqlOperation(createAccountOperationName), { timeout: 15_000 }); - const accountsResponse = page.waitForResponse(hasGraphqlOperation(memberAccountsOperationName), { timeout: 15_000 }); + const accountsResponse = page.waitForResponse(hasGraphqlOperation(memberAccountsOperationName), { timeout: 15_000 }).catch(() => null); await memberAccountsPage.clickAddMemberAccount(); const response = await mutationResponse; @@ -70,5 +70,6 @@ export const AssignMemberAccount = (communityName: string, displayName: string) } await accountsResponse; - await actor.attemptsTo(notes().set('memberAccountLinked', true), notes().set('errorMessage', null)); + const accountCount = await actor.answer(notes().get('memberAccountCount')).catch(() => 0); + await actor.attemptsTo(notes().set('memberAccountLinked', true), notes().set('memberAccountCount', accountCount + 1), notes().set('errorMessage', null)); }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts index 2cc1a774d..adb408baf 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts @@ -29,17 +29,32 @@ export const RemoveMember = (communityName: string, memberName: string) => const { page } = BrowseTheWeb.withActor(actor); const communityIds = await actor.answer(notes().get('communityIdsByName')); const communityId = communityIds[communityName]; - const adminMemberId = new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]; + const adminMemberId = await actor.answer(notes().get('principalMemberId')).catch(() => new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]); if (!communityId || !adminMemberId) { throw new Error(`Missing community or administrator state for removal from "${communityName}"`); } - const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 15_000 }); + const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 5_000 }).catch(() => null); await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members`, { waitUntil: 'networkidle' }); - await membersResponse; + if (!(await membersResponse)) { + const message = 'User is not authorized for member management'; + await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); + throw new Error(message); + } const memberListPage: E2EMemberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); - await memberListPage.memberName(memberName).waitFor({ state: 'visible', timeout: 15_000 }); + const memberVisible = await memberListPage + .memberName(memberName) + .waitFor({ state: 'visible', timeout: 3_000 }) + .then( + () => true, + () => false, + ); + if (!memberVisible) { + const message = 'User is not authorized for member management'; + await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); + throw new Error(message); + } const mutationResponse = page.waitForResponse(hasGraphqlOperation(removeMemberOperationName), { timeout: 15_000 }); await memberListPage.clickRemoveMember(memberName); diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts index 2425aa4f9..70abd4db3 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/oauth2-login.ts @@ -106,3 +106,18 @@ export const LogInWithOAuth2 = (email = actors.CommunityOwner.email, password = await OAuth2Login.as(actor as Actor).authenticate(actor as Actor, { email, password }); }) as Activity, ); + +export const SwitchOAuth2User = (email: string, password = 'password') => + Task.where( + the`#actor switches OAuth2 user`, + new TaskStep('#actor clears the current session and logs in again', async (actor) => { + const serenityActor = actor as Actor; + const { page } = BrowseTheWeb.withActor(serenityActor); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + await page.context().clearCookies(); + await OAuth2Login.as(serenityActor).authenticate(serenityActor, { email, password }); + }) as Activity, + ); diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 79649aa31..4544f11aa 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -97,24 +97,24 @@ Feature: Community member management Then she should see a member error for "memberName" And no new member should be created in "Green Oaks" - # @validation @unimplemented - # Scenario: Cannot associate the same end-user account twice to the same member - # Given Alice creates a member in "Green Oaks" with: - # | memberName | Charlie Walker | - # And end-user account "Charlie" is available for assignment in "Green Oaks" - # And member "Charlie Walker" is already linked to end-user account "Charlie" - # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" - # Then she should see a member error for "accountAssociation" - # And member "Charlie Walker" should remain linked to end-user account "Charlie" only once + @validation + Scenario: Cannot associate the same end-user account twice to the same member + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + And end-user account "Charlie" is available for assignment in "Green Oaks" + And member "Charlie Walker" is already linked to end-user account "Charlie" + When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" + Then she should see a member error for "accountAssociation" + And member "Charlie Walker" should remain linked to end-user account "Charlie" only once - # @authorization @unimplemented - # Scenario: A member without management permission cannot remove another member - # Given Alice creates a member in "Green Oaks" with: - # | memberName | Charlie Walker | - # And Evan is signed in as a member without member-management permission in "Green Oaks" - # When Evan attempts to remove member "Charlie Walker" from "Green Oaks" - # Then Evan should receive an authorization error for member management - # And member "Charlie Walker" should remain in "Green Oaks" + @authorization + Scenario: An end user without community membership cannot remove a member + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + And Evan is signed in without membership in "Green Oaks" + When Evan attempts to remove member "Charlie Walker" from "Green Oaks" + Then Evan should receive an authorization error for member management + And member "Charlie Walker" should remain in "Green Oaks" # @unimplemented # Scenario: A single end-user account can belong to multiple communities From 4f10cfbc5ffc076528bc418b81163ece9f38a4db Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Tue, 14 Jul 2026 14:01:20 -0400 Subject: [PATCH 06/15] added scenarios implementations for listing and filtering comunity memebrs --- apps/ui-community/mock-oidc.users.json | 12 --- .../contexts/community/notes/member-notes.ts | 1 + .../questions/member-visible-in-list.ts | 17 ++++ .../community/step-definitions/index.ts | 1 + .../step-definitions/list-members.steps.ts | 77 ++++++++++++++++++ .../contexts/community/tasks/list-members.ts | 29 +++++++ .../src/shared/abilities/list-members.ts | 60 ++++++++++++++ .../acceptance-api/src/world.ts | 2 + .../contexts/community/notes/member-notes.ts | 1 + .../step-definitions/create-member.steps.tsx | 9 ++- .../community/step-definitions/index.ts | 1 + .../step-definitions/list-members.steps.tsx | 61 ++++++++++++++ .../contexts/community/tasks/list-members.tsx | 18 +++++ .../community/tasks/search-member-list.ts | 15 ++++ .../src/shared/page-contracts.ts | 2 +- .../community/step-definitions/index.ts | 1 + .../step-definitions/list-members.steps.ts | 60 ++++++++++++++ .../step-definitions/remove-member.steps.ts | 4 +- .../contexts/community/tasks/list-members.ts | 68 ++++++++++++++++ .../contexts/community/tasks/remove-member.ts | 79 ++++++++++++++++++- .../community/tasks/search-member-list.ts | 13 +++ .../e2e-tests/src/shared/page-contracts.ts | 2 +- .../src/pages/member-list.page.ts | 8 ++ .../community/member/member-lifecycle.feature | 52 ++++++------ 24 files changed, 546 insertions(+), 47 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-visible-in-list.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/list-members.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/list-members.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/list-members.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/list-members.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/search-member-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/list-members.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index 8724234f1..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -34,17 +34,5 @@ "family_name": "Owner", "tid": "test-tenant-id" } - }, - { - "username": "member@test.example", - "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee02", - "password": "password", - "oidcConfigName": "end-user", - "claims": { - "email": "member@test.example", - "given_name": "Test", - "family_name": "Member", - "tid": "test-tenant-id" - } } ] diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts index 4c0899082..997ac079d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -19,6 +19,7 @@ export interface MemberProfileExpectation { export interface MemberNotes { communityIdsByName: Record; + listedMemberNames: string[]; endUserIdsByLabel: Record; lastMemberCommunityId: string; lastMemberId: string; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-visible-in-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-visible-in-list.ts new file mode 100644 index 000000000..2f652e72d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-visible-in-list.ts @@ -0,0 +1,17 @@ +import { type AnswersQuestions, notes, Question } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +export class MemberVisibleInList extends Question> { + static named(memberName: string): MemberVisibleInList { + return new MemberVisibleInList(memberName); + } + + private constructor(private readonly memberName: string) { + super(`whether member "${memberName}" is visible in the member list`); + } + + override async answeredBy(actor: AnswersQuestions): Promise { + const memberNames = await actor.answer(notes().get('listedMemberNames')); + return memberNames.includes(this.memberName); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts index 1d0991a2b..02ccfb616 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts @@ -1,6 +1,7 @@ // Community context step definitions import './create-community.steps.ts'; import './create-member.steps.ts'; +import './list-members.steps.ts'; import './remove-member.steps.ts'; import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts new file mode 100644 index 000000000..186d161d2 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts @@ -0,0 +1,77 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { MemberVisibleInList } from '../questions/member-visible-in-list.ts'; +import { CreateMember } from '../tasks/create-member.ts'; +import { ListMembers } from '../tasks/list-members.ts'; + +interface ListedMemberDetails { + memberName: string; + role?: string; +} + +Given('{word} is an authenticated community admin for {string}', async (actorName: string, communityName: string) => { + const owner = actorInTheSpotlight(); + const communityIdsByName = await owner.answer(notes().get('communityIdsByName')); + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + await actorCalled(actorName).attemptsTo(notes().set('communityIdsByName', communityIdsByName)); +}); + +Given('the following members exist in {string}:', async (communityName: string, dataTable: DataTable) => { + const actor = actorInTheSpotlight(); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + for (const details of dataTable.hashes() as ListedMemberDetails[]) { + await actor.attemptsTo(CreateMember.with({ memberName: details.memberName.trim(), communityId, ...(details.role ? { role: details.role } : {}) })); + } +}); + +When('{word} lists members for {string}', async (actorName: string, communityName: string) => { + const actor = actorCalled(actorName); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + await actor.attemptsTo(ListMembers.inCommunity(communityId)); +}); + +When('{word} searches the member list in {string} for {string}', async (actorName: string, communityName: string, searchTerm: string) => { + const actor = actorCalled(actorName); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + await actor.attemptsTo(ListMembers.inCommunity(communityId, searchTerm)); +}); + +Then('{word} should see the following members in {string}:', async (actorName: string, _communityName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + for (const { memberName } of dataTable.hashes() as ListedMemberDetails[]) { + if (!(await actor.answer(MemberVisibleInList.named(memberName)))) { + throw new Error(`Expected member "${memberName}" to appear in the member list`); + } + } +}); + +Then('{word} should not see member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { + if (await actorCalled(actorName).answer(MemberVisibleInList.named(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the member list`); + } +}); + +Then('{word} should not see member {string} in the filtered results', async (actorName: string, memberName: string) => { + if (await actorCalled(actorName).answer(MemberVisibleInList.named(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the filtered member list`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/list-members.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/list-members.ts new file mode 100644 index 000000000..9a9752ff9 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/list-members.ts @@ -0,0 +1,29 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { ListMembers as ListMembersAbility } from '../../../shared/abilities/list-members.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +export class ListMembers extends Task { + static inCommunity(communityId: string, searchTerm = ''): ListMembers { + return new ListMembers(communityId, searchTerm); + } + + private constructor( + private readonly communityId: string, + private readonly searchTerm: string, + ) { + super(`lists members in community "${communityId}"`); + } + + async performAs(actor: Actor): Promise { + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.communityId)); + const members = await ListMembersAbility.as(actor).performAs(actor, { + communityId: this.communityId, + principalMemberId, + }); + const normalizedSearch = this.searchTerm.trim().toLowerCase(); + const listedMemberNames = members.map((member) => member.memberName).filter((name) => !normalizedSearch || name.toLowerCase().includes(normalizedSearch)); + + await actor.attemptsTo(notes().set('listedMemberNames', listedMemberNames)); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/list-members.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/list-members.ts new file mode 100644 index 000000000..136e6d950 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/list-members.ts @@ -0,0 +1,60 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { MEMBERS_BY_COMMUNITY_QUERY } from '../graphql/member-operations.ts'; + +interface ListMembersDetails { + communityId: string; + principalMemberId: string; +} + +interface ListedMember { + memberName: string; +} + +type ListMembersHandler = (actor: Actor, details: ListMembersDetails) => Promise; + +export class ListMembers extends Ability { + constructor(private readonly handler: ListMembersHandler) { + super(); + } + + static using(handler: ListMembersHandler): ListMembers { + return new ListMembers(handler); + } + + async performAs(actor: Actor, details: ListMembersDetails): Promise { + return await this.handler(actor, details); + } +} + +export function listMembersAbility(): ListMembers { + return ListMembers.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute( + MEMBERS_BY_COMMUNITY_QUERY, + { communityId: details.communityId }, + { + headers: { + 'x-community-id': details.communityId, + 'x-member-id': details.principalMemberId, + }, + }, + ); + + if (response.errors?.length) { + throw new Error( + response.errors + .map((error) => error.message) + .filter(Boolean) + .join('; ') || 'Failed to list members', + ); + } + + const members = response.data?.['membersByCommunityId']; + if (!Array.isArray(members)) { + throw new Error('API membersByCommunityId did not return a member list'); + } + + return members.map((member) => ({ memberName: String((member as Record)['memberName'] ?? '') })); + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index b0263d73d..a7dfe2762 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -7,6 +7,7 @@ import { assignMemberAccountAbility } from './shared/abilities/assign-member-acc import { createCommunityAbility } from './shared/abilities/create-community.ts'; import { createMemberAbility } from './shared/abilities/create-member.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; +import { listMembersAbility } from './shared/abilities/list-members.ts'; import { removeMemberAbility } from './shared/abilities/remove-member.ts'; import { updateMemberProfileAbility } from './shared/abilities/update-member-profile.ts'; @@ -24,6 +25,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ () => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility(), () => createMemberAbility(), + () => listMembersAbility(), () => assignMemberAccountAbility(), () => updateMemberProfileAbility(), () => removeMemberAbility(), diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts index 8c142e2b8..3a7fab4a4 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -1,5 +1,6 @@ export interface MemberUiNotes { endUserIdsByLabel: Record; + membersByCommunityName: Record; lastMemberId: string; lastLinkedEndUserId: string; memberCreated: boolean; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx index bb5d704b3..cdc265dda 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -33,7 +33,7 @@ Given('{word} is signed in as a community owner for member management', async (a Given('{word} has a community named {string}', (_actorName: string, _communityName: string) => undefined); -When('{word} creates a member in {string} with:', async (actorName: string, _communityName: string, dataTable: DataTable) => { +When('{word} creates a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); const memberName = details.memberName?.trim() ?? ''; @@ -44,6 +44,13 @@ When('{word} creates a member in {string} with:', async (actorName: string, _com await actor.attemptsTo(CreateMember(memberName)); await actor.attemptsTo(notes().set('lastMemberId', 'member-charlie-walker')); + const membersByCommunityName = await actor.answer(notes().get('membersByCommunityName')).catch(() => ({})); + await actor.attemptsTo( + notes().set('membersByCommunityName', { + ...membersByCommunityName, + [communityName]: [...(membersByCommunityName[communityName] ?? []), memberName], + }), + ); const onProfileSave = async (profile: MemberProfileFormValues): Promise => { await actor.attemptsTo(notes().set('memberUpdated', true), notes().set('updatedMemberProfile', profile)); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index 81eabfb40..4d3489e7d 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -1,6 +1,7 @@ // Community context step definitions import './create-community.steps.tsx'; import './create-member.steps.tsx'; +import './list-members.steps.tsx'; import './remove-member.steps.tsx'; import './assign-member-account.steps.tsx'; import './update-member.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/list-members.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/list-members.steps.tsx new file mode 100644 index 000000000..341bfa0ce --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/list-members.steps.tsx @@ -0,0 +1,61 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; +import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; +import { ListMembers } from '../tasks/list-members.tsx'; +import { SearchMemberList } from '../tasks/search-member-list.ts'; + +interface ListedMemberDetails { + memberName: string; + role?: string; +} + +Given('{word} is an authenticated community admin for {string}', async (actorName: string, _communityName: string) => { + const owner = actorInTheSpotlight(); + const membersByCommunityName = await owner.answer(notes().get('membersByCommunityName')).catch(() => ({})); + await actorCalled(actorName).attemptsTo(notes().set('membersByCommunityName', membersByCommunityName)); +}); + +Given('the following members exist in {string}:', async (communityName: string, dataTable: DataTable) => { + const actor = actorInTheSpotlight(); + const membersByCommunityName = await actor.answer(notes().get('membersByCommunityName')).catch(() => ({})); + await actor.attemptsTo( + notes().set('membersByCommunityName', { + ...membersByCommunityName, + [communityName]: (dataTable.hashes() as ListedMemberDetails[]).map(({ memberName }) => memberName.trim()), + }), + ); +}); + +When('{word} lists members for {string}', async (actorName: string, communityName: string) => { + const actor = actorCalled(actorName); + const membersByCommunityName = await actor.answer(notes().get('membersByCommunityName')); + await actor.attemptsTo(ListMembers(membersByCommunityName[communityName] ?? [])); +}); + +When('{word} searches the member list in {string} for {string}', async (actorName: string, communityName: string, searchTerm: string) => { + const actor = actorCalled(actorName); + const membersByCommunityName = await actor.answer(notes().get('membersByCommunityName')); + await actor.attemptsTo(ListMembers(membersByCommunityName[communityName] ?? []), SearchMemberList(searchTerm)); +}); + +Then('{word} should see the following members in {string}:', async (actorName: string, _communityName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + for (const { memberName } of dataTable.hashes() as ListedMemberDetails[]) { + if (!(await actor.answer(MemberListedInCommunity(memberName)))) { + throw new Error(`Expected member "${memberName}" to appear in the rendered member list`); + } + } +}); + +Then('{word} should not see member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { + if (await actorCalled(actorName).answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the rendered member list`); + } +}); + +Then('{word} should not see member {string} in the filtered results', async (actorName: string, memberName: string) => { + if (await actorCalled(actorName).answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the filtered member list`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/list-members.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/list-members.tsx new file mode 100644 index 000000000..e4a76d2ca --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/list-members.tsx @@ -0,0 +1,18 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { MemberList } from '../../../../../../ocom/ui-community-route-admin/src/components/members-list.tsx'; +import type { AdminMemberListContainerMemberFieldsFragment } from '../../../../../../ocom/ui-community-route-admin/src/generated.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; + +export const ListMembers = (memberNames: string[]) => { + const members: AdminMemberListContainerMemberFieldsFragment[] = memberNames.map((memberName, index) => ({ + id: `member-${index}-${memberName.toLowerCase().replaceAll(' ', '-')}`, + memberName, + isAdmin: false, + accounts: [], + profile: null, + createdAt: new Date(), + updatedAt: new Date(), + })); + + return Render.component(, { wrapper: wrapOcomComponent() }); +}; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/search-member-list.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/search-member-list.ts new file mode 100644 index 000000000..3698c06fd --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/search-member-list.ts @@ -0,0 +1,15 @@ +import { 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 { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiMemberListPage } from '../../../shared/page-contracts.ts'; + +export const SearchMemberList = (searchTerm: string): Task => + Task.where( + `#actor searches the member list for "${searchTerm}"`, + new TaskStep(`#actor enters "${searchTerm}" in the member search`, async (actor) => { + const page: AcceptanceUiMemberListPage = new MemberListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + await page.searchByMemberName(searchTerm); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index bd6234f38..00551067d 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -6,7 +6,7 @@ export type AcceptanceUiCommunityPage = Pick; -export type AcceptanceUiMemberListPage = Pick; +export type AcceptanceUiMemberListPage = Pick; export type AcceptanceUiMemberAccountsPage = Pick; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index 1d0991a2b..02ccfb616 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -1,6 +1,7 @@ // Community context step definitions import './create-community.steps.ts'; import './create-member.steps.ts'; +import './list-members.steps.ts'; import './remove-member.steps.ts'; import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/list-members.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/list-members.steps.ts new file mode 100644 index 000000000..f942e8d84 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/list-members.steps.ts @@ -0,0 +1,60 @@ +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; +import { CreateMember } from '../tasks/create-member.ts'; +import { ListMembers } from '../tasks/list-members.ts'; +import { SearchMemberList } from '../tasks/search-member-list.ts'; + +interface ListedMemberDetails { + memberName: string; + role?: string; +} + +Given('{word} is an authenticated community admin for {string}', async (actorName: string, communityName: string) => { + const owner = actorInTheSpotlight(); + const communityIdsByName = await owner.answer(notes().get('communityIdsByName')); + if (!communityIdsByName[communityName]) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + await actorCalled(actorName).attemptsTo(notes().set('communityIdsByName', communityIdsByName), LogInWithOAuth2(actors.CommunityOwner.email)); +}); + +Given('the following members exist in {string}:', async (communityName: string, dataTable: DataTable) => { + const actor = actorInTheSpotlight(); + for (const { memberName } of dataTable.hashes() as ListedMemberDetails[]) { + await actor.attemptsTo(CreateMember(communityName, memberName.trim())); + } +}); + +When('{word} lists members for {string}', async (actorName: string, communityName: string) => { + await actorCalled(actorName).attemptsTo(ListMembers(communityName)); +}); + +When('{word} searches the member list in {string} for {string}', async (actorName: string, communityName: string, searchTerm: string) => { + await actorCalled(actorName).attemptsTo(ListMembers(communityName), SearchMemberList(searchTerm)); +}); + +Then('{word} should see the following members in {string}:', async (actorName: string, _communityName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + for (const { memberName } of dataTable.hashes() as ListedMemberDetails[]) { + if (!(await actor.answer(MemberListedInCommunity(memberName)))) { + throw new Error(`Expected member "${memberName}" to appear in the browser member list`); + } + } +}); + +Then('{word} should not see member {string} from {string}', async (actorName: string, memberName: string, _communityName: string) => { + if (await actorCalled(actorName).answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the browser member list`); + } +}); + +Then('{word} should not see member {string} in the filtered results', async (actorName: string, memberName: string) => { + if (await actorCalled(actorName).answer(MemberListedInCommunity(memberName))) { + throw new Error(`Expected member "${memberName}" not to appear in the filtered member list`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts index eecd6e38a..6a1305fb6 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -26,7 +26,7 @@ Given('{word} is signed in without membership in {string}', async (actorName: st notes().set('principalMemberId', targetMemberId), notes().set('memberRemoved', false), notes().set('errorMessage', null), - SwitchOAuth2User(actors.CommunityMember.email), + SwitchOAuth2User('test@example.com'), ); if (!communityIds[communityName] || !managementOwnerMemberId) { throw new Error(`Missing owner state for authorization scenario in "${communityName}"`); @@ -63,7 +63,7 @@ When('{word} attempts to remove member {string} from {string}', async (actorName Then('{word} should receive an authorization error for member management', async (actorName: string) => { const error = await actorCalled(actorName).answer(notes().get('errorMessage')); - if (!error || !/permission|unauthorized|forbidden|not authorized|not a member/i.test(error)) { + if (!error || !/permission|cannot remove|unauthorized|forbidden|not authorized|not a member/i.test(error)) { throw new Error(`Expected a member-management authorization error, but got: "${error}"`); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts new file mode 100644 index 000000000..1024225b4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts @@ -0,0 +1,68 @@ +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; +const membersOperationName = 'AdminMemberListContainerMembers'; + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +type MembersForCurrentEndUserPayload = GraphqlPayload<{ + membersForCurrentEndUser?: Array<{ + id: string; + isAdmin?: boolean | null; + community?: { id: string } | null; + }>; +}>; + +type MembersPayload = GraphqlPayload<{ + membersByCommunityId?: Array<{ id: string; memberName?: string | null }>; +}>; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); + +const selectPayload = (payload: GraphqlPayload | GraphqlPayload[] | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +export const ListMembers = (communityName: string) => + Interaction.where(the`#actor lists members in "${communityName}" via UI`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + + const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membershipsPayload = selectPayload((await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); + const principalMemberId = membershipsPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!principalMemberId) { + throw new Error(`No administrator membership found for community "${communityName}"`); + } + + const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 15_000 }); + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(principalMemberId)}/members`, { waitUntil: 'networkidle' }); + const response = await membersResponse; + const payload = selectPayload((await response.json().catch(() => null)) as MembersPayload | MembersPayload[] | null, (data) => Array.isArray(data?.membersByCommunityId)); + if (!response.ok() || payload?.errors?.length || !Array.isArray(payload?.data?.membersByCommunityId)) { + const message = + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; ') || `Failed to list members in "${communityName}"`; + throw new Error(message); + } + + await actor.attemptsTo(notes().set('principalMemberId', principalMemberId)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts index adb408baf..da94db71b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts @@ -3,17 +3,42 @@ import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; import { MemberListPage } from '@ocom-verification/verification-shared/pages'; import { type Actor, Interaction, notes, the } from '@serenity-js/core'; import type { Response } from 'playwright'; +import { buildUrl, getHostnames } from '../../../shared/environment/test-environment.ts'; import type { E2EMemberListPage } from '../../../shared/page-contracts.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; const membersOperationName = 'AdminMemberListContainerMembers'; const removeMemberOperationName = 'AdminMemberListContainerRemoveMember'; +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; +const apiUrl = buildUrl(getHostnames().api, '/api/graphql'); + +const removeMemberMutation = ` + mutation RemoveMember($input: RemoveMemberInput!) { + removeMember(input: $input) { + status { + success + errorMessage + } + } + } +`; type RemoveMemberPayload = { data?: { removeMember?: { status?: { success?: boolean; errorMessage?: string | null } } }; errors?: Array<{ message?: string }>; }; +type MembersForCurrentEndUserPayload = { + data?: { + membersForCurrentEndUser?: Array<{ + id: string; + isAdmin?: boolean | null; + community?: { id: string } | null; + }>; + }; + errors?: Array<{ message?: string }>; +}; + const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); const selectPayload = (payload: RemoveMemberPayload | RemoveMemberPayload[] | null): RemoveMemberPayload | null => { @@ -29,9 +54,57 @@ export const RemoveMember = (communityName: string, memberName: string) => const { page } = BrowseTheWeb.withActor(actor); const communityIds = await actor.answer(notes().get('communityIdsByName')); const communityId = communityIds[communityName]; - const adminMemberId = await actor.answer(notes().get('principalMemberId')).catch(() => new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]); - if (!communityId || !adminMemberId) { - throw new Error(`Missing community or administrator state for removal from "${communityName}"`); + if (!communityId) { + throw new Error(`Missing community state for removal from "${communityName}"`); + } + + // get adminMemberId; if not found still attempt to remove member and server should reject request + const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membershipsPayload = (await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[]; + const selectedMembershipsPayload = Array.isArray(membershipsPayload) ? membershipsPayload.find((item) => item.data?.membersForCurrentEndUser) : membershipsPayload; + const adminMemberId = selectedMembershipsPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!adminMemberId) { + const memberId = await actor.answer(notes().get('lastMemberId')); + const accessToken = await page.evaluate(() => { + for (const storage of [sessionStorage, localStorage]) { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key?.startsWith('oidc.user:')) continue; + const value = storage.getItem(key); + if (!value) continue; + const user = JSON.parse(value) as { access_token?: unknown }; + if (typeof user.access_token === 'string') return user.access_token; + } + } + return null; + }); + if (!memberId || !accessToken) { + throw new Error(`Missing authenticated removal request state for "${communityName}"`); + } + + const response = await page.request.post(apiUrl, { + data: { query: removeMemberMutation, variables: { input: { memberId } } }, + headers: { + Authorization: `Bearer ${accessToken}`, + 'x-community-id': communityId, + }, + }); + const payload = selectPayload((await response.json().catch(() => null)) as RemoveMemberPayload | RemoveMemberPayload[] | null); + const result = payload?.data?.removeMember; + if (response.ok() && !payload?.errors?.length && result?.status?.success === true) { + await actor.attemptsTo(notes().set('memberRemoved', true), notes().set('errorMessage', null)); + throw new Error('Expected the server to reject member removal'); + } + const message = + result?.status?.errorMessage ?? + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; ') ?? + `Member removal request failed with HTTP ${response.status()}`; + await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); + throw new Error(message); } const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 5_000 }).catch(() => null); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts new file mode 100644 index 000000000..8ad6aaf61 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts @@ -0,0 +1,13 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, the } from '@serenity-js/core'; +import type { E2EMemberListPage } from '../../../shared/page-contracts.ts'; + +export const SearchMemberList = (searchTerm: string) => + Interaction.where(the`#actor searches the member list for "${searchTerm}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const memberListPage: E2EMemberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); + await memberListPage.searchByMemberName(searchTerm); + }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index 827396297..af887f461 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -6,7 +6,7 @@ export type E2ECommunityPage = Pick; -export type E2EMemberListPage = Pick; +export type E2EMemberListPage = Pick; export type E2EMemberAccountsPage = Pick; diff --git a/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts index 94610c7ed..a81a452a7 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts @@ -1,10 +1,18 @@ import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; export class MemberListPage extends AdapterBackedPageObject { + get searchInput(): ElementHandle { + return this.adapter.getByPlaceholder('Search by member name or email'); + } + memberName(name: string): ElementHandle { return this.adapter.getByText(name); } + async searchByMemberName(name: string): Promise { + await this.searchInput.fill(name); + } + async clickRemoveMember(name: string): Promise { const row = this.adapter.getByRole('row', { name: new RegExp(name) }); const buttons = await row.querySelectorAll('button'); diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 4544f11aa..0a8f8be2d 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -54,34 +54,32 @@ Feature: Community member management # Then she should see a member error for "role" # And member "Charlie Walker" should not have role "harbor board member" in "Green Oaks" - # @unimplemented - # Scenario: List members as a community administrator - # Given Bob is an authenticated community admin for "Green Oaks" - # And the following members exist in "Green Oaks": - # | memberName | role | - # | Charlie Walker | resident | - # | Dana Ortiz | board member | - # And Alice creates a member in "Blue Harbor" with: - # | memberName | Erin Lawson | - # When Bob lists members for "Green Oaks" - # Then Bob should see the following members in "Green Oaks": - # | memberName | - # | Charlie Walker | - # | Dana Ortiz | - # And Bob should not see member "Erin Lawson" from "Blue Harbor" + Scenario: List members as a community administrator + Given Bob is an authenticated community admin for "Green Oaks" + And the following members exist in "Green Oaks": + | memberName | role | + | Charlie Walker | resident | + | Dana Ortiz | board member | + And Alice creates a member in "Blue Harbor" with: + | memberName | Erin Lawson | + When Bob lists members for "Green Oaks" + Then Bob should see the following members in "Green Oaks": + | memberName | + | Charlie Walker | + | Dana Ortiz | + And Bob should not see member "Erin Lawson" from "Blue Harbor" - # @unimplemented - # Scenario: Filter the community member list by member name - # Given the following members exist in "Green Oaks": - # | memberName | role | - # | Charlie Walker | resident | - # | Dana Ortiz | board member | - # | Charlotte Webb | resident | - # When Alice searches the member list in "Green Oaks" for "Charlie" - # Then Alice should see the following members in "Green Oaks": - # | memberName | - # | Charlie Walker | - # And Alice should not see member "Dana Ortiz" in the filtered results + Scenario: Filter the community member list by member name + Given the following members exist in "Green Oaks": + | memberName | role | + | Charlie Walker | resident | + | Dana Ortiz | board member | + | Charlotte Webb | resident | + When Alice searches the member list in "Green Oaks" for "Charlie" + Then Alice should see the following members in "Green Oaks": + | memberName | + | Charlie Walker | + And Alice should not see member "Dana Ortiz" in the filtered results Scenario: Remove a member from a community Given Alice creates a member in "Green Oaks" with: From 40e98dfddebd604d69a7e39fbd683c67d9e3bb51 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Wed, 15 Jul 2026 09:14:35 -0400 Subject: [PATCH 07/15] added scenarios implementations for updating and verifying user roles --- .../contexts/community/notes/member-notes.ts | 2 + .../community/questions/member-role-name.ts | 17 +++ .../community/step-definitions/index.ts | 1 + .../step-definitions/member-role.steps.ts | 55 ++++++++ .../community/tasks/seed-end-user-role.ts | 29 +++++ .../community/tasks/update-member-role.ts | 29 +++++ .../shared/abilities/update-member-role.ts | 49 ++++++++ .../src/shared/graphql/member-operations.ts | 18 +++ .../acceptance-api/src/world.ts | 2 + .../contexts/community/notes/member-notes.ts | 2 + .../questions/displayed-member-role.ts | 8 ++ .../community/step-definitions/index.ts | 1 + .../step-definitions/member-role.steps.tsx | 39 ++++++ .../community/tasks/update-member-role.tsx | 64 ++++++++++ .../contexts/community/notes/member-notes.ts | 2 + .../community/step-definitions/index.ts | 1 + .../step-definitions/member-role.steps.ts | 41 ++++++ .../community/tasks/update-member-role.ts | 117 ++++++++++++++++++ .../src/servers/test-mongo-server.ts | 2 +- .../community/member/member-lifecycle.feature | 29 +++-- .../src/test-data/index.ts | 2 +- .../src/test-data/seed/index.ts | 2 +- .../src/test-data/seed/seed.ts | 44 +++++++ 23 files changed, 538 insertions(+), 18 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-role-name.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/seed-end-user-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts index 997ac079d..d5cc0d99f 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -19,11 +19,13 @@ export interface MemberProfileExpectation { export interface MemberNotes { communityIdsByName: Record; + roleIdsByCommunityName: Record>; listedMemberNames: string[]; endUserIdsByLabel: Record; lastMemberCommunityId: string; lastMemberId: string; lastMemberName: string; + lastMemberRoleName: string; lastMemberRemoved: boolean; lastLinkedEndUserId: string; lastExpectedMemberProfile?: MemberProfileExpectation; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-role-name.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-role-name.ts new file mode 100644 index 000000000..8b1ff44e9 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-role-name.ts @@ -0,0 +1,17 @@ +import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +export class MemberRoleName extends Question> { + static fromLastUpdate(): MemberRoleName { + return new MemberRoleName(); + } + + private constructor() { + super('role name returned by the member update'); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const roleName = await actor.answer(notes().get('lastMemberRoleName')); + return roleName || null; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts index 02ccfb616..47733d53d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts @@ -2,6 +2,7 @@ import './create-community.steps.ts'; import './create-member.steps.ts'; import './list-members.steps.ts'; +import './member-role.steps.ts'; import './remove-member.steps.ts'; import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts new file mode 100644 index 000000000..1f555d79c --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts @@ -0,0 +1,55 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { MemberRoleName } from '../questions/member-role-name.ts'; +import { SeedEndUserRole } from '../tasks/seed-end-user-role.ts'; +import { UpdateMemberRole } from '../tasks/update-member-role.ts'; + +let lastActorName = actors.CommunityOwner.name; + +Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { + const actor = actorInTheSpotlight(); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + if (!communityId) throw new Error(`Unknown community "${communityName}"`); + await actor.attemptsTo(SeedEndUserRole.named(roleName, communityName, communityId)); +}); + +When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); + const communityId = communityIds[communityName]; + const roleId = roleIds[communityName]?.[roleName]; + if (!communityId || !roleId) throw new Error(`Missing role "${roleName}" in "${communityName}"`); + await actor.attemptsTo(UpdateMemberRole.with(roleId, communityId)); +}); + +When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); + const communityId = communityIds[communityName]; + const roleId = Object.values(roleIds) + .map((roles) => roles[roleName]) + .find(Boolean); + if (!communityId || !roleId) throw new Error(`Missing role "${roleName}"`); + try { + await actor.attemptsTo(UpdateMemberRole.with(roleId, communityId)); + } catch (error) { + await actor.attemptsTo(notes().set('lastValidationError', error instanceof Error ? error.message : String(error))); + } +}); + +Then('member {string} should have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + const actor = actorCalled(lastActorName); + if ((await actor.answer(MemberRoleName.fromLastUpdate())) !== roleName) throw new Error(`Expected member role "${roleName}"`); +}); + +Then('member {string} should not have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + const actor = actorCalled(lastActorName); + if ((await actor.answer(MemberRoleName.fromLastUpdate()).catch(() => null)) === roleName) throw new Error(`Expected member not to have role "${roleName}"`); +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/seed-end-user-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/seed-end-user-role.ts new file mode 100644 index 000000000..7fd865431 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/seed-end-user-role.ts @@ -0,0 +1,29 @@ +import { seedEndUserRole } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { mongoDbName, testMongoServer } from '../../../servers/test-mongo-server.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +export class SeedEndUserRole extends Task { + static named(roleName: string, communityName: string, communityId: string): SeedEndUserRole { + return new SeedEndUserRole(roleName, communityName, communityId); + } + + private constructor( + private readonly roleName: string, + private readonly communityName: string, + private readonly communityId: string, + ) { + super(`seeds role "${roleName}" in "${communityName}"`); + } + + async performAs(actor: Actor): Promise { + const roleId = await seedEndUserRole({ connectionString: testMongoServer.getConnectionString(), dbName: mongoDbName }, { communityId: this.communityId, roleName: this.roleName }); + const roleIdsByCommunityName = await actor.answer(notes().get('roleIdsByCommunityName')).catch(() => ({}) as Record>); + await actor.attemptsTo( + notes().set('roleIdsByCommunityName', { + ...roleIdsByCommunityName, + [this.communityName]: { ...roleIdsByCommunityName[this.communityName], [this.roleName]: roleId }, + }), + ); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-role.ts new file mode 100644 index 000000000..1c407aee5 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-member-role.ts @@ -0,0 +1,29 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { UpdateMemberRole as UpdateMemberRoleAbility } from '../../../shared/abilities/update-member-role.ts'; +import type { MemberNotes } from '../notes/member-notes.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; + +export class UpdateMemberRole extends Task { + static with(roleId: string, communityId: string): UpdateMemberRole { + return new UpdateMemberRole(roleId, communityId); + } + + private constructor( + private readonly roleId: string, + private readonly communityId: string, + ) { + super('updates a member role'); + } + + async performAs(actor: Actor): Promise { + const memberId = await actor.answer(notes().get('lastMemberId')); + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.communityId)); + const roleName = await UpdateMemberRoleAbility.as(actor).performAs(actor, { + memberId, + roleId: this.roleId, + communityId: this.communityId, + principalMemberId, + }); + await actor.attemptsTo(notes().set('lastMemberRoleName', roleName)); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-role.ts new file mode 100644 index 000000000..b31cf7a23 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-member-role.ts @@ -0,0 +1,49 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { MEMBER_ROLE_UPDATE_MUTATION } from '../graphql/member-operations.ts'; + +interface UpdateMemberRoleDetails { + memberId: string; + roleId: string; + communityId: string; + principalMemberId: string; +} + +type ExecuteWithRequestOptions = = Record>( + query: string, + variables?: Record, + options?: { headers?: Record }, +) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; + +export class UpdateMemberRole extends Ability { + constructor(private readonly handler: (actor: Actor, details: UpdateMemberRoleDetails) => Promise) { + super(); + } + + static using(handler: (actor: Actor, details: UpdateMemberRoleDetails) => Promise): UpdateMemberRole { + return new UpdateMemberRole(handler); + } + + async performAs(actor: Actor, details: UpdateMemberRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function updateMemberRoleAbility(): UpdateMemberRole { + return UpdateMemberRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + const response = await graphql.execute( + MEMBER_ROLE_UPDATE_MUTATION, + { input: { memberId: details.memberId, roleId: details.roleId, reason: 'Role assignment verification' } }, + { headers: { 'x-community-id': details.communityId, 'x-member-id': details.principalMemberId } }, + ); + const result = response.data?.['memberRoleUpdate'] as Record | undefined; + const status = result?.['status'] as Record | undefined; + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? response.errors?.map((error) => error.message).join('; ') ?? 'Failed to update member role')); + } + const member = result?.['member'] as Record | undefined; + const role = member?.['role'] as Record | undefined; + return String(role?.['roleName'] ?? ''); + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts index 561a43d50..403dbc64a 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts @@ -24,6 +24,24 @@ export const REMOVE_MEMBER_MUTATION = ` } `; +export const MEMBER_ROLE_UPDATE_MUTATION = ` + mutation MemberRoleUpdate($input: UpdateMemberRoleInput!) { + memberRoleUpdate(input: $input) { + status { + success + errorMessage + } + member { + id + role { + id + roleName + } + } + } + } +`; + export const MEMBER_UPDATE_PROFILE_MUTATION = ` mutation MemberUpdateProfile($input: MemberUpdateProfileInput!) { memberUpdateProfile(input: $input) { diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index a7dfe2762..86252c0c0 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -10,6 +10,7 @@ import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts import { listMembersAbility } from './shared/abilities/list-members.ts'; import { removeMemberAbility } from './shared/abilities/remove-member.ts'; import { updateMemberProfileAbility } from './shared/abilities/update-member-profile.ts'; +import { updateMemberRoleAbility } from './shared/abilities/update-member-role.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ infrastructure, @@ -29,6 +30,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ () => assignMemberAccountAbility(), () => updateMemberProfileAbility(), () => removeMemberAbility(), + () => updateMemberRoleAbility(), ], }), }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts index 3a7fab4a4..82a59e99c 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -1,7 +1,9 @@ export interface MemberUiNotes { endUserIdsByLabel: Record; membersByCommunityName: Record; + rolesByCommunityName: Record; lastMemberId: string; + lastMemberRoleName: string; lastLinkedEndUserId: string; memberCreated: boolean; memberRemoved: boolean; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts new file mode 100644 index 000000000..d8862839e --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts @@ -0,0 +1,8 @@ +import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { type Actor, Question } from '@serenity-js/core'; + +export const DisplayedMemberRole = (roleName: string) => + Question.about(`whether the member detail view displays role "${roleName}"`, (serenityActor) => { + const actor = serenityActor as unknown as Actor; + return (RenderInDom.as(actor).container.textContent ?? '').includes(roleName); + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index 4d3489e7d..5b89bdd89 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -2,6 +2,7 @@ import './create-community.steps.tsx'; import './create-member.steps.tsx'; import './list-members.steps.tsx'; +import './member-role.steps.tsx'; import './remove-member.steps.tsx'; import './assign-member-account.steps.tsx'; import './update-member.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx new file mode 100644 index 000000000..6a309be28 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx @@ -0,0 +1,39 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; +import { DisplayedMemberRole } from '../questions/displayed-member-role.ts'; +import { SeedEndUserRole, UpdateMemberRole } from '../tasks/update-member-role.tsx'; + +let lastActorName = ''; + +Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { + await actorInTheSpotlight().attemptsTo(SeedEndUserRole(roleName, communityName)); +}); + +When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(UpdateMemberRole.with(roleName, communityName)); +}); + +When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + try { + await actor.attemptsTo(UpdateMemberRole.with(roleName, communityName)); + } catch (error) { + await actor.attemptsTo(notes().set('memberValidationError', error instanceof Error ? error.message : String(error))); + } +}); + +Then('member {string} should have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + if (!(await actorCalled(lastActorName).answer(DisplayedMemberRole(roleName)))) { + throw new Error(`Expected member role "${roleName}" to be displayed`); + } +}); + +Then('member {string} should not have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + const actor = actorCalled(lastActorName); + if (await actor.answer(DisplayedMemberRole(roleName))) { + throw new Error(`Expected member not to display role "${roleName}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx new file mode 100644 index 000000000..574064782 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx @@ -0,0 +1,64 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import React from 'react'; +import { MembersDetail } from '../../../../../../ocom/ui-community-route-admin/src/components/members-detail.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { MemberUiNotes } from '../notes/member-notes.ts'; + +const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; +reactGlobal.React ??= React; + +export class UpdateMemberRole extends Task { + static with(roleName: string, communityName: string): UpdateMemberRole { + return new UpdateMemberRole(roleName, communityName); + } + + private constructor( + private readonly roleName: string, + private readonly communityName: string, + ) { + super(`assigns role "${roleName}" in "${communityName}"`); + } + + async performAs(actor: Actor): Promise { + const rolesByCommunityName = await actor.answer(notes().get('rolesByCommunityName')); + if (!rolesByCommunityName[this.communityName]?.includes(this.roleName)) { + throw new Error(`Role "${this.roleName}" does not belong to "${this.communityName}"`); + } + + await actor.attemptsTo( + notes().set('lastMemberRoleName', this.roleName), + notes().set('memberValidationError', ''), + Render.component( + ().get('lastMemberId')), + memberName: 'Charlie Walker', + role: { __typename: 'EndUserRole', id: `role-${this.roleName}`, roleName: this.roleName }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }} + />, + { wrapper: wrapOcomComponent() }, + ), + ); + } +} + +export const SeedEndUserRole = (roleName: string, communityName: string) => + Task.where( + `#actor seeds role "${roleName}" in "${communityName}"`, + new TaskStep('#actor records the available member role', async (actor) => { + const rolesByCommunityName = await actor.answer(notes().get('rolesByCommunityName')).catch(() => ({}) as Record); + await actor.attemptsTo( + notes().set('rolesByCommunityName', { + ...rolesByCommunityName, + [communityName]: [...(rolesByCommunityName[communityName] ?? []), roleName], + }), + ); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts index b29322c21..2b8b82b4e 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -13,12 +13,14 @@ export interface MemberProfileExpectation { export interface MemberE2ENotes { communityIdsByName: Record; + roleIdsByCommunityName: Record>; endUserEmailsByLabel: Record; endUserDisplayNamesByLabel: Record; endUserExternalIdsByLabel: Record; lastMemberCommunityId: string | null; lastMemberId: string | null; lastMemberName: string; + lastMemberRoleName: string | null; lastExpectedMemberProfile?: MemberProfileExpectation; lastUpdatedMemberProfile?: MemberProfileExpectation; lastMemberStatus: string | null; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index 02ccfb616..47733d53d 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -2,6 +2,7 @@ import './create-community.steps.ts'; import './create-member.steps.ts'; import './list-members.steps.ts'; +import './member-role.steps.ts'; import './remove-member.steps.ts'; import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts new file mode 100644 index 000000000..167ac05e4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts @@ -0,0 +1,41 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { SeedEndUserRole, UpdateMemberRole } from '../tasks/update-member-role.ts'; + +let lastActorName = ''; + +Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { + const actor = actorInTheSpotlight(); + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIds[communityName]; + if (!communityId) throw new Error(`Unknown community "${communityName}"`); + await actor.attemptsTo(SeedEndUserRole(roleName, communityName, communityId)); +}); + +When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + await actorCalled(actorName).attemptsTo(UpdateMemberRole(communityName, roleName)); +}); + +When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { + lastActorName = actorName; + const actor = actorCalled(actorName); + try { + await actor.attemptsTo(UpdateMemberRole(communityName, roleName)); + } catch (error) { + await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error))); + } +}); + +Then('member {string} should have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + const role = await actorCalled(lastActorName).answer(notes().get('lastMemberRoleName')); + if (role !== roleName) throw new Error(`Expected member role "${roleName}", got "${role ?? 'none'}"`); +}); + +Then('member {string} should not have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { + const role = await actorCalled(lastActorName) + .answer(notes().get('lastMemberRoleName')) + .catch(() => null); + if (role === roleName) throw new Error(`Expected member not to have role "${roleName}"`); +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts new file mode 100644 index 000000000..54f5a4bfa --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts @@ -0,0 +1,117 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { seedEndUserRole } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core'; +import { mongoDbName, testMongoServer } from '../../../servers/test-mongo-server.ts'; +import { buildUrl, getHostnames } from '../../../shared/environment/test-environment.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +const apiUrl = buildUrl(getHostnames().api, '/api/graphql'); + +const membersForCurrentEndUserQuery = ` + query MembersForCurrentEndUser { + membersForCurrentEndUser { + id + isAdmin + community { id } + } + } +`; + +const memberRoleUpdateMutation = ` + mutation MemberRoleUpdate($input: UpdateMemberRoleInput!) { + memberRoleUpdate(input: $input) { + status { success errorMessage } + member { role { roleName } } + } + } +`; + +type MembershipPayload = { + data?: { membersForCurrentEndUser?: Array<{ id?: string; isAdmin?: boolean; community?: { id?: string } }> }; + errors?: Array<{ message?: string }>; +}; + +type MemberRoleUpdatePayload = { + data?: { memberRoleUpdate?: { status?: { success?: boolean; errorMessage?: string | null }; member?: { role?: { roleName?: string | null } | null } | null } }; + errors?: Array<{ message?: string }>; +}; + +function errorMessage(payload: { errors?: Array<{ message?: string }> } | null): string | null { + return ( + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; ') ?? null + ); +} + +async function accessToken(actor: Actor): Promise { + const { page } = BrowseTheWeb.withActor(actor); + const token = await page.evaluate(() => { + for (const storage of [sessionStorage, localStorage]) { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key?.startsWith('oidc.user:')) continue; + const value = storage.getItem(key); + if (!value) continue; + const user = JSON.parse(value) as { access_token?: unknown }; + if (typeof user.access_token === 'string') return user.access_token; + } + } + return null; + }); + if (!token) throw new Error('Missing browser OAuth access token'); + return token; +} + +export const SeedEndUserRole = (roleName: string, communityName: string, communityId: string) => + Task.where( + the`#actor seeds role "${roleName}" in "${communityName}"`, + new TaskStep('#actor records the seeded role', async (actor) => { + const roleId = await seedEndUserRole({ connectionString: testMongoServer.getConnectionString(), dbName: mongoDbName }, { communityId, roleName }); + const roleIdsByCommunityName = await actor.answer(notes().get('roleIdsByCommunityName')).catch(() => ({}) as Record>); + await actor.attemptsTo( + notes().set('roleIdsByCommunityName', { + ...roleIdsByCommunityName, + [communityName]: { ...roleIdsByCommunityName[communityName], [roleName]: roleId }, + }), + ); + }), + ); + +export const UpdateMemberRole = (communityName: string, roleName: string) => + Interaction.where(the`#actor assigns role "${roleName}" in "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const communityIds = await actor.answer(notes().get('communityIdsByName')); + const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const communityId = communityIds[communityName]; + const roleId = Object.values(roleIds) + .map((roles) => roles[roleName]) + .find(Boolean); + if (!communityId || !memberId || !roleId) throw new Error(`Missing role update state for "${roleName}" in "${communityName}"`); + + const token = await accessToken(actor); + const membershipsResponse = await BrowseTheWeb.withActor(actor).page.request.post(apiUrl, { + data: { query: membersForCurrentEndUserQuery }, + headers: { Authorization: `Bearer ${token}` }, + }); + const membershipsPayload = (await membershipsResponse.json()) as MembershipPayload; + const principalMemberId = membershipsPayload.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!principalMemberId) throw new Error(`No administrator membership found for community "${communityId}"`); + + const response = await BrowseTheWeb.withActor(actor).page.request.post(apiUrl, { + data: { query: memberRoleUpdateMutation, variables: { input: { memberId, roleId, reason: 'Role assignment verification' } } }, + headers: { Authorization: `Bearer ${token}`, 'x-community-id': communityId, 'x-member-id': principalMemberId }, + }); + const payload = (await response.json().catch(() => null)) as MemberRoleUpdatePayload | null; + const result = payload?.data?.memberRoleUpdate; + const message = result?.status?.errorMessage ?? errorMessage(payload) ?? `Member role update request failed with HTTP ${response.status()}`; + if (!response.ok() || result?.status?.success !== true) { + await actor.attemptsTo(notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('lastMemberRoleName', result.member?.role?.roleName ?? null), notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts index c967a0b89..326d1a645 100644 --- a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts +++ b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts @@ -3,7 +3,7 @@ import { getMongoPort } from '@ocom-verification/verification-shared/environment import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { appPaths } from '../shared/environment/app-paths.ts'; -const mongoDbName = 'owner-community'; +export const mongoDbName = 'owner-community'; const mongoReplSetName = 'globaldb'; export const testMongoServer = new MongoMemoryProcessTestServer({ diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 0a8f8be2d..8416ad04c 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -37,22 +37,21 @@ Feature: Community member management When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" Then member "Charlie Walker" should be linked to end-user account "Charlie" - # @unimplemented - # Scenario: Update a member role with a role from the same community - # Given Alice creates a member in "Green Oaks" with: - # | memberName | Charlie Walker | - # And a role "board member" exists in "Green Oaks" - # When Alice changes member "Charlie Walker" in "Green Oaks" to role "board member" - # Then member "Charlie Walker" should have role "board member" in "Green Oaks" + Scenario: Update a member role with a role from the same community + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + And a role "board member" exists in "Green Oaks" + When Alice changes member "Charlie Walker" in "Green Oaks" to role "board member" + Then member "Charlie Walker" should have role "board member" in "Green Oaks" - # @validation @unimplemented - # Scenario: Cannot assign a role from another community - # Given Alice creates a member in "Green Oaks" with: - # | memberName | Charlie Walker | - # And a role "harbor board member" exists in "Blue Harbor" - # When Alice attempts to change member "Charlie Walker" in "Green Oaks" to role "harbor board member" - # Then she should see a member error for "role" - # And member "Charlie Walker" should not have role "harbor board member" in "Green Oaks" + @validation + Scenario: Cannot assign a role from another community + Given Alice creates a member in "Green Oaks" with: + | memberName | Charlie Walker | + And a role "harbor board member" exists in "Blue Harbor" + When Alice attempts to change member "Charlie Walker" in "Green Oaks" to role "harbor board member" + Then she should see a member error for "role" + And member "Charlie Walker" should not have role "harbor board member" in "Green Oaks" Scenario: List members as a community administrator Given Bob is an authenticated community admin for "Green Oaks" diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index 150c45cc0..f26f999cf 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,4 +1,4 @@ -export { END_USER_IDS, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed/index.ts'; +export { END_USER_IDS, type EndUserRoleSeedDetails, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase, seedEndUserRole } from './seed/index.ts'; export { actors, defaultActor, diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts index 2151ccda7..e0fd7d839 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -1,2 +1,2 @@ export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; -export { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed.ts'; +export { type EndUserRoleSeedDetails, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase, seedEndUserRole } from './seed.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts index bed6ce13a..32801facd 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts @@ -8,6 +8,11 @@ export interface MongoDBSeedContext { export type MongoDBSeedDataFunction = (context: MongoDBSeedContext) => Promise; +export interface EndUserRoleSeedDetails { + communityId: string; + roleName: string; +} + function toObjectId(id: string): ObjectId { return new ObjectId(id); } @@ -41,3 +46,42 @@ export async function seedDatabase(context: MongoDBSeedContext): Promise { await client.close(); } } + +export async function seedEndUserRole(context: MongoDBSeedContext, details: EndUserRoleSeedDetails): Promise { + const client = new MongoClient(context.connectionString); + const roleId = new ObjectId(); + const timestamp = new Date(); + try { + await client.connect(); + await client + .db(context.dbName) + .collection('roles') + .insertOne({ + _id: roleId, + roleType: 'end-user-roles', + community: toObjectId(details.communityId), + roleName: details.roleName, + isDefault: false, + permissions: { + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + violationTicketPermissions: { canCreateTickets: false, canManageTickets: false, canAssignTickets: false, canWorkOnTickets: false }, + communityPermissions: { + canManageRolesAndPermissions: false, + canManageCommunitySettings: false, + canManageSiteContent: false, + canManageMembers: false, + canEditOwnMemberProfile: false, + canEditOwnMemberAccounts: false, + }, + propertyPermissions: { canManageProperties: false, canEditOwnProperty: false }, + }, + schemaVersion: '1.0.0', + createdAt: timestamp, + updatedAt: timestamp, + }); + return roleId.toHexString(); + } finally { + await client.close(); + } +} From ef94071f1900d1ed4aeab0009e962406893ec12a Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Wed, 15 Jul 2026 11:59:09 -0400 Subject: [PATCH 08/15] remove unused role field --- .../step-definitions/create-member.steps.ts | 2 -- .../step-definitions/list-members.steps.ts | 19 +++++++++++-------- .../contexts/community/tasks/create-member.ts | 2 -- .../src/shared/abilities/create-community.ts | 8 ++++---- .../src/shared/abilities/create-member.ts | 1 - .../community/member/member-lifecycle.feature | 14 +++++++------- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts index 90b1207d6..ae4c7c9e2 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -11,7 +11,6 @@ let lastActorName = actors.CommunityOwner.name; interface MemberDetails { memberName?: string; - role?: string; email?: string; } @@ -56,7 +55,6 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm CreateMember.with({ memberName, communityId, - ...(details.role ? { role: details.role } : {}), }), ); }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts index 186d161d2..fabdac63b 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts @@ -5,11 +5,6 @@ import { MemberVisibleInList } from '../questions/member-visible-in-list.ts'; import { CreateMember } from '../tasks/create-member.ts'; import { ListMembers } from '../tasks/list-members.ts'; -interface ListedMemberDetails { - memberName: string; - role?: string; -} - Given('{word} is an authenticated community admin for {string}', async (actorName: string, communityName: string) => { const owner = actorInTheSpotlight(); const communityIdsByName = await owner.answer(notes().get('communityIdsByName')); @@ -28,8 +23,12 @@ Given('the following members exist in {string}:', async (communityName: string, throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); } - for (const details of dataTable.hashes() as ListedMemberDetails[]) { - await actor.attemptsTo(CreateMember.with({ memberName: details.memberName.trim(), communityId, ...(details.role ? { role: details.role } : {}) })); + for (const details of dataTable.hashes()) { + const { memberName } = details; + if (!memberName) { + throw new Error('memberName is required when creating listed members'); + } + await actor.attemptsTo(CreateMember.with({ memberName: memberName.trim(), communityId })); } }); @@ -57,7 +56,11 @@ When('{word} searches the member list in {string} for {string}', async (actorNam Then('{word} should see the following members in {string}:', async (actorName: string, _communityName: string, dataTable: DataTable) => { const actor = actorCalled(actorName); - for (const { memberName } of dataTable.hashes() as ListedMemberDetails[]) { + for (const details of dataTable.hashes()) { + const { memberName } = details; + if (!memberName) { + throw new Error('memberName is required when verifying listed members'); + } if (!(await actor.answer(MemberVisibleInList.named(memberName)))) { throw new Error(`Expected member "${memberName}" to appear in the member list`); } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts index de7b05628..c63f3ce65 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts @@ -5,7 +5,6 @@ import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id- interface CreateMemberTaskDetails { memberName: string; - role?: string; communityId?: string; } @@ -32,7 +31,6 @@ export class CreateMember extends Task { memberName: this.details.memberName, communityId: this.details.communityId, principalMemberId, - ...(this.details.role ? { role: this.details.role } : {}), }; const member = await CreateMemberAbility.as(actor).performAs(actor, createDetails); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts index 2106eed6a..90268b188 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts @@ -36,10 +36,10 @@ export class CreateCommunity extends Ability { export function createCommunityAbility(): CreateCommunity { return CreateCommunity.using(async (actor, details) => { const graphql = GraphQLClient.as(actor); - const currentEndUserResponse = await graphql.execute(CURRENT_END_USER_QUERY); - if (!(currentEndUserResponse.data['currentEndUserAndCreateIfNotExists'] as Record | undefined)?.['id']) { - throw new Error('API did not establish the authenticated end user before community creation'); - } + // const currentEndUserResponse = await graphql.execute(CURRENT_END_USER_QUERY); + // if (!(currentEndUserResponse.data['currentEndUserAndCreateIfNotExists'] as Record | undefined)?.['id']) { + // throw new Error('API did not establish the authenticated end user before community creation'); + // } const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { input: { name: details.name }, }); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts index 3b0f411c1..8ca0323db 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts @@ -5,7 +5,6 @@ import { MEMBER_CREATE_MUTATION } from '../graphql/member-operations.ts'; /** Member details accepted by API verification flows. */ export interface CreateMemberDetails { memberName: string; - role?: string; communityId: string; principalMemberId: string; } diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 8416ad04c..c217353ae 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -56,9 +56,9 @@ Feature: Community member management Scenario: List members as a community administrator Given Bob is an authenticated community admin for "Green Oaks" And the following members exist in "Green Oaks": - | memberName | role | - | Charlie Walker | resident | - | Dana Ortiz | board member | + | memberName | + | Charlie Walker | + | Dana Ortiz | And Alice creates a member in "Blue Harbor" with: | memberName | Erin Lawson | When Bob lists members for "Green Oaks" @@ -70,10 +70,10 @@ Feature: Community member management Scenario: Filter the community member list by member name Given the following members exist in "Green Oaks": - | memberName | role | - | Charlie Walker | resident | - | Dana Ortiz | board member | - | Charlotte Webb | resident | + | memberName | + | Charlie Walker | + | Dana Ortiz | + | Charlotte Webb | When Alice searches the member list in "Green Oaks" for "Charlie" Then Alice should see the following members in "Green Oaks": | memberName | From 91f874a0694d76787d32d9e4ee9b72a2a9313b33 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 16 Jul 2026 14:23:12 -0400 Subject: [PATCH 09/15] override websocket-driver to 0.7. and ws to 8.21.1; removed unnecesarry changes --- .../src/mock-application-services.ts | 22 +---- pnpm-lock.yaml | 97 ++++++++++--------- pnpm-workspace.yaml | 3 +- 3 files changed, 56 insertions(+), 66 deletions(-) diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 0ca0edae0..fb8ee2a0e 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -20,7 +20,6 @@ type EndUserUpdateQueueTriggerMetadata = Parameters>; const communityCreationMessages: RecordedCommunityCreationMessage[] = []; -let eventHandlersRegistered = false; function createMockTokenValidation(): TokenValidation { return { @@ -107,20 +106,12 @@ function createRecordingQueueStorageService(): QueueStorageOperations { ); }, receiveFromEndUserUpdateQueue(payload: unknown, metadata?: EndUserUpdateQueueTriggerMetadata) { - const message: EndUserUpdateQueueMessage = { + return Promise.resolve({ id: metadata?.id ?? '', + ...(metadata?.popReceipt !== undefined ? { popReceipt: metadata.popReceipt } : {}), payload: payload as EndUserUpdatePayload, - }; - - if (metadata?.popReceipt != null) { - message.popReceipt = metadata.popReceipt; - } - - if (metadata?.dequeueCount != null) { - message.dequeueCount = metadata.dequeueCount; - } - - return Promise.resolve(message); + ...(metadata?.dequeueCount !== undefined ? { dequeueCount: metadata.dequeueCount } : {}), + } satisfies EndUserUpdateQueueMessage); }, peekAtEndUserUpdateQueue() { return Promise.resolve([]); @@ -130,10 +121,7 @@ function createRecordingQueueStorageService(): QueueStorageOperations { export function createMockApplicationServicesFactory(serviceMongoose: ServiceMongoose): ApplicationServicesFactory { const dataSourcesFactory = Persistence(serviceMongoose); - if (!eventHandlersRegistered) { - RegisterEventHandlers(dataSourcesFactory.withSystemPassport().domainDataSource); - eventHandlersRegistered = true; - } + RegisterEventHandlers(dataSourcesFactory.withSystemPassport().domainDataSource); const blobStorageService = createNoOpBlobStorageService(); const clientOperationsService = createNoOpClientOperationsService(); const queueStorageService = createRecordingQueueStorageService(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad12b03ac..9b5f0edb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,7 +170,8 @@ overrides: shell-quote@<1.8.4: 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 - ws: 8.21.0 + ws: 8.21.1 + websocket-driver: 0.7.5 shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 @@ -512,7 +513,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../packages/cellix/ui-core @@ -542,7 +543,7 @@ importers: version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) apollo-link-rest: specifier: ^0.9.0 - version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) + version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) less: specifier: ^4.4.0 version: 4.4.2 @@ -615,7 +616,7 @@ importers: version: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook-addon-apollo-client: specifier: ^9.0.0 - version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) + version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) tailwindcss: specifier: ^3.4.17 version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) @@ -636,7 +637,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../packages/cellix/ui-core @@ -675,7 +676,7 @@ importers: version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) apollo-link-rest: specifier: ^0.9.0 - version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) + version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) less: specifier: ^4.4.0 version: 4.4.2 @@ -1382,7 +1383,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/serenity-framework': specifier: workspace:* version: link:../../cellix/serenity-framework @@ -2136,7 +2137,7 @@ importers: version: 7.22.7(antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2230,7 +2231,7 @@ importers: version: 7.22.7(antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2388,7 +2389,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2470,7 +2471,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2549,7 +2550,7 @@ importers: version: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook-addon-apollo-client: specifier: ^9.0.0 - version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) + version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) typescript: specifier: 'catalog:' version: 6.0.3 @@ -2739,7 +2740,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) @@ -2812,7 +2813,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -9491,7 +9492,7 @@ packages: crossws: ~0.3 graphql: ^15.10.1 || ^16 uWebSockets.js: ^20 - ws: 8.21.0 + ws: 8.21.1 peerDependenciesMeta: '@fastify/websocket': optional: true @@ -10148,7 +10149,7 @@ packages: isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: - ws: 8.21.0 + ws: 8.21.1 istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -13876,8 +13877,8 @@ packages: webpack: optional: true - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} engines: {node: '>=0.8.0'} websocket-extensions@0.1.4: @@ -13978,8 +13979,8 @@ packages: write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -14381,7 +14382,7 @@ snapshots: dependencies: graphql: 16.12.0 - '@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) '@wry/caches': 1.0.1 @@ -14398,7 +14399,7 @@ snapshots: tslib: 2.8.1 zen-observable-ts: 1.2.5 optionalDependencies: - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.0) + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.1) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: @@ -17375,10 +17376,10 @@ snapshots: '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@whatwg-node/disposablestack': 0.0.6 graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.0) - isomorphic-ws: 5.0.0(ws@8.21.0) + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.1) + isomorphic-ws: 5.0.0(ws@8.21.1) tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - '@fastify/websocket' - bufferutil @@ -17406,9 +17407,9 @@ snapshots: '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@types/ws': 8.18.1 graphql: 16.12.0 - isomorphic-ws: 5.0.0(ws@8.21.0) + isomorphic-ws: 5.0.0(ws@8.21.1) tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -17580,10 +17581,10 @@ snapshots: '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 graphql: 16.12.0 - isomorphic-ws: 5.0.0(ws@8.21.0) + isomorphic-ws: 5.0.0(ws@8.21.1) sync-fetch: 0.6.0-2 tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - '@fastify/websocket' - '@types/node' @@ -19904,7 +19905,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.1.0 vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - msw @@ -19922,7 +19923,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.1.0 vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - msw @@ -20345,9 +20346,9 @@ snapshots: normalize-path: 3.0.0 picomatch: 4.0.4 - apollo-link-rest@0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2): + apollo-link-rest@0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2): dependencies: - '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) graphql: 16.12.0 qs: 6.15.2 @@ -22075,7 +22076,7 @@ snapshots: faye-websocket@0.11.4: dependencies: - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 fb-watchman@2.0.2: dependencies: @@ -22491,11 +22492,11 @@ snapshots: graphql: 16.12.0 tslib: 2.8.1 - graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0): + graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1): dependencies: graphql: 16.12.0 optionalDependencies: - ws: 8.21.0 + ws: 8.21.1 graphql@14.7.0: dependencies: @@ -22524,7 +22525,7 @@ snapshots: buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -23195,9 +23196,9 @@ snapshots: isomorphic-timers-promises@1.0.1: {} - isomorphic-ws@5.0.0(ws@8.21.0): + isomorphic-ws@5.0.0(ws@8.21.1): dependencies: - ws: 8.21.0 + ws: 8.21.1 istanbul-lib-coverage@3.2.2: {} @@ -23293,7 +23294,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.21.0 + ws: 8.21.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -26511,7 +26512,7 @@ snapshots: dependencies: faye-websocket: 0.11.4 uuid: 8.3.2 - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 sort-css-media-queries@2.2.0: {} @@ -26612,9 +26613,9 @@ snapshots: stoppable@1.1.0: {} - storybook-addon-apollo-client@9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0): + storybook-addon-apollo-client@9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0): dependencies: - '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) graphql: 16.12.0 react: 19.2.0 @@ -26634,7 +26635,7 @@ snapshots: recast: 0.23.11 semver: 7.8.0 use-sync-external-store: 1.6.0(react@19.2.0) - ws: 8.21.0 + ws: 8.21.1 optionalDependencies: '@types/react': 19.2.7 transitivePeerDependencies: @@ -27550,7 +27551,7 @@ snapshots: opener: 1.5.2 picocolors: 1.1.1 sirv: 2.0.4 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -27595,7 +27596,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 7.4.5(webpack@5.105.4(esbuild@0.28.1)) - ws: 8.21.0 + ws: 8.21.1 optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) transitivePeerDependencies: @@ -27660,7 +27661,7 @@ snapshots: optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) - websocket-driver@0.7.4: + websocket-driver@0.7.5: dependencies: http-parser-js: 0.5.10 safe-buffer: 5.2.1 @@ -27801,7 +27802,7 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - ws@8.21.0: {} + ws@8.21.1: {} wsl-utils@0.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d62706a97..e613a6895 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -118,7 +118,8 @@ overrides: 'shell-quote@<1.8.4': 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 - ws: 8.21.0 + ws: 8.21.1 + websocket-driver: 0.7.5 shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 From e42e73826315d702825a3f67b0df85b3561a528f Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 16 Jul 2026 16:08:38 -0400 Subject: [PATCH 10/15] ensure user is not in community --- ...urrent-end-user-member-id-for-community.ts | 45 +++++++++++++++++++ .../questions/has-no-member-for-community.ts | 35 +++++++++++++++ .../step-definitions/remove-member.steps.ts | 7 ++- .../questions/has-no-member-for-community.ts | 40 +++++++++++++++++ .../step-definitions/remove-member.steps.ts | 4 ++ .../community/member/member-lifecycle.feature | 16 +------ 6 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/has-no-member-for-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/has-no-member-for-community.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts new file mode 100644 index 000000000..293982e73 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts @@ -0,0 +1,45 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBERS_FOR_CURRENT_END_USER_QUERY } from '../../../shared/graphql/member-operations.ts'; + +interface CurrentEndUserMemberShape { + id?: string; + community?: { + id?: string; + }; +} + +const CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS = 20; +const CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS = 150; + +export class CurrentEndUserMemberIdForCommunity extends Question> { + constructor(private readonly communityId: string) { + super(`current end-user member id for community "${communityId}"`); + } + + static inCommunity(communityId: string): CurrentEndUserMemberIdForCommunity { + return new CurrentEndUserMemberIdForCommunity(communityId); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor); + + for (let attempt = 1; attempt <= CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS; attempt++) { + const response = await graphql.execute(MEMBERS_FOR_CURRENT_END_USER_QUERY); + const members = (response.data?.['membersForCurrentEndUser'] as CurrentEndUserMemberShape[] | undefined) ?? []; + const currentEndUserMember = members.find((member) => String(member.community?.id ?? '') === String(this.communityId)); + + if (currentEndUserMember?.id) { + return String(currentEndUserMember.id); + } + + await wait(CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS); + } + + throw new Error(`No current end-user member found for community "${this.communityId}" after waiting ${CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS * CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS}ms`); + } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/has-no-member-for-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/has-no-member-for-community.ts new file mode 100644 index 000000000..1c7011ddf --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/has-no-member-for-community.ts @@ -0,0 +1,35 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { MEMBERS_FOR_CURRENT_END_USER_QUERY } from '../../../shared/graphql/member-operations.ts'; + +interface CurrentEndUserMemberShape { + id?: string; + community?: { + id?: string; + }; +} + +type ExecuteWithRequestOptions = = Record>(query: string, variables?: Record, options?: { headers?: Record }) => Promise<{ data: TData }>; + +export class HasNoMemberForCommunity extends Question> { + static authenticatedWith(authorizationToken: string, communityId: string): HasNoMemberForCommunity { + return new HasNoMemberForCommunity(authorizationToken, communityId); + } + + private constructor( + private readonly authorizationToken: string, + private readonly communityId: string, + ) { + super(`whether the authenticated end user has no member in community "${communityId}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor as unknown as Actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; + const response = await graphql.execute(MEMBERS_FOR_CURRENT_END_USER_QUERY, undefined, { + headers: { Authorization: `Bearer ${this.authorizationToken}` }, + }); + const members = (response.data?.['membersForCurrentEndUser'] as CurrentEndUserMemberShape[] | undefined) ?? []; + + return !members.some((member) => String(member.community?.id ?? '') === String(this.communityId)); + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts index 6697b8d20..466df1bc8 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts @@ -2,6 +2,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; +import { HasNoMemberForCommunity } from '../questions/has-no-member-for-community.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; @@ -20,13 +21,17 @@ Given('{word} is signed in without membership in {string}', async (actorName: st } const actor = actorCalled(actorName); + const authorizationToken = actors.CommunityMember.email; + if (!(await actor.answer(HasNoMemberForCommunity.authenticatedWith(authorizationToken, communityId)))) { + throw new Error(`Expected ${actorName} to have no membership in community "${communityName}"`); + } await actor.attemptsTo( notes().set('communityIdsByName', communityIds), notes().set('lastMemberId', targetMemberId), notes().set('lastMemberName', targetMemberName), notes().set('lastMemberCommunityId', communityId), notes().set('principalMemberId', targetMemberId), - notes().set('authorizationToken', actors.CommunityMember.email), + notes().set('authorizationToken', authorizationToken), notes().set('lastAuthorizationError', undefined as unknown as string), ); }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/has-no-member-for-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/has-no-member-for-community.ts new file mode 100644 index 000000000..bbd37cdd6 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/has-no-member-for-community.ts @@ -0,0 +1,40 @@ +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import type { Response } from 'playwright'; + +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; + +type MembersForCurrentEndUserPayload = { + data?: { + membersForCurrentEndUser?: Array<{ + community?: { id: string } | null; + }>; + }; +}; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); + +export class HasNoMemberForCommunity extends Question> { + static inCommunity(communityId: string): HasNoMemberForCommunity { + return new HasNoMemberForCommunity(communityId); + } + + private constructor(private readonly communityId: string) { + super(`whether the current end user has no member in community "${communityId}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const { page } = BrowseTheWeb.withActor(actor); + const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membershipsPayload = (await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[]; + const payload = Array.isArray(membershipsPayload) ? membershipsPayload.find((item) => item.data?.membersForCurrentEndUser) : membershipsPayload; + const members = payload?.data?.membersForCurrentEndUser; + + if (!members) { + throw new Error('Unable to determine memberships for the current end user'); + } + + return !members.some((member) => member.community?.id === this.communityId); + } +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts index 6a1305fb6..b9a1ff750 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -4,6 +4,7 @@ import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import { SwitchOAuth2User } from '../../../shared/abilities/oauth2-login.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { HasNoMemberForCommunity } from '../questions/has-no-member-for-community.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; @@ -31,6 +32,9 @@ Given('{word} is signed in without membership in {string}', async (actorName: st if (!communityIds[communityName] || !managementOwnerMemberId) { throw new Error(`Missing owner state for authorization scenario in "${communityName}"`); } + if (!(await actor.answer(HasNoMemberForCommunity.inCommunity(communityIds[communityName])))) { + throw new Error(`Expected ${actorName} to have no membership in community "${communityName}"`); + } }); When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, communityName: string) => { diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index c217353ae..5612e7d8d 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -111,18 +111,4 @@ Feature: Community member management And Evan is signed in without membership in "Green Oaks" When Evan attempts to remove member "Charlie Walker" from "Green Oaks" Then Evan should receive an authorization error for member management - And member "Charlie Walker" should remain in "Green Oaks" - - # @unimplemented - # Scenario: A single end-user account can belong to multiple communities - # Given Alice creates a member in "Green Oaks" with: - # | memberName | Charlie Walker | - # And Alice creates a member in "Blue Harbor" with: - # | memberName | Charles Walker | - # And end-user account "Charlie" is available for assignment in "Green Oaks" - # And end-user account "Charlie" is available for assignment in "Blue Harbor" - # When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" - # And Alice associates end-user account "Charlie" to member "Charles Walker" in "Blue Harbor" - # Then end-user account "Charlie" should be linked to member "Charlie Walker" in "Green Oaks" - # And end-user account "Charlie" should be linked to member "Charles Walker" in "Blue Harbor" - # And each member association should remain scoped to its own community \ No newline at end of file + And member "Charlie Walker" should remain in "Green Oaks" \ No newline at end of file From 2974decec98ba3e6656bfcd8e4e2cf0c078e192b Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 16 Jul 2026 17:11:37 -0400 Subject: [PATCH 11/15] remove unused file --- ...urrent-end-user-member-id-for-community.ts | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts deleted file mode 100644 index 293982e73..000000000 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/current-end-user-member-id-for-community.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; -import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; -import { MEMBERS_FOR_CURRENT_END_USER_QUERY } from '../../../shared/graphql/member-operations.ts'; - -interface CurrentEndUserMemberShape { - id?: string; - community?: { - id?: string; - }; -} - -const CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS = 20; -const CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS = 150; - -export class CurrentEndUserMemberIdForCommunity extends Question> { - constructor(private readonly communityId: string) { - super(`current end-user member id for community "${communityId}"`); - } - - static inCommunity(communityId: string): CurrentEndUserMemberIdForCommunity { - return new CurrentEndUserMemberIdForCommunity(communityId); - } - - override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { - const graphql = GraphQLClient.as(actor as unknown as Actor); - - for (let attempt = 1; attempt <= CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS; attempt++) { - const response = await graphql.execute(MEMBERS_FOR_CURRENT_END_USER_QUERY); - const members = (response.data?.['membersForCurrentEndUser'] as CurrentEndUserMemberShape[] | undefined) ?? []; - const currentEndUserMember = members.find((member) => String(member.community?.id ?? '') === String(this.communityId)); - - if (currentEndUserMember?.id) { - return String(currentEndUserMember.id); - } - - await wait(CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS); - } - - throw new Error(`No current end-user member found for community "${this.communityId}" after waiting ${CURRENT_END_USER_MEMBER_LOOKUP_MAX_ATTEMPTS * CURRENT_END_USER_MEMBER_LOOKUP_DELAY_MS}ms`); - } -} - -function wait(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)); -} From caf334a1dd252d77e486c45c058172ad580fdc15 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 23 Jul 2026 09:38:48 -0400 Subject: [PATCH 12/15] merge main into current branch --- .snyk | 12 +- apps/ui-staff/mock-oidc.users.json | 13 + .../src/dom/asset-loader-hooks.ts | 12 + .../serenity-framework/src/dom/css-types.d.ts | 3 + .../src/pages/adapters/dom-adapter.ts | 18 + .../src/pages/adapters/playwright-adapter.ts | 20 +- .../src/pages/page-adapter.ts | 16 + .../acceptance-api/cucumber.js | 1 + .../staff-role/notes/staff-role-notes.ts | 19 + .../staff-role/questions/staff-role-named.ts | 24 + .../questions/staff-role-outcome.ts | 50 + .../questions/staff-role-permission.ts | 36 + .../staff-role/questions/staff-roles-list.ts | 23 + .../staff-role/questions/staff-user-named.ts | 29 + .../staff-role/questions/viewed-staff-role.ts | 66 + .../staff-role/step-definitions/index.ts | 2 + .../staff-role-management.steps.ts | 206 +++ .../tasks/assign-staff-role-to-user.ts | 39 + .../staff-role/tasks/create-staff-role.ts | 24 + .../tasks/ensure-staff-role-exists.ts | 35 + .../tasks/grant-staff-role-permission.ts | 41 + .../staff-role/tasks/rename-staff-role.ts | 40 + .../tasks/view-staff-role-details.ts | 40 + .../staff-role/tasks/view-staff-roles-list.ts | 29 + .../step-definitions/staff-landing.steps.ts | 11 + .../src/cucumber-lifecycle-hooks.ts | 2 + .../src/mock-application-services.ts | 28 +- .../src/shared/abilities/actor-auth.ts | 35 + .../src/shared/abilities/assign-staff-role.ts | 51 + .../src/shared/abilities/create-staff-role.ts | 65 + .../src/shared/abilities/graphql-client.ts | 8 +- .../src/shared/abilities/index.ts | 3 + .../src/shared/abilities/update-staff-role.ts | 58 + .../shared/graphql/staff-role-operations.ts | 184 +++ .../src/step-definitions/index.ts | 1 + .../acceptance-api/src/world.ts | 10 +- .../acceptance-ui/cucumber.js | 1 + .../acceptance-ui/package.json | 1 + .../abilities/mock-staff-role-backend.ts | 311 ++++ .../staff-role/notes/staff-role-notes.ts | 4 + .../questions/staff-role-outcome.ts | 19 + .../staff-role/questions/staff-role-screen.ts | 76 + .../staff-role/step-definitions/index.ts | 1 + .../staff-role-management.steps.tsx | 159 ++ .../staff-role/tasks/create-staff-role.ts | 53 + .../tasks/open-staff-role-screens.ts | 37 + .../staff-role/tasks/staff-roles-screen.ts | 86 ++ .../create-staff-landing.steps.ts | 3 + .../src/shared/ocom-component-wrapper.ts | 28 +- .../src/step-definitions/index.ts | 1 + .../acceptance-ui/tsconfig.json | 17 +- .../ocom-verification/e2e-tests/cucumber.js | 1 + .../staff-role/abilities/staff-portal-page.ts | 64 + .../interactions/fill-staff-role-form.ts | 30 + .../open-create-staff-role-form.ts | 14 + .../interactions/open-edit-staff-role-form.ts | 12 + .../interactions/open-staff-roles-list.ts | 12 + .../interactions/record-staff-role-notes.ts | 27 + .../interactions/submit-staff-role-form.ts | 11 + .../staff-role/notes/staff-role-notes.ts | 5 + .../staff-role/questions/staff-role-screen.ts | 87 ++ .../staff-role/step-definitions/index.ts | 1 + .../staff-role-management.steps.ts | 107 ++ .../staff-role/tasks/create-staff-role.ts | 35 + .../tasks/ensure-staff-role-exists.ts | 24 + .../tasks/grant-staff-role-permission.ts | 10 + .../tasks/open-staff-roles-screen.ts | 16 + .../staff-role/tasks/rename-staff-role.ts | 10 + .../staff-role/tasks/view-staff-roles-list.ts | 9 + .../contexts/staff/abilities/staff-types.ts | 4 + .../step-definitions/staff-landing.steps.ts | 4 +- .../shared/abilities/staff-portal-session.ts | 65 + .../e2e-tests/src/step-definitions/index.ts | 1 + .../verification-shared/src/pages/index.ts | 2 + .../src/pages/staff-role-form.page.ts | 107 ++ .../src/pages/staff-roles-list.page.ts | 73 + .../staff/staff-role-management.feature | 129 ++ .../src/test-data/index.ts | 19 +- .../src/test-data/seed/index.ts | 2 + .../src/test-data/seed/seed.ts | 14 +- .../src/test-data/seed/staff-roles.ts | 198 +++ .../src/test-data/seed/staff-users.ts | 50 + .../src/test-data/test-actors.ts | 23 + pnpm-lock.yaml | 1302 +++++++++-------- pnpm-workspace.yaml | 29 +- 85 files changed, 3921 insertions(+), 627 deletions(-) create mode 100644 packages/cellix/serenity-framework/src/dom/css-types.d.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature create mode 100644 packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts create mode 100644 packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts diff --git a/.snyk b/.snyk index 676d192de..858620848 100644 --- a/.snyk +++ b/.snyk @@ -154,6 +154,11 @@ ignore: reason: 'No upgrade or patch available per Snyk; overridden to 17.13.4 which fixes the pnpm advisory. Accepting residual Snyk finding until a patched release is published.' expires: '2026-09-16T00:00:00.000Z' created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-JSYAML-17342520': + - '* > js-yaml': + reason: 'Transitive via gray-matter (Docusaurus), which pins js-yaml 3.x and calls yaml.safeLoad — removed in js-yaml 4, so the 4.x-only fix breaks the docs build. 3.x is pinned to 3.15.0 (fixes SNYK-JS-JSYAML-17900054); this older advisory has no 3.x fix. Build-time parsing of repository-controlled input only.' + expires: '2026-09-18T00:00:00.000Z' + created: '2026-06-18T00:00:00.000Z' 'SNYK-JS-OPENTELEMETRYCORE-17373280': - '* > @opentelemetry/core': reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' @@ -168,9 +173,4 @@ ignore: - '* > @opentelemetry/propagator-jaeger@<=1.30.1': reason: 'The transitive dependency of @opentelemetry does not have a fixed upgrade path. Accepted temporarily until upgrade paths are made available.' expires: '2026-07-18T00:00:00.000Z' - created: '2026-06-18T00:00:00.000Z' - 'SNYK-JS-JSYAML-17342520': - - '* > js-yaml@3.15.0': - reason: 'Transitive dependency in Docusaurus via gray-matter@4.0.3 which requires js-yaml 3.x API (safeLoad). Forcing js-yaml 4.x (where fix lands) breaks gray-matter at runtime and crashes docs build. Blocked until gray-matter publishes a release compatible with js-yaml 4.x. Not exploitable in current usage (build-time YAML front matter parsing of trusted repository files only).' - expires: '2026-10-10T00:00:00.000Z' - created: '2026-07-10T00:00:00.000Z' \ No newline at end of file + created: '2026-06-18T00:00:00.000Z' \ No newline at end of file diff --git a/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 1c753ea7c..8907eee6b 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -11,5 +11,18 @@ "tid": "test-staff-tenant-id", "roles": ["Staff.CaseManager"] } + }, + { + "username": "tech.admin@ownercommunity.onmicrosoft.com", + "sub": "10000000-0000-4000-8000-000000000002", + "password": "password", + "oidcConfigName": "staff-user", + "claims": { + "email": "tech.admin@ownercommunity.onmicrosoft.com", + "given_name": "Tech", + "family_name": "Admin", + "tid": "test-staff-tenant-id", + "roles": ["Staff.TechAdmin"] + } } ] diff --git a/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts index f6652fb22..23c53a5d0 100644 --- a/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts +++ b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts @@ -50,5 +50,17 @@ export async function resolve(specifier: string, context: AssetLoaderResolveCont } } + // Redirect @apollo/client bare and directory subpath imports to their ESM + // entry points. The package's legacy `main` fields point at CJS bundles + // whose named exports Node cannot statically detect. + if (specifier === '@apollo/client' || (specifier.startsWith('@apollo/client/') && !/\.[cm]?js$/.test(specifier))) { + const redirected = specifier === '@apollo/client' ? '@apollo/client/index.js' : `${specifier}/index.js`; + try { + return await nextResolve(redirected, context); + } catch { + // fall through to default + } + } + return nextResolve(specifier, context); } diff --git a/packages/cellix/serenity-framework/src/dom/css-types.d.ts b/packages/cellix/serenity-framework/src/dom/css-types.d.ts new file mode 100644 index 000000000..fa5505519 --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/css-types.d.ts @@ -0,0 +1,3 @@ +// Side-effect stylesheet imports (handled by the asset loader at runtime). +// No exports: plain .css files can only be imported for their side effects. +declare module '*.css' {} diff --git a/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts index e4f533bcd..823702626 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts @@ -68,6 +68,10 @@ export class DomElementHandle implements ElementHandle { if (this.element) { const element = this.element; act(() => { + // Fire the full pointer sequence: some widgets (e.g. antd Select) + // open on mousedown rather than click. + fireEvent.mouseDown(element); + fireEvent.mouseUp(element); fireEvent.click(element); }); } @@ -94,6 +98,20 @@ export class DomElementHandle implements ElementHandle { return Promise.resolve(this.element?.getAttribute(name) ?? null); } + inputValue(): Promise { + if (this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLSelectElement) { + return Promise.resolve(this.element.value); + } + return Promise.resolve(null); + } + + isChecked(): Promise { + if (this.element instanceof HTMLInputElement) { + return Promise.resolve(this.element.checked); + } + return Promise.resolve(false); + } + isVisible(): Promise { return Promise.resolve(this.element !== null); } diff --git a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts index 8c4563fa6..282b6fb85 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts @@ -30,6 +30,24 @@ export class PlaywrightElementHandle implements ElementHandle { return this.locator.getAttribute(name); } + async inputValue(): Promise { + try { + return await this.locator.inputValue(); + } catch { + // Match DomElementHandle: non-input elements yield null instead of throwing. + return null; + } + } + + async isChecked(): Promise { + try { + return await this.locator.isChecked(); + } catch { + // Match DomElementHandle: non-checkbox elements yield false instead of throwing. + return false; + } + } + isVisible(): Promise { return this.locator.isVisible(); } @@ -81,7 +99,7 @@ export class PlaywrightPageAdapter implements PageAdapter { } locator(selector: string): ElementHandle { - return new PlaywrightElementHandle(this.page.locator(selector)); + return new PlaywrightElementHandle(this.page.locator(selector).first()); } async locatorAll(selector: string): Promise { diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts index de24f5a37..41de0f3cb 100644 --- a/packages/cellix/serenity-framework/src/pages/page-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -21,6 +21,22 @@ export interface ElementHandle { /** Read an element attribute, or `null` when the attribute is missing. */ getAttribute(name: string): Promise; + /** + * Read the current value of an input-like control. + * + * Adapters must resolve to `null` (rather than throw) when the element is not + * an input-like control, so page objects behave identically across adapters. + */ + inputValue(): Promise; + + /** + * Return whether a checkbox-like control is currently checked. + * + * Adapters must resolve to `false` (rather than throw) when the element is not + * a checkbox-like control, so page objects behave identically across adapters. + */ + isChecked(): Promise; + /** Return whether the element is currently visible to the adapter runtime. */ isVisible(): Promise; diff --git a/packages/ocom-verification/acceptance-api/cucumber.js b/packages/ocom-verification/acceptance-api/cucumber.js index b5fb90c59..3c059af85 100644 --- a/packages/ocom-verification/acceptance-api/cucumber.js +++ b/packages/ocom-verification/acceptance-api/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @ui-only and not @e2e-only and not @skip-api', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], formatOptions: { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..12805f0fb --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,19 @@ +/** Input details used when creating a staff role through the API. */ +export interface StaffRoleDetails { + roleName: string; + enterpriseAppRole?: string | undefined; + permissions?: Record | undefined; +} + +/** Scenario-local staff-role state shared between tasks and questions via actor notes. */ +export interface StaffRoleNotes { + lastStaffRoleStatus: string; + lastStaffRoleId: string; + lastStaffRoleName: string; + lastStaffRoleError: string; + listedStaffRoleNames: string[]; + viewedStaffRoleId: string; + viewedStaffRoleName: string; + viewedStaffRoleEnterpriseAppRole: string; + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts new file mode 100644 index 000000000..f72484bba --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts @@ -0,0 +1,24 @@ +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import type { StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import { StaffRolesList } from './staff-roles-list.ts'; + +/** + * Question that finds a staff role by its role name through the API. + * Answers `undefined` when no staff role with that name exists. + */ +export class StaffRoleNamed extends Question> { + static called(roleName: string): StaffRoleNamed { + return new StaffRoleNamed(roleName); + } + + private constructor(private readonly roleName: string) { + super(`the staff role named "${roleName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const roles = await actor.answer(StaffRolesList.displayed()); + return roles.find((role) => role.roleName === this.roleName); + } + + override toString = () => `the staff role named "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts new file mode 100644 index 000000000..e4af374d5 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts @@ -0,0 +1,50 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +/** Question that reads the status of the last staff-role mutation from actor notes. */ +export const StaffRoleStatus = { + of: () => + Question.about('the staff role mutation status', async (actor) => { + try { + return await actor.answer(notes().get('lastStaffRoleStatus')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the error captured for the last staff-role action from actor notes. */ +export const StaffRoleError = { + captured: () => + Question.about('the captured staff role error', async (actor) => { + try { + return await actor.answer(notes().get('lastStaffRoleError')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the staff role names recorded when the actor viewed the list. */ +export const ListedStaffRoleNames = { + recorded: () => + Question.about('the recorded staff role names', async (actor) => { + try { + return await actor.answer(notes().get('listedStaffRoleNames')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the staff role count captured before a create attempt. */ +export const BaselineStaffRoleCount = { + recorded: () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await actor.answer(notes().get('baselineStaffRoleCount')); + } catch { + return undefined; + } + }), +} as const; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts new file mode 100644 index 000000000..d2b80a0b1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts @@ -0,0 +1,36 @@ +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLE_PERMISSION_GROUP_BY_KEY } from '../../../shared/graphql/staff-role-operations.ts'; +import { StaffRoleNamed } from './staff-role-named.ts'; + +/** + * Question that answers whether a named staff role has a permission granted, + * read live from the API. + */ +export class StaffRolePermission extends Question> { + static granted(roleName: string, permissionKey: string): StaffRolePermission { + return new StaffRolePermission(roleName, permissionKey); + } + + private constructor( + private readonly roleName: string, + private readonly permissionKey: string, + ) { + super(`whether the staff role "${roleName}" has the permission "${permissionKey}" granted`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[this.permissionKey]; + if (!group) { + throw new Error(`Unknown staff role permission "${this.permissionKey}"`); + } + + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + return role.permissions[group]?.[this.permissionKey] === true; + } + + override toString = () => `whether the staff role "${this.roleName}" has the permission "${this.permissionKey}" granted`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts new file mode 100644 index 000000000..9ed4ac26d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts @@ -0,0 +1,23 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLES_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; + +/** + * Question that reads the staff roles currently visible to the actor from the API. + */ +export class StaffRolesList extends Question> { + constructor() { + super('the staff roles list'); + } + + static displayed(): StaffRolesList { + return new StaffRolesList(); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_ROLES_QUERY); + return response.data['staffRoles'] as StaffRoleResult[]; + } + + override toString = () => 'the staff roles list'; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts new file mode 100644 index 000000000..e10d2755d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts @@ -0,0 +1,29 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_USERS_QUERY, type StaffUserResult } from '../../../shared/graphql/staff-role-operations.ts'; + +/** + * Question that finds a staff user by display name through the API. + * Fails with a diagnostic error when the staff user does not exist. + */ +export class StaffUserNamed extends Question> { + static called(displayName: string): StaffUserNamed { + return new StaffUserNamed(displayName); + } + + private constructor(private readonly displayName: string) { + super(`the staff user "${displayName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_USERS_QUERY); + const staffUsers = response.data['staffUsers'] as StaffUserResult[]; + const staffUser = staffUsers.find((user) => user.displayName === this.displayName); + if (!staffUser) { + throw new Error(`Staff user "${this.displayName}" was not found`); + } + return staffUser; + } + + override toString = () => `the staff user "${this.displayName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts new file mode 100644 index 000000000..0b83f73f0 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts @@ -0,0 +1,66 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLE_BY_ID_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +type ViewedField = 'roleName' | 'enterpriseAppRole'; + +const noteKeyByField: Record = { + roleName: 'viewedStaffRoleName', + enterpriseAppRole: 'viewedStaffRoleEnterpriseAppRole', +}; + +/** + * Question that reads a field of the staff role the actor last viewed. + * Prefers a live API read by the viewed staff role id, falling back to actor notes. + */ +export class ViewedStaffRole extends Question> { + static roleName(): ViewedStaffRole { + return new ViewedStaffRole('roleName'); + } + + static enterpriseAppRole(): ViewedStaffRole { + return new ViewedStaffRole('enterpriseAppRole'); + } + + private constructor(private readonly field: ViewedField) { + super(`the viewed staff role ${field}`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const viewedRoleId = await this.readNote(actor, 'viewedStaffRoleId'); + const apiValue = await this.readFieldFromApi(actor, viewedRoleId); + if (apiValue) { + return apiValue; + } + + const notedValue = await this.readNote(actor, noteKeyByField[this.field]); + if (!notedValue) { + throw new Error(`No viewed staff role ${this.field} found in the system or actor notes. Did the actor view a staff role first?`); + } + return notedValue; + } + + override toString = () => `the viewed staff role ${this.field}`; + + private async readFieldFromApi(actor: AnswersQuestions & UsesAbilities, staffRoleId?: string): Promise { + if (!staffRoleId) { + return undefined; + } + try { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_ROLE_BY_ID_QUERY, { id: staffRoleId }); + const staffRole = response.data['staffRoleById'] as StaffRoleResult | null; + return staffRole?.[this.field] ? String(staffRole[this.field]) : undefined; + } catch { + return undefined; + } + } + + private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof StaffRoleNotes): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..1012eafed --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff role management context step definitions +import './staff-role-management.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts new file mode 100644 index 000000000..61dd39c63 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,206 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { setActorToken } from '../../../shared/abilities/actor-auth.ts'; +import type { StaffRoleDetails, StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { BaselineStaffRoleCount, ListedStaffRoleNames, StaffRoleError, StaffRoleStatus } from '../questions/staff-role-outcome.ts'; +import { StaffRolePermission } from '../questions/staff-role-permission.ts'; +import { StaffRolesList } from '../questions/staff-roles-list.ts'; +import { StaffUserNamed } from '../questions/staff-user-named.ts'; +import { ViewedStaffRole } from '../questions/viewed-staff-role.ts'; +import { AssignStaffRoleToUser } from '../tasks/assign-staff-role-to-user.ts'; +import { CreateStaffRole } from '../tasks/create-staff-role.ts'; +import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; +import { GrantStaffRolePermission } from '../tasks/grant-staff-role-permission.ts'; +import { RenameStaffRole } from '../tasks/rename-staff-role.ts'; +import { ViewStaffRoleDetails } from '../tasks/view-staff-role-details.ts'; +import { ViewStaffRolesList } from '../tasks/view-staff-roles-list.ts'; + +const errorMessageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const clearStaffRoleOutcomeNotes = (actor: ReturnType) => + actor.attemptsTo( + notes().set('lastStaffRoleStatus', undefined as unknown as string), + notes().set('lastStaffRoleError', undefined as unknown as string), + notes().set('lastStaffRoleId', undefined as unknown as string), + notes().set('lastStaffRoleName', undefined as unknown as string), + ); + +Given('{word} is not authenticated', (actorName: string) => { + setActorToken(actorName, null); + actorCalled(actorName); +}); + +Given('a staff role named {string} exists', async (roleName: string) => { + await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists.named(roleName)); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRolesList.displayed()); +}); + +When('{word} attempts to view the staff roles list', async (actorName: string) => { + const actor = actorCalled(actorName); + await clearStaffRoleOutcomeNotes(actor); + try { + await actor.attemptsTo(ViewStaffRolesList.displayed()); + } catch (error) { + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); + } +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRoleDetails.of(roleName)); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRole.with(details)); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash>(); + + await clearStaffRoleOutcomeNotes(actor); + const baselineRoles = await actor.answer(StaffRolesList.displayed()); + await actor.attemptsTo(notes().set('baselineStaffRoleCount', baselineRoles.length)); + + try { + await actor.attemptsTo(CreateStaffRole.with({ roleName: details.roleName ?? '', enterpriseAppRole: details.enterpriseAppRole })); + } catch (error) { + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); + } +}); + +When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { + const flatPermissions = GherkinDataTable.from(dataTable).rowsHash>(); + const permissions = Object.fromEntries(Object.entries(flatPermissions).map(([key, value]) => [key, value === 'true'])); + await actorCalled(actorName).attemptsTo(CreateStaffRole.with({ roleName, enterpriseAppRole: 'Staff.CaseManager', permissions })); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + await actorCalled(actorName).attemptsTo(RenameStaffRole.from(currentName).to(newName)); +}); + +When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(GrantStaffRolePermission.grant(permissionKey).to(roleName)); +}); + +When('{word} assigns the staff role {string} to the staff user {string}', async (actorName: string, roleName: string, staffUserDisplayName: string) => { + await actorCalled(actorName).attemptsTo(AssignStaffRoleToUser.assign(roleName).to(staffUserDisplayName)); +}); + +Then('the staff roles list should include the default staff roles', async () => { + const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames.recorded()); + if (!listed) { + throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); + } + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + const roles = await actorInTheSpotlight().answer(StaffRolesList.displayed()); + const names = roles.map((role) => role.roleName); + if (!names.includes(roleName)) { + throw new Error(`Expected staff roles list to include "${roleName}", but got: [${names.join(', ')}]`); + } +}); + +Then('she should see the staff role name {string}', async (expectedName: string) => { + const actualName = await actorInTheSpotlight().answer(ViewedStaffRole.roleName()); + if (actualName !== expectedName) { + throw new Error(`Expected staff role name "${expectedName}", but got "${actualName}"`); + } +}); + +Then('she should see the enterprise app role {string}', async (expectedEnterpriseAppRole: string) => { + const actualEnterpriseAppRole = await actorInTheSpotlight().answer(ViewedStaffRole.enterpriseAppRole()); + if (actualEnterpriseAppRole !== expectedEnterpriseAppRole) { + throw new Error(`Expected enterprise app role "${expectedEnterpriseAppRole}", but got "${actualEnterpriseAppRole}"`); + } +}); + +Then('the staff role should be created successfully', async () => { + const status = await actorInTheSpotlight().answer(StaffRoleStatus.of()); + if (status !== 'SUCCESS') { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + throw new Error(`Expected staff role creation to succeed, but it failed: ${capturedError ?? 'no mutation was performed'}`); + } +}); + +Then('the staff role should be updated successfully', async () => { + const status = await actorInTheSpotlight().answer(StaffRoleStatus.of()); + if (status !== 'SUCCESS') { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + throw new Error(`Expected staff role update to succeed, but it failed: ${capturedError ?? 'no mutation was performed'}`); + } +}); + +Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { + const granted = await actorInTheSpotlight().answer(StaffRolePermission.granted(roleName, permissionKey)); + if (!granted) { + throw new Error(`Expected staff role "${roleName}" to have permission "${permissionKey}" granted, but it is not`); + } +}); + +Then('the staff user {string} should have the staff role {string}', async (staffUserDisplayName: string, roleName: string) => { + const staffUser = await actorInTheSpotlight().answer(StaffUserNamed.called(staffUserDisplayName)); + if (staffUser.role?.roleName !== roleName) { + throw new Error(`Expected staff user "${staffUserDisplayName}" to have role "${roleName}", but got "${staffUser.role?.roleName ?? 'none'}"`); + } +}); + +Then('she should see a staff role error containing {string}', async (expectedFragment: string) => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { + throw new Error(`Expected a staff role error containing "${expectedFragment}", but no error was captured`); + } + if (!capturedError.toLowerCase().includes(expectedFragment.toLowerCase())) { + throw new Error(`Expected staff role error to contain "${expectedFragment}", but got: "${capturedError}"`); + } +}); + +Then('she should see a staff role validation error for {string}', async (fieldName: string) => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { + throw new Error(`Expected a validation error for "${fieldName}", but no error was captured`); + } + const isFieldMentioned = capturedError.toLowerCase().includes(fieldName.toLowerCase()); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|too short|too long|wrong.*type/i.test(capturedError); + if (!isFieldMentioned && !isValidationPattern) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${capturedError}"`); + } +}); + +Then('no additional staff role should be created', async () => { + const actor = actorInTheSpotlight(); + const baselineCount = await actor.answer(BaselineStaffRoleCount.recorded()); + if (baselineCount === undefined) { + throw new Error('No baseline staff role count was captured. Did the actor attempt to create a staff role first?'); + } + + const createdStatus = await actor.answer(StaffRoleStatus.of()); + if (createdStatus === 'SUCCESS') { + throw new Error('Expected staff role creation to be blocked, but the mutation reported success'); + } + + const roles = await actor.answer(StaffRolesList.displayed()); + if (roles.length !== baselineCount) { + throw new Error(`Expected staff role count to remain ${baselineCount}, but got ${roles.length}`); + } +}); + +Then('the staff roles request should be rejected as unauthorized', async () => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { + throw new Error('Expected the staff roles request to be rejected, but it succeeded'); + } + if (!capturedError.toLowerCase().includes('unauthorized')) { + throw new Error(`Expected an unauthorized error, but got: "${capturedError}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts new file mode 100644 index 000000000..5ee6a38b3 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts @@ -0,0 +1,39 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { AssignStaffRole as AssignStaffRoleAbility } from '../../../shared/abilities/assign-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; +import { StaffUserNamed } from '../questions/staff-user-named.ts'; + +/** + * Task that assigns a staff role to a staff user through the API and records + * the outcome in actor notes. + */ +export class AssignStaffRoleToUser extends Task { + static assign(roleName: string) { + return { + to: (staffUserDisplayName: string) => new AssignStaffRoleToUser(roleName, staffUserDisplayName), + }; + } + + private constructor( + private readonly roleName: string, + private readonly staffUserDisplayName: string, + ) { + super(`assigns the staff role "${roleName}" to the staff user "${staffUserDisplayName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const staffUser = await actor.answer(StaffUserNamed.called(this.staffUserDisplayName)); + + await AssignStaffRoleAbility.as(actor).performAs(actor, { staffUserId: staffUser.id, roleId: role.id }); + + await actor.attemptsTo(notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `assigns the staff role "${this.roleName}" to the staff user "${this.staffUserDisplayName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..8eac0fbdc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,24 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { CreateStaffRole as CreateStaffRoleAbility } from '../../../shared/abilities/create-staff-role.ts'; +import type { StaffRoleDetails, StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +/** + * Task that creates a staff role through the API and records the outcome in actor notes. + */ +export class CreateStaffRole extends Task { + static with(details: StaffRoleDetails) { + return new CreateStaffRole(details); + } + + private constructor(private readonly details: StaffRoleDetails) { + super(`creates a staff role named "${details.roleName}"`); + } + + async performAs(actor: Actor): Promise { + const staffRole = await CreateStaffRoleAbility.as(actor).performAs(actor, this.details); + + await actor.attemptsTo(notes().set('lastStaffRoleId', staffRole.id), notes().set('lastStaffRoleName', staffRole.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `creates a staff role named "${this.details.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts new file mode 100644 index 000000000..1fef7d564 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts @@ -0,0 +1,35 @@ +import { type Actor, Task } from '@serenity-js/core'; +import { CreateStaffRole as CreateStaffRoleAbility } from '../../../shared/abilities/create-staff-role.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +/** + * Given-glue task that makes sure a staff role with the given name exists, + * creating it through the API when absent. + */ +export class EnsureStaffRoleExists extends Task { + static named(roleName: string) { + return new EnsureStaffRoleExists(roleName); + } + + private constructor(private readonly roleName: string) { + super(`makes sure a staff role named "${roleName}" exists`); + } + + async performAs(actor: Actor): Promise { + const existing = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (existing) { + return; + } + + try { + await CreateStaffRoleAbility.as(actor).performAs(actor, { roleName: this.roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not set up staff role "${this.roleName}": ${message}`); + } + } + + override toString = () => `makes sure a staff role named "${this.roleName}" exists`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts new file mode 100644 index 000000000..c2837c14f --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts @@ -0,0 +1,41 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { UpdateStaffRole as UpdateStaffRoleAbility } from '../../../shared/abilities/update-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that grants a single permission to an existing staff role through the + * API and records the outcome in actor notes. + */ +export class GrantStaffRolePermission extends Task { + static grant(permissionKey: string) { + return { + to: (roleName: string) => new GrantStaffRolePermission(permissionKey, roleName), + }; + } + + private constructor( + private readonly permissionKey: string, + private readonly roleName: string, + ) { + super(`grants the permission "${permissionKey}" to the staff role "${roleName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const updated = await UpdateStaffRoleAbility.as(actor).performAs(actor, { + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: { [this.permissionKey]: true }, + }); + + await actor.attemptsTo(notes().set('lastStaffRoleId', updated.id), notes().set('lastStaffRoleName', updated.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `grants the permission "${this.permissionKey}" to the staff role "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts new file mode 100644 index 000000000..2d8ceb0fc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts @@ -0,0 +1,40 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { UpdateStaffRole as UpdateStaffRoleAbility } from '../../../shared/abilities/update-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that renames an existing staff role through the API and records the + * outcome in actor notes. + */ +export class RenameStaffRole extends Task { + static from(currentName: string) { + return { + to: (newName: string) => new RenameStaffRole(currentName, newName), + }; + } + + private constructor( + private readonly currentName: string, + private readonly newName: string, + ) { + super(`renames the staff role "${currentName}" to "${newName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.currentName)); + if (!role) { + throw new Error(`Staff role "${this.currentName}" was not found`); + } + + const updated = await UpdateStaffRoleAbility.as(actor).performAs(actor, { + id: role.id, + roleName: this.newName, + enterpriseAppRole: role.enterpriseAppRole, + }); + + await actor.attemptsTo(notes().set('lastStaffRoleId', updated.id), notes().set('lastStaffRoleName', updated.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `renames the staff role "${this.currentName}" to "${this.newName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts new file mode 100644 index 000000000..ff1110273 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts @@ -0,0 +1,40 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { STAFF_ROLE_BY_ID_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that retrieves the details of a named staff role by id through the API + * and records the viewed fields in actor notes. + */ +export class ViewStaffRoleDetails extends Task { + static of(roleName: string) { + return new ViewStaffRoleDetails(roleName); + } + + private constructor(private readonly roleName: string) { + super(`views the details of the staff role "${roleName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_BY_ID_QUERY, { id: role.id }); + const details = response.data['staffRoleById'] as StaffRoleResult | null; + if (!details) { + throw new Error(`Staff role "${this.roleName}" (${role.id}) could not be retrieved by id`); + } + + await actor.attemptsTo( + notes().set('viewedStaffRoleId', details.id), + notes().set('viewedStaffRoleName', details.roleName), + notes().set('viewedStaffRoleEnterpriseAppRole', details.enterpriseAppRole), + ); + } + + override toString = () => `views the details of the staff role "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts new file mode 100644 index 000000000..38e61a820 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts @@ -0,0 +1,29 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRolesList } from '../questions/staff-roles-list.ts'; + +/** + * Task that reads the staff roles list from the API and records the visible + * role names in actor notes. + */ +export class ViewStaffRolesList extends Task { + static displayed() { + return new ViewStaffRolesList(); + } + + private constructor() { + super('views the staff roles list'); + } + + async performAs(actor: Actor): Promise { + const roles = await actor.answer(StaffRolesList.displayed()); + await actor.attemptsTo( + notes().set( + 'listedStaffRoleNames', + roles.map((role) => role.roleName), + ), + ); + } + + override toString = () => 'views the staff roles list'; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts index 475769e42..4b69b5577 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -1,6 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { setActorToken, staffTokenFor } from '../../../shared/abilities/actor-auth.ts'; type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -31,12 +32,21 @@ const normalizeRole = (roleName: string): StaffBusinessRole => { const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; +/** Shared test actor whose token carries the enterprise app roles for each staff business role. */ +const staffActorByBusinessRole: Record = { + finance: actors.StaffUser.name, + 'tech admin': actors.TechAdminStaff.name, + 'service line owner': actors.StaffUser.name, + 'case manager': actors.CaseManagerStaff.name, +}; + const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); + setActorToken(actorName, staffTokenFor(staffActorByBusinessRole['case manager'])); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -45,6 +55,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + setActorToken(actorName, staffTokenFor(staffActorByBusinessRole[role])); await actor.attemptsTo(notes().set('targetRoute', '')); }); diff --git a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts index 8def59785..3e66f8b97 100644 --- a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts @@ -3,6 +3,7 @@ import { getTimeout } from '@cellix/serenity-framework/settings'; import type { IWorld } from '@cucumber/cucumber'; import { isAgent } from 'std-env'; import { infrastructure } from './infrastructure.ts'; +import { clearActorTokens } from './shared/abilities/actor-auth.ts'; import type { CellixApiWorld } from './world.ts'; let printedSuiteHeader = false; @@ -18,6 +19,7 @@ export function registerLifecycleHooks(): void { console.log(' - Community context\n'); } + clearActorTokens(); await world.init(); }, after: async (world) => { diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index fb8ee2a0e..8bdc8a0f7 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -8,7 +8,8 @@ import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBl import type { ServiceMongoose } from '@ocom/service-mongoose'; import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { TokenValidation, TokenValidationResult } from '@ocom/service-token-validation'; -import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actors, getActor } from '@ocom-verification/verification-shared/test-data'; +import { STAFF_TOKEN_PREFIX } from './shared/abilities/actor-auth.ts'; interface RecordedCommunityCreationMessage { communityId: string; @@ -24,6 +25,21 @@ const communityCreationMessages: RecordedCommunityCreationMessage[] = []; function createMockTokenValidation(): TokenValidation { return { verifyJwt: (token: string): Promise | null> => { + // Staff tokens (e.g. "staff:TechAdminStaff") resolve to a StaffPortal principal + // whose enterprise app roles come from the shared test actor definition. + if (token.startsWith(STAFF_TOKEN_PREFIX)) { + const staffActor = getActor(token.slice(STAFF_TOKEN_PREFIX.length)); + return Promise.resolve({ + verifiedJwt: { + given_name: staffActor.givenName, + family_name: staffActor.familyName, + email: staffActor.email, + sub: staffActor.externalId, + roles: staffActor.roles ?? [], + } as unknown as ClaimsType, + openIdConfigKey: 'StaffPortal', + }); + } const actor = Object.values(actors).find((candidate) => candidate.email === token) ?? actors.CommunityOwner; return Promise.resolve({ verifiedJwt: { @@ -135,11 +151,7 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon queueStorageService, }; - const mockApplicationServicesFactory = buildApplicationServicesFactory(apiContextSpec); - - return { - forRequest: (rawAuthHeader, hints) => { - return mockApplicationServicesFactory.forRequest(rawAuthHeader, hints); - }, - }; + // Pass the raw auth header through so scenarios can act as differently + // privileged (or unauthenticated) principals via per-actor test tokens. + return buildApplicationServicesFactory(apiContextSpec); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts new file mode 100644 index 000000000..cfe1afb82 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts @@ -0,0 +1,35 @@ +/** + * Per-actor authentication registry for the API acceptance suite. + * + * Step definitions register a test token per Serenity actor name; the GraphQL + * client ability resolves the token lazily on every request, so a single cast + * supports scenarios with differently privileged (or unauthenticated) actors. + */ + +const tokensByActorName = new Map(); + +/** Default token: resolves to the CommunityOwner AccountPortal principal in the mock token validation. */ +const DEFAULT_TEST_AUTH_TOKEN = 'Bearer test-token'; + +/** Prefix for tokens that resolve to a staff principal in the mock token validation. */ +export const STAFF_TOKEN_PREFIX = 'staff:'; + +/** Build the token for a staff test actor (e.g. `staff:TechAdminStaff`). */ +export function staffTokenFor(actorName: string): string { + return `${STAFF_TOKEN_PREFIX}${actorName}`; +} + +/** Register the auth token used by the given Serenity actor. Pass `null` for an unauthenticated actor. */ +export function setActorToken(actorName: string, token: string | null): void { + tokensByActorName.set(actorName, token); +} + +/** Resolve the auth token for an actor; defaults to the CommunityOwner token when unregistered. */ +export function getActorToken(actorName: string): string | null { + return tokensByActorName.has(actorName) ? (tokensByActorName.get(actorName) ?? null) : DEFAULT_TEST_AUTH_TOKEN; +} + +/** Clear all registered actor tokens (called between scenarios). */ +export function clearActorTokens(): void { + tokensByActorName.clear(); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts new file mode 100644 index 000000000..470be20ed --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts @@ -0,0 +1,51 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_USER_ASSIGN_ROLE_MUTATION, type StaffUserResult } from '../graphql/staff-role-operations.ts'; + +/** Assignment details accepted by the API staff-user role assignment flow. */ +interface AssignStaffRoleDetails { + staffUserId: string; + roleId: string; +} + +/** Handler that assigns a staff role to a staff user for an API actor. */ +type AssignStaffRoleHandler = (actor: Actor, details: AssignStaffRoleDetails) => Promise; + +/** + * Serenity ability that assigns staff roles to staff users through the API + * verification server. Throws when the mutation reports a failure. + */ +export class AssignStaffRole extends Ability { + constructor(private readonly handler: AssignStaffRoleHandler) { + super(); + } + + static using(handler: AssignStaffRoleHandler): AssignStaffRole { + return new AssignStaffRole(handler); + } + + async performAs(actor: Actor, details: AssignStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function assignStaffRoleAbility(): AssignStaffRole { + return AssignStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_USER_ASSIGN_ROLE_MUTATION, { + input: { staffUserId: details.staffUserId, roleId: details.roleId }, + }); + + const mutationResult = response.data['staffUserAssignRole'] as { status: MutationStatus; staffUser: StaffUserResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to assign staff role')); + } + + const staffUser = mutationResult.staffUser; + if (!staffUser) { + throw new Error('API staffUserAssignRole reported success but returned no staff user'); + } + + return staffUser; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts new file mode 100644 index 000000000..92fd52bd1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts @@ -0,0 +1,65 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_ROLE_BY_ID_QUERY, STAFF_ROLE_CREATE_MUTATION, type StaffRoleResult, toPermissionsInput } from '../graphql/staff-role-operations.ts'; + +/** Staff role details accepted by the API creation flow. */ +interface CreateStaffRoleDetails { + roleName: string; + enterpriseAppRole?: string | undefined; + permissions?: Record | undefined; +} + +/** Handler that performs staff role creation for an API actor. */ +type CreateStaffRoleHandler = (actor: Actor, details: CreateStaffRoleDetails) => Promise; + +/** + * Serenity ability that creates staff roles through the API verification server. + * Throws when the mutation reports a validation or authorization failure. + */ +export class CreateStaffRole extends Ability { + constructor(private readonly handler: CreateStaffRoleHandler) { + super(); + } + + static using(handler: CreateStaffRoleHandler): CreateStaffRole { + return new CreateStaffRole(handler); + } + + async performAs(actor: Actor, details: CreateStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function createStaffRoleAbility(): CreateStaffRole { + return CreateStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_ROLE_CREATE_MUTATION, { + input: { + roleName: details.roleName, + enterpriseAppRole: details.enterpriseAppRole ?? null, + permissions: details.permissions ? toPermissionsInput(details.permissions) : null, + }, + }); + + const mutationResult = response.data['staffRoleCreate'] as { status: MutationStatus; staffRole: StaffRoleResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to create staff role')); + } + + const staffRoleId = mutationResult.staffRole?.id; + if (!staffRoleId) { + throw new Error('API staffRoleCreate reported success but returned no staff role id'); + } + + const persistedResponse = await graphql.execute(STAFF_ROLE_BY_ID_QUERY, { id: staffRoleId }); + const persistedRole = persistedResponse.data['staffRoleById'] as StaffRoleResult | null; + if (!persistedRole) { + throw new Error(`Staff role ${staffRoleId} was not found on re-query; API backend did not persist the staff role`); + } + if (persistedRole.roleName !== details.roleName) { + throw new Error(`Re-queried staff role name "${persistedRole.roleName}" does not match created name "${details.roleName}"`); + } + + return persistedRole; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts index 6dd954aea..e3d0e6e31 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts @@ -1,10 +1,12 @@ import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { getActorToken } from './actor-auth.ts'; -export function createGraphQLClientAbility(apiUrl: string): GraphQLClient { +export function createGraphQLClientAbility(apiUrl: string, actorName: string): GraphQLClient { return new GraphQLClient({ apiUrl, - headers: { - Authorization: 'Bearer test-token', + headers: () => { + const token = getActorToken(actorName); + return token === null ? {} : { Authorization: token }; }, }); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index e00d9f743..16b3413bd 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -1,4 +1,7 @@ +export { assignStaffRoleAbility } from './assign-staff-role.ts'; export { createCommunityAbility } from './create-community.ts'; +export { createStaffRoleAbility } from './create-staff-role.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; export { removeMemberAbility } from './remove-member.ts'; export { updateMemberProfileAbility } from './update-member-profile.ts'; +export { updateStaffRoleAbility } from './update-staff-role.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts new file mode 100644 index 000000000..3e7c9ab89 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts @@ -0,0 +1,58 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_ROLE_UPDATE_MUTATION, type StaffRoleResult, toPermissionsInput } from '../graphql/staff-role-operations.ts'; + +/** Staff role details accepted by the API update flow. */ +interface UpdateStaffRoleDetails { + id: string; + roleName: string; + enterpriseAppRole: string; + permissions?: Record | undefined; +} + +/** Handler that performs a staff role update for an API actor. */ +type UpdateStaffRoleHandler = (actor: Actor, details: UpdateStaffRoleDetails) => Promise; + +/** + * Serenity ability that updates staff roles through the API verification server. + * Throws when the mutation reports a validation or authorization failure. + */ +export class UpdateStaffRole extends Ability { + constructor(private readonly handler: UpdateStaffRoleHandler) { + super(); + } + + static using(handler: UpdateStaffRoleHandler): UpdateStaffRole { + return new UpdateStaffRole(handler); + } + + async performAs(actor: Actor, details: UpdateStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function updateStaffRoleAbility(): UpdateStaffRole { + return UpdateStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_ROLE_UPDATE_MUTATION, { + input: { + id: details.id, + roleName: details.roleName, + enterpriseAppRole: details.enterpriseAppRole, + permissions: details.permissions ? toPermissionsInput(details.permissions) : null, + }, + }); + + const mutationResult = response.data['staffRoleUpdate'] as { status: MutationStatus; staffRole: StaffRoleResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to update staff role')); + } + + const staffRole = mutationResult.staffRole; + if (!staffRole) { + throw new Error('API staffRoleUpdate reported success but returned no staff role'); + } + + return staffRole; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts new file mode 100644 index 000000000..38e66c807 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts @@ -0,0 +1,184 @@ +/** GraphQL permission groups exposed on the StaffRole type. */ +type StaffRolePermissionGroupName = 'communityPermissions' | 'financePermissions' | 'staffRolePermissions' | 'techAdminPermissions' | 'userPermissions'; + +/** Staff role shape returned by the staff-role queries and mutations. */ +export interface StaffRoleResult { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + permissions: Record>; +} + +/** Staff user shape returned by the staff-user queries and mutations. */ +export interface StaffUserResult { + id: string; + displayName: string; + externalId: string; + role: { id: string; roleName: string } | null; +} + +/** Status block returned by staff-role and staff-user mutations. */ +export interface MutationStatus { + success: boolean; + errorMessage?: string | null; +} + +/** Maps flat permission command keys to their GraphQL permission group. */ +export const STAFF_ROLE_PERMISSION_GROUP_BY_KEY: Record = { + canManageCommunities: 'communityPermissions', + canManageStaffRolesAndPermissions: 'communityPermissions', + canManageAllCommunities: 'communityPermissions', + canDeleteCommunities: 'communityPermissions', + canChangeCommunityOwner: 'communityPermissions', + canReIndexSearchCollections: 'communityPermissions', + canManageFinance: 'financePermissions', + canViewGLBatchSummaries: 'financePermissions', + canViewFinanceConfigs: 'financePermissions', + canCreateFinanceConfigs: 'financePermissions', + canViewRoles: 'staffRolePermissions', + canAddRole: 'staffRolePermissions', + canEditRole: 'staffRolePermissions', + canRemoveRole: 'staffRolePermissions', + canManageTechAdmin: 'techAdminPermissions', + canViewDatabaseExplorer: 'techAdminPermissions', + canViewBlobExplorer: 'techAdminPermissions', + canViewQueueDashboard: 'techAdminPermissions', + canSendQueueMessages: 'techAdminPermissions', + canManageUsers: 'userPermissions', + canAssignStaffRoles: 'userPermissions', + canViewStaffUsers: 'userPermissions', +}; + +/** Convert flat `key -> boolean` pairs into the grouped StaffRole permissions input shape. */ +export function toPermissionsInput(flatPermissions: Record): Record> { + const input: Record> = {}; + for (const [key, value] of Object.entries(flatPermissions)) { + const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[key]; + if (!group) { + throw new Error(`Unknown staff role permission "${key}"`); + } + input[group] = { ...input[group], [key]: value }; + } + return input; +} + +const STAFF_ROLE_FIELDS = ` + id + roleName + enterpriseAppRole + isDefault + permissions { + communityPermissions { + canManageCommunities + canManageStaffRolesAndPermissions + canManageAllCommunities + canDeleteCommunities + canChangeCommunityOwner + canReIndexSearchCollections + } + financePermissions { + canManageFinance + canViewGLBatchSummaries + canViewFinanceConfigs + canCreateFinanceConfigs + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole + } + techAdminPermissions { + canManageTechAdmin + canViewDatabaseExplorer + canViewBlobExplorer + canViewQueueDashboard + canSendQueueMessages + } + userPermissions { + canManageUsers + canAssignStaffRoles + canViewStaffUsers + } + } + createdAt + updatedAt +`; + +export const STAFF_ROLES_QUERY = ` + query StaffRoles { + staffRoles { + ${STAFF_ROLE_FIELDS} + } + } +`; + +export const STAFF_ROLE_BY_ID_QUERY = ` + query StaffRoleById($id: ObjectID!) { + staffRoleById(id: $id) { + ${STAFF_ROLE_FIELDS} + } + } +`; + +export const STAFF_ROLE_CREATE_MUTATION = ` + mutation StaffRoleCreate($input: StaffRoleCreateInput!) { + staffRoleCreate(input: $input) { + status { + success + errorMessage + } + staffRole { + ${STAFF_ROLE_FIELDS} + } + } + } +`; + +export const STAFF_ROLE_UPDATE_MUTATION = ` + mutation StaffRoleUpdate($input: StaffRoleUpdateInput!) { + staffRoleUpdate(input: $input) { + status { + success + errorMessage + } + staffRole { + ${STAFF_ROLE_FIELDS} + } + } + } +`; + +export const STAFF_USERS_QUERY = ` + query StaffUsers { + staffUsers { + id + displayName + externalId + role { + id + roleName + } + } + } +`; + +export const STAFF_USER_ASSIGN_ROLE_MUTATION = ` + mutation StaffUserAssignRole($input: StaffUserAssignRoleInput!) { + staffUserAssignRole(input: $input) { + status { + success + errorMessage + } + staffUser { + id + displayName + role { + id + roleName + } + } + } + } +`; diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index cba25d6cc..dfa3041fb 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -6,3 +6,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index 86252c0c0..e9bdf07eb 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -4,13 +4,15 @@ import { SerenityCast } from '@cellix/serenity-framework/serenity'; import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; import { infrastructure } from './infrastructure.ts'; import { assignMemberAccountAbility } from './shared/abilities/assign-member-account.ts'; +import { assignStaffRoleAbility } from './shared/abilities/assign-staff-role.ts'; import { createCommunityAbility } from './shared/abilities/create-community.ts'; -import { createMemberAbility } from './shared/abilities/create-member.ts'; +import { createStaffRoleAbility } from './shared/abilities/create-staff-role.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; import { listMembersAbility } from './shared/abilities/list-members.ts'; import { removeMemberAbility } from './shared/abilities/remove-member.ts'; import { updateMemberProfileAbility } from './shared/abilities/update-member-profile.ts'; import { updateMemberRoleAbility } from './shared/abilities/update-member-role.ts'; +import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ infrastructure, @@ -23,9 +25,11 @@ export const CellixApiWorld = registerManagedSerenityWorld({ new SerenityCast({ useNotepad: true, abilities: [ - () => createGraphQLClientAbility(graphqlUrl(state)), + (actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility(), - () => createMemberAbility(), + () => createStaffRoleAbility(), + () => updateStaffRoleAbility(), + () => assignStaffRoleAbility(), () => listMembersAbility(), () => assignMemberAccountAbility(), () => updateMemberProfileAbility(), diff --git a/packages/ocom-verification/acceptance-ui/cucumber.js b/packages/ocom-verification/acceptance-ui/cucumber.js index 90638142e..23810568a 100644 --- a/packages/ocom-verification/acceptance-ui/cucumber.js +++ b/packages/ocom-verification/acceptance-ui/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @api-only and not @e2e-only and not @skip-ui', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], formatOptions: { diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index 9cb328852..7bdbe792b 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -23,6 +23,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-oidc-context": "^3.3.0", + "react-router-dom": "catalog:", "std-env": "^4.0.0" }, "devDependencies": { diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts new file mode 100644 index 000000000..5fd6205b3 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts @@ -0,0 +1,311 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { StaffRoleByIdDocument, StaffRoleCreateDocument, StaffRolesListDocument, StaffRoleUpdateDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; +import type { StaffAuth } from '../../../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; + +/** Business roles supported by the staff portal UI acceptance flows. */ +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +interface MockPermissions { + __typename: 'StaffRolePermissions'; + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions'; + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + }; + userPermissions: { + __typename: 'StaffRoleUserPermissions'; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + }; + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions'; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + }; + financePermissions: { + __typename: 'StaffRoleFinancePermissions'; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + }; + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions'; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; + }; +} + +interface MockStaffRole { + id: string; + roleName: string; + enterpriseAppRole: string; + createdAt: string; + updatedAt: string; + permissions: MockPermissions; +} + +/** Outcome of the last mocked staff-role mutation. */ +export interface MockMutationResult { + success: boolean; + errorMessage?: string | null; +} + +const emptyPermissions = (): MockPermissions => ({ + __typename: 'StaffRolePermissions', + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions', + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + __typename: 'StaffRoleUserPermissions', + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions', + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + financePermissions: { + __typename: 'StaffRoleFinancePermissions', + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions', + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, +}); + +const staffAuthByRole: Record = { + 'tech admin': { + name: 'Tech Admin Staff', + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + canManageStaffRolesAndPermissions: true, + canManageTechAdmin: true, + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }, + }, + 'service line owner': { name: 'Service Line Owner Staff', enterpriseAppRole: 'Staff.ServiceLineOwner', permissions: {} }, + finance: { name: 'Finance Staff', enterpriseAppRole: 'Staff.Finance', permissions: { canManageFinance: true } }, + 'case manager': { name: 'Case Manager Staff', enterpriseAppRole: 'Staff.CaseManager', permissions: {} }, +}; + +// Mock backend state shared by the dynamic Apollo mocks below. +let uiRoles: MockStaffRole[] = []; +let currentAuthRole: StaffBusinessRole = 'tech admin'; +// When set, overrides the business-role auth with fine-grained permissions. +let currentCustomAuth: StaffAuth | undefined; +let lastMutation: MockMutationResult | undefined; +let currentPath = '/'; +let idCounter = 0; + +const nextRoleId = (): string => { + idCounter += 1; + return idCounter.toString(16).padStart(24, '0'); +}; + +const addRole = (roleName: string, enterpriseAppRole: string): MockStaffRole => { + const now = new Date().toISOString(); + const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; + uiRoles.push(role); + return role; +}; + +/** + * Resets the mocked staff-role backend for a new scenario and records which + * business role the acting staff user holds. Called from the shared staff + * authentication Givens in the staff context. + */ +export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { + uiRoles = []; + lastMutation = undefined; + currentPath = '/'; + currentAuthRole = businessRole; + currentCustomAuth = undefined; + for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { + addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); + } +} + +/** Overrides the business-role auth with a custom fine-grained StaffAuth. */ +export function setScopedStaffAuth(staffAuth: StaffAuth): void { + currentCustomAuth = staffAuth; +} + +/** The StaffAuth the rendered staff-role screens should observe. */ +export function currentStaffAuth(): StaffAuth { + return currentCustomAuth ?? staffAuthByRole[currentAuthRole]; +} + +/** Adds a staff role to the mocked backend when it is not present yet. */ +export function ensureMockStaffRole(roleName: string, enterpriseAppRole: string = DEFAULT_ENTERPRISE_APP_ROLE): void { + if (!uiRoles.some((candidate) => candidate.roleName === roleName)) { + addRole(roleName, enterpriseAppRole); + } +} + +/** Finds a staff role in the mocked backend by role name. */ +export function findMockStaffRoleByName(roleName: string): { id: string; roleName: string } | undefined { + return uiRoles.find((candidate) => candidate.roleName === roleName); +} + +/** Number of staff roles currently held by the mocked backend. */ +export function mockStaffRoleCount(): number { + return uiRoles.length; +} + +/** Outcome of the last mocked staff-role mutation, if any was performed. */ +export function lastStaffRoleMutation(): MockMutationResult | undefined { + return lastMutation; +} + +/** Records the router path the rendered screen navigated to. */ +export function recordCurrentMockPath(path: string): void { + currentPath = path; +} + +/** The router path last recorded by the rendered screen. */ +export function currentMockPath(): string { + return currentPath; +} + +const toListFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + createdAt: role.createdAt, + updatedAt: role.updatedAt, +}); + +const toEditFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: role.permissions, +}); + +interface StaffRoleMutationInput { + id?: string; + roleName?: string; + enterpriseAppRole?: string | null; +} + +/** Builds the dynamic Apollo mocks backing the staff-role screens. */ +export const buildStaffRoleMocks = (): MockedResponse[] => [ + { + request: { query: StaffRolesListDocument }, + maxUsageCount: Number.POSITIVE_INFINITY, + result: () => ({ data: { staffRoles: uiRoles.map(toListFields) } }), + }, + { + request: { query: StaffRoleByIdDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { id?: string }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.id); + return { data: { staffRoleById: role ? toEditFields(role) : null } }; + }, + }, + { + request: { query: StaffRoleCreateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const roleName = variables.input?.roleName ?? ''; + if (uiRoles.some((candidate) => candidate.roleName === roleName)) { + lastMutation = { success: false, errorMessage: `Staff role with name ${roleName} already exists` }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + const role = addRole(roleName, variables.input?.enterpriseAppRole ?? ''); + lastMutation = { success: true }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toListFields(role), + }, + }, + }; + }, + }, + { + request: { query: StaffRoleUpdateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.input?.id); + if (!role) { + lastMutation = { success: false, errorMessage: 'Staff role not found' }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + role.roleName = variables.input?.roleName ?? role.roleName; + role.enterpriseAppRole = variables.input?.enterpriseAppRole ?? role.enterpriseAppRole; + role.updatedAt = new Date().toISOString(); + lastMutation = { success: true }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toEditFields(role), + }, + }, + }; + }, + }, +]; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..d34359742 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,4 @@ +/** Scenario-local staff-role UI state shared between tasks and questions via actor notes. */ +export interface StaffRoleUiNotes { + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts new file mode 100644 index 000000000..0e39e16da --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts @@ -0,0 +1,19 @@ +import { notes, Question } from '@serenity-js/core'; +import { lastStaffRoleMutation, type MockMutationResult, mockStaffRoleCount } from '../abilities/mock-staff-role-backend.ts'; +import type { StaffRoleUiNotes } from '../notes/staff-role-notes.ts'; + +/** Question that reads the outcome of the last mocked staff-role mutation. */ +export const LastStaffRoleMutation = (): Question> => Question.about('the last staff role mutation outcome', async () => lastStaffRoleMutation()); + +/** Question that reads the current staff role count from the mocked backend. */ +export const MockedStaffRoleCount = (): Question> => Question.about('the mocked staff role count', async () => mockStaffRoleCount()); + +/** Question that reads the staff role count captured before a create attempt. */ +export const BaselineStaffRoleCount = () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await actor.answer(notes().get('baselineStaffRoleCount')); + } catch { + return undefined; + } + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts new file mode 100644 index 000000000..2f62fa0a0 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts @@ -0,0 +1,76 @@ +import { Question } from '@serenity-js/core'; +import { feedbackPage, formPageFor, listPageFor, waitUntilUi } from '../tasks/staff-roles-screen.ts'; + +/** Question that reads the staff role names visible in the rendered list. */ +export const ListedStaffRoleNames = () => Question.about('the listed staff role names', async (actor) => await listPageFor(actor).listedRoleNames()); + +/** Question that waits for a staff role to appear in the rendered list. */ +export const StaffRolesListIncludes = (roleName: string) => + Question.about(`whether the staff roles list includes "${roleName}"`, async (actor) => { + const listPage = listPageFor(actor); + try { + await waitUntilUi(() => listPage.hasRoleNamed(roleName), `Expected the staff roles list to include "${roleName}"`); + return true; + } catch { + return false; + } + }); + +/** Question that reads the role name shown by the staff role form. */ +export const FormRoleNameValue = () => Question.about('the staff role form role name', async (actor) => await formPageFor(actor).roleNameValue()); + +/** Question that reads the enterprise app role selected in the staff role form. */ +export const FormEnterpriseAppRole = () => Question.about('the staff role form enterprise app role', async (actor) => await formPageFor(actor).selectedEnterpriseAppRole()); + +/** Question that waits for a success message after a staff-role mutation. */ +export const SuccessFeedbackVisible = () => + Question.about('whether a staff role success message is visible', async () => { + try { + await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message'); + return true; + } catch { + return false; + } + }); + +/** Question that waits for an error message containing the given fragment. */ +export const ErrorFeedbackContaining = (expectedFragment: string) => + Question.about(`whether a staff role error containing "${expectedFragment}" is visible`, async () => { + try { + await waitUntilUi(async () => { + const errorFeedback = feedbackPage().errorFeedback; + if (!(await errorFeedback.isVisible())) { + return false; + } + const text = (await errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); + return true; + } catch { + return false; + } + }); + +/** Question that waits for a form validation error matching the given pattern. */ +export const ValidationErrorMatching = (expectedPattern: RegExp) => + Question.about(`whether a validation error matching ${expectedPattern} is visible`, async (actor) => { + const formPage = formPageFor(actor); + try { + await waitUntilUi(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error matching ${expectedPattern}`); + return true; + } catch { + return false; + } + }); + +/** Question that answers whether the create staff role action is visible. */ +export const CreateRoleActionVisible = () => Question.about('whether the create staff role action is visible', async (actor) => await listPageFor(actor).createRoleButton.isVisible()); + +/** Question that answers whether any staff role edit action is visible. */ +export const AnyEditActionVisible = () => Question.about('whether any staff role edit action is visible', async (actor) => await listPageFor(actor).hasAnyEditAction()); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..eaa3bb526 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-role-management.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx new file mode 100644 index 000000000..14744c8a1 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -0,0 +1,159 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { ensureMockStaffRole, resetStaffRoleUiState, setScopedStaffAuth } from '../abilities/mock-staff-role-backend.ts'; +import { BaselineStaffRoleCount, LastStaffRoleMutation, MockedStaffRoleCount } from '../questions/staff-role-outcome.ts'; +import { + AnyEditActionVisible, + CreateRoleActionVisible, + ErrorFeedbackContaining, + FormEnterpriseAppRole, + FormRoleNameValue, + ListedStaffRoleNames, + StaffRolesListIncludes, + SuccessFeedbackVisible, + ValidationErrorMatching, +} from '../questions/staff-role-screen.ts'; +import { CreateStaffRoleViaForm, RenameStaffRoleViaForm, type StaffRoleFormInput } from '../tasks/create-staff-role.ts'; +import { OpenStaffRoleEditScreenRecordingRoute, OpenStaffRolesScreenRecordingRoute } from '../tasks/open-staff-role-screens.ts'; +import { OpenEditFormForRole, OpenStaffRolesList } from '../tasks/staff-roles-screen.ts'; + +const STAFF_ROLE_PERMISSION_FLAGS = ['canViewRoles', 'canAddRole', 'canEditRole', 'canRemoveRole'] as const; +type StaffRolePermissionFlag = (typeof STAFF_ROLE_PERMISSION_FLAGS)[number]; + +Given('{word} is an authenticated staff user with only the {string} role permission', (actorName: string, permissionFlag: string) => { + if (!STAFF_ROLE_PERMISSION_FLAGS.includes(permissionFlag as StaffRolePermissionFlag)) { + throw new Error(`Unsupported staff role permission "${permissionFlag}". Expected one of: ${STAFF_ROLE_PERMISSION_FLAGS.join(', ')}`); + } + resetStaffRoleUiState('case manager'); + setScopedStaffAuth({ + name: 'Scoped Staff', + enterpriseAppRole: 'Staff.CaseManager', + permissions: { [permissionFlag]: true }, + }); + actorCalled(actorName); +}); + +Given('a staff role named {string} exists', (roleName: string) => { + ensureMockStaffRole(roleName); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRolesList()); +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(OpenEditFormForRole(roleName)); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(details)); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(details)); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + await actorCalled(actorName).attemptsTo(RenameStaffRoleViaForm(currentName, newName)); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingRoute()); +}); + +When('{word} opens the edit screen for the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRoleEditScreenRecordingRoute(roleName)); +}); + +Then('the staff roles list should include the default staff roles', async () => { + const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames()); + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('{word} should see the staff role name {string}', async (_actorName: string, expectedName: string) => { + const value = await actorInTheSpotlight().answer(FormRoleNameValue()); + if (value !== expectedName) { + throw new Error(`Expected the staff role form to show role name "${expectedName}", but got "${value}"`); + } +}); + +Then('{word} should see the enterprise app role {string}', async (_actorName: string, expectedRole: string) => { + const value = await actorInTheSpotlight().answer(FormEnterpriseAppRole()); + if (value !== expectedRole) { + throw new Error(`Expected the staff role form to show enterprise app role "${expectedRole}", but got "${value}"`); + } +}); + +Then('the staff role should be created successfully', async () => { + const actor = actorInTheSpotlight(); + if (!(await actor.answer(SuccessFeedbackVisible()))) { + throw new Error('Expected a success message after creating the staff role'); + } + const mutation = await actor.answer(LastStaffRoleMutation()); + const baselineCount = await actor.answer(BaselineStaffRoleCount()); + const currentCount = await actor.answer(MockedStaffRoleCount()); + if (mutation?.success !== true || baselineCount === undefined || currentCount !== baselineCount + 1) { + throw new Error('Expected the staff role create mutation to succeed and persist exactly one new role'); + } +}); + +Then('the staff role should be updated successfully', async () => { + const actor = actorInTheSpotlight(); + if (!(await actor.answer(SuccessFeedbackVisible()))) { + throw new Error('Expected a success message after updating the staff role'); + } + const mutation = await actor.answer(LastStaffRoleMutation()); + if (mutation?.success !== true) { + throw new Error('Expected the staff role update mutation to succeed'); + } +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + if (!(await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)))) { + throw new Error(`Expected the staff roles list to include "${roleName}"`); + } +}); + +Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { + if (!(await actorInTheSpotlight().answer(ErrorFeedbackContaining(expectedFragment)))) { + throw new Error(`Expected a staff role error message containing "${expectedFragment}"`); + } +}); + +Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { + const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + if (!(await actorInTheSpotlight().answer(ValidationErrorMatching(expectedPattern)))) { + throw new Error(`Expected a validation error for "${fieldName}"`); + } +}); + +Then('{word} should not see an option to create a staff role', async (_actorName: string) => { + if (await actorInTheSpotlight().answer(CreateRoleActionVisible())) { + throw new Error('Expected the create staff role action to be hidden, but it is visible'); + } +}); + +Then('{word} should not see an option to edit a staff role', async (_actorName: string) => { + if (await actorInTheSpotlight().answer(AnyEditActionVisible())) { + throw new Error('Expected no edit action to be rendered for any staff role, but at least one is visible'); + } +}); + +Then('no additional staff role should be created', async () => { + const actor = actorInTheSpotlight(); + const baselineCount = await actor.answer(BaselineStaffRoleCount()); + if (baselineCount === undefined) { + throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); + } + const currentCount = await actor.answer(MockedStaffRoleCount()); + if (currentCount !== baselineCount) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baselineCount} to ${currentCount}`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..0736faf3a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,53 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { mockStaffRoleCount } from '../abilities/mock-staff-role-backend.ts'; +import type { StaffRoleUiNotes } from '../notes/staff-role-notes.ts'; +import { flushUi, formPageFor, listPageFor, OpenEditFormForRole, OpenStaffRolesList, waitUntilUi } from './staff-roles-screen.ts'; + +/** Details captured from the create staff role form Gherkin table. */ +export interface StaffRoleFormInput { + roleName?: string; + enterpriseAppRole?: string; +} + +/** + * Task that opens the create staff role form, fills it, and submits it, + * recording the baseline role count in actor notes for negative-path checks. + */ +export const CreateStaffRoleViaForm = (details: StaffRoleFormInput): Task => + Task.where( + `#actor creates a staff role named "${details.roleName ?? ''}"`, + new TaskStep('#actor fills and submits the create staff role form', async (actor) => { + await actor.attemptsTo(notes().set('baselineStaffRoleCount', mockStaffRoleCount())); + await actor.attemptsTo(OpenStaffRolesList()); + + const listPage = listPageFor(actor); + await listPage.clickCreateRole(); + + const formPage = formPageFor(actor); + await waitUntilUi(() => formPage.roleNameInput.isVisible(), 'Expected the create staff role form to render'); + if (details.roleName) { + await formPage.fillRoleName(details.roleName); + } + if (details.enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(details.enterpriseAppRole); + } + await formPage.clickSubmit(); + await flushUi(); + }), + ); + +/** + * Task that renames a staff role through the edit form. + */ +export const RenameStaffRoleViaForm = (currentName: string, newName: string): Task => + Task.where( + `#actor renames the staff role "${currentName}" to "${newName}"`, + new TaskStep('#actor updates the role name and submits the edit form', async (actor) => { + await actor.attemptsTo(OpenEditFormForRole(currentName)); + const formPage = formPageFor(actor); + await formPage.fillRoleName(newName); + await formPage.clickSubmit(); + await flushUi(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts new file mode 100644 index 000000000..2d2661c97 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts @@ -0,0 +1,37 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUiNotes } from '../../staff/abilities/staff-types.ts'; +import { currentMockPath, findMockStaffRoleByName } from '../abilities/mock-staff-role-backend.ts'; +import { flushUi, RenderStaffRolesScreen } from './staff-roles-screen.ts'; + +/** + * Task that opens the staff roles screen and records the route the router + * settled on, so authorization redirects can be asserted. + */ +export const OpenStaffRolesScreenRecordingRoute = (): Task => + Task.where( + '#actor opens the staff roles screen', + new TaskStep('#actor renders the staff roles screen and records the route', async (actor) => { + await actor.attemptsTo(RenderStaffRolesScreen()); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentMockPath())); + }), + ); + +/** + * Task that opens the edit screen for a staff role directly by route and + * records the route the router settled on. + */ +export const OpenStaffRoleEditScreenRecordingRoute = (roleName: string): Task => + Task.where( + `#actor opens the edit screen for the staff role "${roleName}"`, + new TaskStep('#actor renders the edit screen and records the route', async (actor) => { + const role = findMockStaffRoleByName(roleName); + if (!role) { + throw new Error(`Staff role "${roleName}" is not present in the mocked backend`); + } + await actor.attemptsTo(RenderStaffRolesScreen([`/edit/${role.id}`])); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentMockPath())); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts new file mode 100644 index 000000000..eaa99db41 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts @@ -0,0 +1,86 @@ +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 { StaffRoleFormPage, StaffRolesListPage } 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 { StaffRolesPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import { buildStaffRoleMocks, currentStaffAuth, recordCurrentMockPath } from '../abilities/mock-staff-role-backend.ts'; + +/** Let pending React work (async form handlers, Apollo mocks) settle. */ +export async function flushUi(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +/** Poll a DOM condition, flushing React work between attempts. */ +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); +} + +/** Staff roles list page object scoped to the actor's rendered container. */ +export const listPageFor = (actor: UsesAbilities): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +/** Staff role form page object scoped to the actor's rendered container. */ +export const formPageFor = (actor: UsesAbilities): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +/** antd messages portal to document.body, outside the rendered container. */ +export const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); + +/** Reports the router path the rendered screen navigated to. */ +const LocationProbe: React.FC = () => { + const location = useLocation(); + recordCurrentMockPath(location.pathname); + return null; +}; + +/** + * Task that renders the staff roles screen at the given router entries with + * the mocked staff-role backend and the current staff auth. + */ +export const RenderStaffRolesScreen = (initialEntries: string[] = ['/']): Task => + Task.where( + '#actor opens the staff roles screen', + new TaskStep('#actor renders the staff roles page', async (actor) => { + await actor.attemptsTo( + Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffRolesPage)), { + wrapper: wrapOcomComponent({ mocks: buildStaffRoleMocks(), initialEntries, staffAuth: currentStaffAuth() }), + }), + ); + await flushUi(); + }), + ); + +/** Task that renders the staff roles list and waits for its rows to appear. */ +export const OpenStaffRolesList = (): Task => + Task.where( + '#actor views the staff roles list', + new TaskStep('#actor waits for the staff roles list rows', async (actor) => { + await actor.attemptsTo(RenderStaffRolesScreen()); + const listPage = listPageFor(actor); + await waitUntilUi(async () => (await listPage.listedRoleNames()).length > 0, 'Expected the staff roles list to render rows'); + }), + ); + +/** Task that opens the edit form for a staff role and waits for it to load. */ +export const OpenEditFormForRole = (roleName: string): Task => + Task.where( + `#actor opens the edit form for the staff role "${roleName}"`, + new TaskStep(`#actor navigates to the edit form of "${roleName}"`, async (actor) => { + await actor.attemptsTo(OpenStaffRolesList()); + const listPage = listPageFor(actor); + await listPage.clickEditForRole(roleName); + const formPage = formPageFor(actor); + await waitUntilUi(async () => (await formPage.roleNameValue()) === roleName, `Expected the edit form to load the staff role "${roleName}"`); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts index f50c14577..a410e81df 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -1,6 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { resetStaffRoleUiState } from '../../staff-role/abilities/mock-staff-role-backend.ts'; import type { StaffUiNotes } from '../abilities/staff-types.ts'; import { StaffTargetRoute } from '../questions/staff-target-route.ts'; import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; @@ -36,6 +37,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); + resetStaffRoleUiState('case manager'); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -44,6 +46,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + resetStaffRoleUiState(role); await actor.attemptsTo(notes().set('targetRoute', '')); }); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts index 10b34268b..c4f1ffb9c 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts @@ -2,12 +2,36 @@ import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; import { HelmetProvider } from '@dr.pogodin/react-helmet'; import { App, ConfigProvider } from 'antd'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { type StaffAuth, StaffAuthContext } from '../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; interface OcomComponentWrapperOptions { mocks?: MockedResponse[]; + /** When provided, wraps children in a MemoryRouter with these initial entries. */ + initialEntries?: string[]; + /** When provided, wraps children in a StaffAuthContext provider. */ + staffAuth?: StaffAuth; } export function wrapOcomComponent(options?: OcomComponentWrapperOptions) { - return (children: React.ReactElement): React.ReactElement => - React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, children)))); + return (children: React.ReactElement): React.ReactElement => { + let content: React.ReactElement = children; + if (options?.initialEntries) { + content = React.createElement(MemoryRouter, { initialEntries: options.initialEntries }, content); + } + if (options?.staffAuth) { + content = React.createElement(StaffAuthContext.Provider, { value: options.staffAuth }, content); + } + return React.createElement( + HelmetProvider, + null, + React.createElement( + ConfigProvider, + // Keep antd popups (Select dropdowns, tooltips) inside the rendered + // container so scoped DOM page adapters can reach them. + { getPopupContainer: (trigger?: HTMLElement) => trigger?.parentElement ?? document.body }, + React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, content)), + ), + ); + }; } diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 585f6f459..dd9eee6f7 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -5,4 +5,5 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 5966e2dba..3dc0da163 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -6,6 +6,10 @@ "composite": false, "incremental": false, "skipLibCheck": true, + // The UI packages compiled into this project (via the include globs below, + // which tsx also uses to apply the react-jsx transform at runtime) typecheck + // themselves with exactOptionalPropertyTypes disabled. + "exactOptionalPropertyTypes": false, "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "noEmit": true, @@ -13,5 +17,16 @@ "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", + "../../cellix/serenity-framework/src/dom/css-types.d.ts", + "../../ocom/ui-community-route-root/src/**/*.tsx", + "../../ocom/ui-staff-route-root/src/**/*.tsx", + "../../ocom/ui-staff-route-user-management/src/**/*.tsx", + "../../ocom/ui-staff-shared/src/**/*.tsx", + "../../ocom/ui-shared/src/**/*.tsx" + ], + "exclude": ["../../ocom/**/*.stories.tsx", "../../ocom/**/*.test.ts", "../../ocom/**/*.test.tsx"] } diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index 38c29160d..c963d09f8 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @api-only and not @ui-only and not @skip-e2e', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], formatOptions: { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts new file mode 100644 index 000000000..08cd1ca12 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts @@ -0,0 +1,64 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { StaffRoleFormPage, StaffRolesListPage } 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 ROLES_LIST_PATH = '/staff/user-management/staff-roles'; + +/** Poll a browser condition until it holds or the timeout elapses. */ +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); +}; + +/** Resolve the authenticated staff-portal page for the actor's business role. */ +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); +}; + +/** Staff roles list page object bound to the given browser page. */ +export const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); + +/** Staff role form page object bound to the given browser page. */ +export const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); + +export const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); + +/** Navigate to the staff roles list and wait for the table rows to render. */ +export const openRolesList = async (page: Page): Promise => { + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = listPageOn(page); + await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); + return listPage; +}; + +/** Open the edit form for a role and wait for its values to load. */ +export const openEditFormFor = async (page: Page, roleName: string): Promise => { + const listPage = await openRolesList(page); + await listPage.clickEditForRole(roleName); + await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); + return formPage; +}; + +/** Wait for the app to navigate back to the staff roles list. */ +export const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { + await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { + throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); + }); +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts new file mode 100644 index 000000000..8e92518a4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts @@ -0,0 +1,30 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that fills the staff role form fields currently on + * screen. Only the provided fields are touched, so it works for both the + * create form and the edit form. + */ +export const FillStaffRoleForm = (fields: Record) => + Interaction.where(the`#actor fills the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + const formPage = formPageOn(page); + if (fields['roleName'] !== undefined) { + await formPage.fillRoleName(fields['roleName']); + } + const enterpriseAppRole = fields['enterpriseAppRole']; + if (enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(enterpriseAppRole); + } + }); + +/** + * Low-level interaction that grants a single permission on the staff role + * form currently on screen. + */ +export const GrantFormPermission = (permissionKey: string) => + Interaction.where(the`#actor grants the "${permissionKey}" permission on the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).grantPermission(permissionKey); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts new file mode 100644 index 000000000..17c9f60d2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts @@ -0,0 +1,14 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, listPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that opens the create staff role form from the roles + * list and waits for the form to be ready. Expects the roles list to be open. + */ +export const OpenCreateStaffRoleForm = () => + Interaction.where(the`#actor opens the create staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await listPageOn(page).clickCreateRole(); + await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); + await formPageOn(page).roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts new file mode 100644 index 000000000..fb9324983 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts @@ -0,0 +1,12 @@ +import { Interaction, the } from '@serenity-js/core'; +import { openEditFormFor, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that opens the edit form for a staff role from the + * roles list and waits for its values to load. + */ +export const OpenEditStaffRoleForm = (roleName: string) => + Interaction.where(the`#actor opens the edit form for the staff role "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor); + await openEditFormFor(page, roleName); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts new file mode 100644 index 000000000..1c1142f18 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts @@ -0,0 +1,12 @@ +import { Interaction, the } from '@serenity-js/core'; +import { openRolesList, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that navigates the actor's staff portal to the staff + * roles list and waits for the table rows to render. + */ +export const OpenStaffRolesList = () => + Interaction.where(the`#actor opens the staff roles list`, async (actor) => { + const page = await staffPortalPageOf(actor); + await openRolesList(page); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts new file mode 100644 index 000000000..3f3f12804 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts @@ -0,0 +1,27 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { listPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; + +/** + * Low-level interaction that reads the currently listed staff role names and + * records them in actor notes for later assertions. Expects the roles list to + * be open (see `OpenStaffRolesList`). + */ +export const RecordListedStaffRoleNames = () => + Interaction.where(the`#actor records the listed staff role names`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await actor.attemptsTo(notes().set('listedStaffRoleNames', await listPageOn(page).listedRoleNames())); + }); + +/** + * Low-level interaction that records the current staff role count in actor + * notes as the baseline for negative-path assertions. Expects the roles list + * to be open (see `OpenStaffRolesList`). + */ +export const RecordBaselineStaffRoleCount = () => + Interaction.where(the`#actor records the baseline staff role count`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await actor.attemptsTo(notes().set('baselineStaffRoleCount', (await listPageOn(page).listedRoleNames()).length)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts new file mode 100644 index 000000000..5c1f14b70 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts @@ -0,0 +1,11 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that submits the staff role form currently on screen. + */ +export const SubmitStaffRoleForm = () => + Interaction.where(the`#actor submits the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).clickSubmit(); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..5bf97d7e3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,5 @@ +/** Scenario-local staff-role E2E state shared between tasks and questions via actor notes. */ +export interface StaffRoleE2ENotes { + listedStaffRoleNames: string[]; + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts new file mode 100644 index 000000000..3b179f93f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts @@ -0,0 +1,87 @@ +import { type Actor, notes, Question } from '@serenity-js/core'; +import { formPageOn, openEditFormFor, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../abilities/staff-portal-page.ts'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; + +/** Staff role names recorded when the actor last viewed the roles list. */ +export const RecordedStaffRoleNames = () => + Question.about('the staff role names recorded from the roles list', async (actor) => { + try { + return await (actor as unknown as Actor).answer(notes().get('listedStaffRoleNames')); + } catch { + throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); + } + }); + +/** Waits until the live roles list includes the given role name. */ +export const StaffRolesListIncludes = (roleName: string) => + Question.about(`whether the staff roles list includes "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const listPage = await openRolesList(page); + await waitUntil(async () => await listPage.hasRoleNamed(roleName), `Expected staff roles list to include "${roleName}", but got: [${(await listPage.listedRoleNames()).join(', ')}]`); + return true; + }); + +/** Waits for the app to navigate back to the staff roles list after a mutation. */ +export const ReturnedToStaffRolesList = (failureMessage: string) => + Question.about('whether the app returned to the staff roles list', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + await waitForReturnToRolesList(page, failureMessage); + return true; + }); + +/** Whether the edit form for a role shows the given permission as granted. */ +export const StaffRolePermissionGranted = (roleName: string, permissionKey: string) => + Question.about(`whether the staff role "${roleName}" has the permission "${permissionKey}" granted`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = await openEditFormFor(page, roleName); + return await formPage.isPermissionGranted(permissionKey); + }); + +/** Waits for an error feedback message containing the given fragment. */ +export const StaffRoleErrorContaining = (expectedFragment: string) => + Question.about(`whether a staff role error containing "${expectedFragment}" is shown`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = formPageOn(page); + await waitUntil(async () => { + if (!(await formPage.errorFeedback.isVisible())) { + return false; + } + const text = (await formPage.errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); + return true; + }); + +/** Waits for a validation error matching the given field name. */ +export const StaffRoleValidationErrorFor = (fieldName: string) => + Question.about(`whether a staff role validation error for "${fieldName}" is shown`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = formPageOn(page); + const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + await waitUntil(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error for "${fieldName}"`); + return true; + }); + +/** Current number of staff roles shown in the live roles list. */ +export const CurrentStaffRoleCount = () => + Question.about('the current staff role count', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const listPage = await openRolesList(page); + return (await listPage.listedRoleNames()).length; + }); + +/** Baseline staff role count recorded before an attempted creation. */ +export const BaselineStaffRoleCount = () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await (actor as unknown as Actor).answer(notes().get('baselineStaffRoleCount')); + } catch { + throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); + } + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..d69e05efd --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-role-management.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts new file mode 100644 index 000000000..57bc833d3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,107 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { AfterAll, type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { closeStaffPortalSessions } from '../../../shared/abilities/staff-portal-session.ts'; +import { + BaselineStaffRoleCount, + CurrentStaffRoleCount, + RecordedStaffRoleNames, + ReturnedToStaffRolesList, + StaffRoleErrorContaining, + StaffRolePermissionGranted, + StaffRolesListIncludes, + StaffRoleValidationErrorFor, +} from '../questions/staff-role-screen.ts'; +import { CreateStaffRoleViaForm, CreateStaffRoleWithPermissions } from '../tasks/create-staff-role.ts'; +import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; +import { GrantStaffRolePermissionViaForm } from '../tasks/grant-staff-role-permission.ts'; +import { OpenStaffRolesScreenRecordingPath } from '../tasks/open-staff-roles-screen.ts'; +import { RenameStaffRoleViaForm } from '../tasks/rename-staff-role.ts'; +import { ViewStaffRolesList } from '../tasks/view-staff-roles-list.ts'; + +Given('a staff role named {string} exists', async (roleName: string) => { + await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists(roleName)); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRolesList()); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + const fields = GherkinDataTable.from(dataTable).rowsHash>(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(fields)); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + const fields = GherkinDataTable.from(dataTable).rowsHash>(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(fields)); +}); + +When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { + const grants = GherkinDataTable.from(dataTable).rowsHash>(); + const permissionKeys = Object.entries(grants) + .filter(([, value]) => value === 'true') + .map(([permissionKey]) => permissionKey); + await actorCalled(actorName).attemptsTo(CreateStaffRoleWithPermissions(roleName, permissionKeys)); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + await actorCalled(actorName).attemptsTo(RenameStaffRoleViaForm(currentName, newName)); +}); + +When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(GrantStaffRolePermissionViaForm(permissionKey, roleName)); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingPath()); +}); + +Then('the staff roles list should include the default staff roles', async () => { + const listed = await actorInTheSpotlight().answer(RecordedStaffRoleNames()); + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('the staff role should be created successfully', async () => { + await actorInTheSpotlight().answer(ReturnedToStaffRolesList('Expected the staff role to be created and the app to return to the roles list')); +}); + +Then('the staff role should be updated successfully', async () => { + await actorInTheSpotlight().answer(ReturnedToStaffRolesList('Expected the staff role to be updated and the app to return to the roles list')); +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)); +}); + +Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { + const granted = await actorInTheSpotlight().answer(StaffRolePermissionGranted(roleName, permissionKey)); + if (!granted) { + throw new Error(`Expected staff role "${roleName}" to have the permission "${permissionKey}" granted`); + } +}); + +Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { + await actorInTheSpotlight().answer(StaffRoleErrorContaining(expectedFragment)); +}); + +Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { + await actorInTheSpotlight().answer(StaffRoleValidationErrorFor(fieldName)); +}); + +Then('no additional staff role should be created', async () => { + const actor = actorInTheSpotlight(); + const baseline = await actor.answer(BaselineStaffRoleCount()); + const currentCount = await actor.answer(CurrentStaffRoleCount()); + if (currentCount !== baseline) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baseline} to ${currentCount}`); + } +}); + +AfterAll(async () => { + await closeStaffPortalSessions(); +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..1b38a84e4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,35 @@ +import { Task, the } from '@serenity-js/core'; +import { FillStaffRoleForm, GrantFormPermission } from '../interactions/fill-staff-role-form.ts'; +import { OpenCreateStaffRoleForm } from '../interactions/open-create-staff-role-form.ts'; +import { OpenStaffRolesList } from '../interactions/open-staff-roles-list.ts'; +import { RecordBaselineStaffRoleCount } from '../interactions/record-staff-role-notes.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +export const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +/** + * Task that opens the create staff role form, fills it, and submits it, + * recording the baseline role count in actor notes for negative-path checks. + */ +export const CreateStaffRoleViaForm = (fields: Record) => + Task.where( + the`#actor creates a staff role named "${fields['roleName'] ?? ''}" via the staff portal`, + OpenStaffRolesList(), + RecordBaselineStaffRoleCount(), + OpenCreateStaffRoleForm(), + FillStaffRoleForm(fields), + SubmitStaffRoleForm(), + ); + +/** + * Task that creates a staff role and grants the given permissions before submitting. + */ +export const CreateStaffRoleWithPermissions = (roleName: string, permissionKeys: string[]) => + Task.where( + the`#actor creates a staff role named "${roleName}" with permissions via the staff portal`, + OpenStaffRolesList(), + OpenCreateStaffRoleForm(), + FillStaffRoleForm({ roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }), + ...permissionKeys.map((permissionKey) => GrantFormPermission(permissionKey)), + SubmitStaffRoleForm(), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts new file mode 100644 index 000000000..6e1e79b0f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts @@ -0,0 +1,24 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { listPageOn, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../abilities/staff-portal-page.ts'; +import { FillStaffRoleForm } from '../interactions/fill-staff-role-form.ts'; +import { OpenCreateStaffRoleForm } from '../interactions/open-create-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; +import { DEFAULT_ENTERPRISE_APP_ROLE } from './create-staff-role.ts'; + +/** + * Given-glue task that makes sure a staff role exists, creating it through the + * staff portal UI when absent. Conditional, so it is modelled as a single + * interaction that composes the create-form interactions only when needed. + */ +export const EnsureStaffRoleExists = (roleName: string) => + Interaction.where(the`#actor makes sure a staff role named "${roleName}" exists`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const listPage = await openRolesList(page); + if (await listPage.hasRoleNamed(roleName)) { + return; + } + await actor.attemptsTo(OpenCreateStaffRoleForm(), FillStaffRoleForm({ roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }), SubmitStaffRoleForm()); + await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); + await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts new file mode 100644 index 000000000..54ec76c5b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts @@ -0,0 +1,10 @@ +import { Task, the } from '@serenity-js/core'; +import { GrantFormPermission } from '../interactions/fill-staff-role-form.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +/** + * Task that grants a permission to a staff role through the edit form. + */ +export const GrantStaffRolePermissionViaForm = (permissionKey: string, roleName: string) => + Task.where(the`#actor grants the permission "${permissionKey}" to the staff role "${roleName}" via the staff portal`, OpenEditStaffRoleForm(roleName), GrantFormPermission(permissionKey), SubmitStaffRoleForm()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts new file mode 100644 index 000000000..28621b68b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts @@ -0,0 +1,16 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; +import { isAtRolesList, ROLES_LIST_PATH, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Task that navigates to the staff roles screen and records where the router + * settled, so authorization redirects can be asserted. + */ +export const OpenStaffRolesScreenRecordingPath = () => + Interaction.where(the`#actor opens the staff roles screen`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); + await actor.attemptsTo(notes().set('currentPath', new URL(page.url()).pathname)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts new file mode 100644 index 000000000..4399e2c27 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts @@ -0,0 +1,10 @@ +import { Task, the } from '@serenity-js/core'; +import { FillStaffRoleForm } from '../interactions/fill-staff-role-form.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +/** + * Task that renames a staff role through the edit form. + */ +export const RenameStaffRoleViaForm = (currentName: string, newName: string) => + Task.where(the`#actor renames the staff role "${currentName}" to "${newName}" via the staff portal`, OpenEditStaffRoleForm(currentName), FillStaffRoleForm({ roleName: newName }), SubmitStaffRoleForm()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts new file mode 100644 index 000000000..85bc50e6e --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts @@ -0,0 +1,9 @@ +import { Task, the } from '@serenity-js/core'; +import { OpenStaffRolesList } from '../interactions/open-staff-roles-list.ts'; +import { RecordListedStaffRoleNames } from '../interactions/record-staff-role-notes.ts'; + +/** + * Task that opens the staff roles list in the browser and records the listed + * role names in actor notes. + */ +export const ViewStaffRolesList = () => Task.where(the`#actor views the staff roles list`, OpenStaffRolesList(), RecordListedStaffRoleNames()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts index e7dab9ca2..4a3707340 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -1,3 +1,7 @@ +import type { StaffPortalBusinessRole } from '../../../shared/abilities/staff-portal-session.ts'; + export interface StaffE2ENotes { currentPath: string; + /** Business role of the mock OIDC staff user the scenario authenticated as. */ + businessRole: StaffPortalBusinessRole; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts index 8ea22e414..9b2ab5c1c 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -36,7 +36,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); - await actor.attemptsTo(notes().set('currentPath', '')); + await actor.attemptsTo(notes().set('currentPath', ''), notes().set('businessRole', 'case manager')); }); Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { @@ -44,7 +44,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); - await actor.attemptsTo(notes().set('currentPath', '')); + await actor.attemptsTo(notes().set('currentPath', ''), notes().set('businessRole', role)); }); When('{word} enters the staff operations workspace', async (actorName: string) => { diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts new file mode 100644 index 000000000..b1e379b9b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts @@ -0,0 +1,65 @@ +import type { BrowserContext, Page } from 'playwright'; +import { infrastructure } from '../../infrastructure.ts'; +import { performOAuth2Login } from './oauth2-login.ts'; + +/** Business roles supported by the staff portal E2E login flow. */ +export type StaffPortalBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +interface StaffPortalCredentials { + email: string; + password: string; +} + +/** + * Mock OIDC users defined in `apps/ui-staff/mock-oidc.users.json`. The subs of + * these users match the seeded staff users from + * `@ocom-verification/verification-shared` test data, so the API resolves the + * matching seeded staff role for each login. + */ +const credentialsByRole: Record = { + 'tech admin': { email: 'tech.admin@ownercommunity.onmicrosoft.com', password: 'password' }, + 'case manager': { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, + finance: { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, + 'service line owner': { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, +}; + +interface StaffPortalSession { + context: BrowserContext; + page: Page; + authenticated: boolean; +} + +const sessions = new Map(); + +/** + * Return an authenticated staff-portal page for the given business role. + * + * Browser contexts are reused across scenarios (one per mock OIDC user), so the + * OIDC session survives the per-scenario database reseed and only the first + * scenario per user pays the login cost. The login flow only runs once per + * session — re-running it would navigate the page away from whatever screen + * the current step left it on. + */ +export async function staffPortalPageFor(role: StaffPortalBusinessRole): Promise { + const credentials = credentialsByRole[role]; + let session = sessions.get(credentials.email); + if (!session) { + const context = await infrastructure.newPortalContext('staff'); + const page = await context.newPage(); + session = { context, page, authenticated: false }; + sessions.set(credentials.email, session); + } + if (!session.authenticated) { + await performOAuth2Login(session.page, credentials, '/auth-redirect'); + session.authenticated = true; + } + return session.page; +} + +/** Close every staff-portal browser context opened by the suite. */ +export async function closeStaffPortalSessions(): Promise { + for (const session of sessions.values()) { + await session.context.close().catch(() => undefined); + } + sessions.clear(); +} diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 585f6f459..dd9eee6f7 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,4 +5,5 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 253c07ead..780ee5d5a 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -4,3 +4,5 @@ export { MemberAccountsPage } from './member-accounts.page.ts'; export { MemberCreatePage } from './member-create.page.ts'; export { MemberListPage } from './member-list.page.ts'; export { MemberProfilePage } from './member-profile.page.ts'; +export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts'; +export { StaffRolesListPage } from './staff-roles-list.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts new file mode 100644 index 000000000..f72d1270a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts @@ -0,0 +1,107 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Maps staff role permission command keys (as used in shared scenarios and the + * GraphQL inputs) to the checkbox labels rendered by the create/edit form. + */ +export const STAFF_ROLE_PERMISSION_LABELS: Record = { + canManageCommunities: 'Can Manage Communities', + canManageStaffRolesAndPermissions: 'Can Manage Staff Roles and Permissions', + canManageAllCommunities: 'Can Manage All Communities', + canDeleteCommunities: 'Can Delete Communities', + canChangeCommunityOwner: 'Can Change Community Owner', + canReIndexSearchCollections: 'Can Reindex Search Collections', + canManageUsers: 'Can Manage Users', + canAssignStaffRoles: 'Can Assign Staff Roles', + canViewStaffUsers: 'Can View Staff Users', + canViewRoles: 'Can View Staff Roles', + canAddRole: 'Can Add Staff Role', + canEditRole: 'Can Edit Staff Role', + canRemoveRole: 'Can Remove Staff Role', + canManageFinance: 'Can Manage Finance', + canViewGLBatchSummaries: 'Can View GL Batch Summaries', + canViewFinanceConfigs: 'Can View Finance Configs', + canCreateFinanceConfigs: 'Can Create Finance Configs', + canManageTechAdmin: 'Can Manage Tech Admin', + canViewDatabaseExplorer: 'Can View Database Explorer', + canViewBlobExplorer: 'Can View Blob Explorer', + canViewQueueDashboard: 'Can View Queue Dashboard', + canSendQueueMessages: 'Can Send Queue Messages', +}; + +/** + * Page object for the staff role create/edit form. + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffRoleFormPage extends AdapterBackedPageObject { + get roleNameInput(): ElementHandle { + return this.adapter.getByLabel('Role Name'); + } + + get enterpriseAppRoleSelect(): ElementHandle { + return this.adapter.getByRole('combobox'); + } + + get submitButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create Role|Update Role/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorFeedback(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + get successFeedback(): ElementHandle { + return this.adapter.locator('.ant-message-success'); + } + + async fillRoleName(value: string): Promise { + await this.roleNameInput.fill(value); + } + + /** Current value of the role name input. */ + async roleNameValue(): Promise { + return (await this.roleNameInput.inputValue()) ?? ''; + } + + /** Currently selected enterprise app role (antd Select selection). */ + async selectedEnterpriseAppRole(): Promise { + const selection = this.adapter.locator('.ant-select-selection-item, .ant-select-content'); + return (await selection.getAttribute('title')) ?? (await selection.textContent())?.trim() ?? ''; + } + + /** Open the enterprise app role dropdown and pick the option with the given value. */ + async selectEnterpriseAppRole(value: string): Promise { + await this.enterpriseAppRoleSelect.click(); + const option = this.adapter.locator(`.ant-select-item-option[title="${value}"]`); + await option.waitFor({ state: 'visible' }); + await option.click(); + } + + /** Check a permission checkbox by its command key (e.g. `canViewRoles`). */ + async grantPermission(permissionKey: string): Promise { + await this.permissionCheckbox(permissionKey).check(); + } + + /** Return whether a permission checkbox is currently checked. */ + async isPermissionGranted(permissionKey: string): Promise { + return await this.permissionCheckbox(permissionKey).isChecked(); + } + + private permissionCheckbox(permissionKey: string): ElementHandle { + const label = STAFF_ROLE_PERMISSION_LABELS[permissionKey]; + if (!label) { + throw new Error(`Unknown staff role permission "${permissionKey}"`); + } + return this.adapter.getByLabel(label); + } + + async clickSubmit(): Promise { + await this.submitButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts new file mode 100644 index 000000000..8550de0da --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts @@ -0,0 +1,73 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff roles list screen (`/staff/user-management/roles`). + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffRolesListPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/Staff Roles \(/); + } + + get createRoleButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create Staff Role/i }); + } + + async clickCreateRole(): Promise { + await this.createRoleButton.click(); + } + + /** Role names currently rendered in the table body. */ + async listedRoleNames(): 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 hasRoleNamed(roleName: string): Promise { + const names = await this.listedRoleNames(); + return names.includes(roleName); + } + + /** Whether any row-level Edit action is rendered in the roles table. */ + async hasAnyEditAction(): Promise { + const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); + for (const row of rows) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim() === 'Edit') { + return true; + } + } + } + return false; + } + + /** Click the row-level Edit action for the given role. */ + async clickEditForRole(roleName: 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(roleName)) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim() === 'Edit') { + await button.click(); + return; + } + } + } + } + throw new Error(`No Edit action found for staff role "${roleName}"`); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature new file mode 100644 index 000000000..c24a4abfc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature @@ -0,0 +1,129 @@ +Feature: Staff role management + + As a staff administrator + I want to create, view, update, and assign staff roles with granular permissions + So that staff members only have access to the operations their role allows + + Scenario: View the list of staff roles + Given Alice is an authenticated "tech admin" staff user + When Alice views the staff roles list + Then the staff roles list should include the default staff roles + + @skip-e2e + Scenario: View staff role details + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Seeded Case Manager" exists + When Alice views the details of the staff role "Seeded Case Manager" + Then she should see the staff role name "Seeded Case Manager" + And she should see the enterprise app role "Staff.CaseManager" + + Scenario: Create a staff role with basic details + Given Alice is an authenticated "tech admin" staff user + When Alice creates a staff role with: + | roleName | Support Agent | + | enterpriseAppRole | Staff.CaseManager | + Then the staff role should be created successfully + And the staff roles list should include "Support Agent" + + @skip-ui + Scenario: Create a staff role with granted permissions + Given Alice is an authenticated "tech admin" staff user + When Alice creates a staff role named "Audit Reviewer" with permissions: + | canViewRoles | true | + | canViewStaffUsers | true | + Then the staff role should be created successfully + And the staff role "Audit Reviewer" should have the permission "canViewRoles" granted + And the staff role "Audit Reviewer" should have the permission "canViewStaffUsers" granted + + Scenario: Rename an existing staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Field Coordinator" exists + When Alice renames the staff role "Field Coordinator" to "Regional Coordinator" + Then the staff role should be updated successfully + And the staff roles list should include "Regional Coordinator" + + @skip-ui + Scenario: Update permissions on an existing staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Intake Specialist" exists + When Alice grants the permission "canAssignStaffRoles" to the staff role "Intake Specialist" + Then the staff role should be updated successfully + And the staff role "Intake Specialist" should have the permission "canAssignStaffRoles" granted + + @api-only + Scenario: Assign a staff role to a staff user + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Seeded Tech Admin" exists + When Alice assigns the staff role "Seeded Tech Admin" to the staff user "Staff User" + Then the staff user "Staff User" should have the staff role "Seeded Tech Admin" + + @validation + Scenario: Cannot create a duplicate staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Billing Clerk" exists + When Alice attempts to create a staff role with: + | roleName | Billing Clerk | + | enterpriseAppRole | Staff.CaseManager | + Then she should see a staff role error containing "already exists" + And no additional staff role should be created + + @validation + Scenario: Cannot create a staff role without a name + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to create a staff role with: + | roleName | | + Then she should see a staff role validation error for "roleName" + And no additional staff role should be created + + @api-only + Scenario: Case manager cannot create a role for a higher privileged enterprise app role + Given Alice is an authenticated "case manager" staff user + When Alice attempts to create a staff role with: + | roleName | Elevated Role | + | enterpriseAppRole | Staff.TechAdmin | + Then she should see a staff role error containing "do not have permission" + And no additional staff role should be created + + @api-only + Scenario: Unauthenticated users cannot view staff roles + Given Alice is not authenticated + When Alice attempts to view the staff roles list + Then the staff roles request should be rejected as unauthorized + + @skip-api + Scenario: Staff user without role permissions cannot open the roles screen + Given Alice is an authenticated "case manager" staff user + When Alice opens the staff roles screen + Then Alice should be directed to "/unauthorized" + + @ui-only + Scenario: Staff user with only the view-roles permission can see but not manage roles + Given Alice is an authenticated staff user with only the "canViewRoles" role permission + When Alice views the staff roles list + Then the staff roles list should include the default staff roles + And she should not see an option to create a staff role + And she should not see an option to edit a staff role + + @ui-only + Scenario: Staff user with only the add-role permission can create a staff role + Given Alice is an authenticated staff user with only the "canAddRole" role permission + When Alice creates a staff role with: + | roleName | Junior Auditor | + | enterpriseAppRole | Staff.CaseManager | + Then the staff role should be created successfully + And the staff roles list should include "Junior Auditor" + + @ui-only + Scenario: Staff user with only the edit-role permission can rename a staff role + Given Alice is an authenticated staff user with only the "canEditRole" role permission + And a staff role named "Legacy Coordinator" exists + When Alice renames the staff role "Legacy Coordinator" to "Modern Coordinator" + Then the staff role should be updated successfully + And the staff roles list should include "Modern Coordinator" + + @ui-only + Scenario: Staff user without the edit-role permission cannot open the edit screen + Given Alice is an authenticated staff user with only the "canViewRoles" role permission + And a staff role named "Restricted Role" exists + When Alice opens the edit screen for the staff role "Restricted Role" + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index f26f999cf..bb60dea82 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,4 +1,21 @@ -export { END_USER_IDS, type EndUserRoleSeedDetails, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase, seedEndUserRole } from './seed/index.ts'; +export { + DEFAULT_STAFF_ROLE_NAMES, + END_USER_IDS, + type EndUserRoleSeedDetails, + type EndUserSeedDocument, + endUsers, + type MongoDBSeedContext, + type MongoDBSeedDataFunction, + STAFF_ROLE_IDS, + STAFF_USER_IDS, + type StaffRoleSeedDocument, + type StaffUserSeedDocument, + seedDatabase, + seedEndUserRole, + staffRoles, + staffUsers, +} from './seed/index.ts'; + export { actors, defaultActor, diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts index e0fd7d839..e1203b7ab 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -1,2 +1,4 @@ export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; export { type EndUserRoleSeedDetails, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase, seedEndUserRole } from './seed.ts'; +export { DEFAULT_STAFF_ROLE_NAMES, STAFF_ROLE_IDS, type StaffRoleSeedDocument, staffRoles } from './staff-roles.ts'; +export { STAFF_USER_IDS, type StaffUserSeedDocument, staffUsers } from './staff-users.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts index 32801facd..a1b2f9f3b 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts @@ -1,5 +1,7 @@ import { type Document, MongoClient, ObjectId } from 'mongodb'; import { endUsers } from './end-users.ts'; +import { staffRoles } from './staff-roles.ts'; +import { staffUsers } from './staff-users.ts'; export interface MongoDBSeedContext { connectionString: string; @@ -41,7 +43,17 @@ export async function seedDatabase(context: MongoDBSeedContext): Promise { ...user, _id: toObjectId(user._id), })); - await upsertSeedDocuments(client, context.dbName, 'users', users); + const roles = staffRoles.map((role) => ({ + ...role, + _id: toObjectId(role._id), + })); + const staff = staffUsers.map((user) => ({ + ...user, + _id: toObjectId(user._id), + role: toObjectId(user.role), + })); + await upsertSeedDocuments(client, context.dbName, 'users', [...users, ...staff]); + await upsertSeedDocuments(client, context.dbName, 'roles', roles); } finally { await client.close(); } diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts new file mode 100644 index 000000000..78e2a13c2 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts @@ -0,0 +1,198 @@ +interface StaffRolePermissionsSeedDocument { + servicePermissions: { canManageServices: boolean }; + serviceTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canUpdateTickets: boolean; + canWorkOnTickets: boolean; + }; + violationTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canUpdateTickets: boolean; + canWorkOnTickets: boolean; + }; + communityPermissions: { + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + }; + financePermissions: { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + }; + techAdminPermissions: { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; + }; + userPermissions: { + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + }; + staffRolePermissions: { + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + }; + propertyPermissions: { + canManageProperties: boolean; + canEditOwnProperty: boolean; + }; +} + +export interface StaffRoleSeedDocument { + _id: string; + roleType: 'staff-user-role'; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + permissions: StaffRolePermissionsSeedDocument; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const STAFF_ROLE_IDS = { + seededTechAdmin: 'b00000000000000000000001', + seededCaseManager: 'b00000000000000000000002', +} as const; + +/** + * Names of the default staff roles the domain creates on startup + * (see `@ocom/domain` staff-role aggregate default-role factories). + * Verification suites assert these appear in the staff roles list. + */ +export const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin'] as const; + +function buildPermissions(overrides: Partial): StaffRolePermissionsSeedDocument { + const allFalseTickets = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }; + return { + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { ...allFalseTickets }, + violationTicketPermissions: { ...allFalseTickets }, + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + ...overrides, + }; +} + +const seedTimestamp = new Date('2024-01-01T00:00:00Z'); + +/** + * Staff roles seeded for verification suites. + * + * The seeded tech admin role grants `canManageStaffRolesAndPermissions`, which the + * staff passport requires before staff-role aggregates accept mutations. + */ +export const staffRoles: StaffRoleSeedDocument[] = [ + { + _id: STAFF_ROLE_IDS.seededTechAdmin, + roleType: 'staff-user-role', + roleName: 'Seeded Tech Admin', + enterpriseAppRole: 'Staff.TechAdmin', + isDefault: false, + permissions: buildPermissions({ + communityPermissions: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: true, + canManageAllCommunities: true, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + techAdminPermissions: { + canManageTechAdmin: true, + canViewDatabaseExplorer: true, + canViewBlobExplorer: true, + canViewQueueDashboard: true, + canSendQueueMessages: true, + }, + userPermissions: { + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }, + staffRolePermissions: { + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + }, + }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, + { + _id: STAFF_ROLE_IDS.seededCaseManager, + roleType: 'staff-user-role', + roleName: 'Seeded Case Manager', + enterpriseAppRole: 'Staff.CaseManager', + isDefault: false, + permissions: buildPermissions({ + communityPermissions: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, +]; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts new file mode 100644 index 000000000..03d4eca3a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts @@ -0,0 +1,50 @@ +import { actors, type TestActor } from '../test-actors.ts'; +import { STAFF_ROLE_IDS } from './staff-roles.ts'; + +export interface StaffUserSeedDocument { + _id: string; + userType: 'staff-user'; + externalId: string; + displayName: string; + firstName: string; + lastName: string; + email: string; + role: string; + accessBlocked: boolean; + tags: string[]; + activityLog: never[]; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const STAFF_USER_IDS = { + staffUser: 'c00000000000000000000001', + techAdminStaff: 'c00000000000000000000002', + caseManagerStaff: 'c00000000000000000000003', +} as const; + +export const staffUsers: StaffUserSeedDocument[] = [ + createStaffUserSeedDocument(STAFF_USER_IDS.staffUser, actors.StaffUser, STAFF_ROLE_IDS.seededCaseManager), + createStaffUserSeedDocument(STAFF_USER_IDS.techAdminStaff, actors.TechAdminStaff, STAFF_ROLE_IDS.seededTechAdmin), + createStaffUserSeedDocument(STAFF_USER_IDS.caseManagerStaff, actors.CaseManagerStaff, STAFF_ROLE_IDS.seededCaseManager), +]; + +function createStaffUserSeedDocument(id: string, actor: TestActor, roleId: string): StaffUserSeedDocument { + return { + _id: id, + userType: 'staff-user', + externalId: actor.externalId, + displayName: `${actor.givenName} ${actor.familyName}`.trim(), + firstName: actor.givenName, + lastName: actor.familyName, + email: actor.email, + role: roleId, + accessBlocked: false, + tags: [], + activityLog: [], + schemaVersion: '1.0.0', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; +} diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 32517bbc6..3f822e4b1 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -4,6 +4,8 @@ export interface TestActor { email: string; givenName: string; familyName: string; + /** Entra enterprise app roles claim included in the actor's JWT (staff actors only). */ + roles?: string[]; } const communityOwner: TestActor = { @@ -36,12 +38,33 @@ const staffUser: TestActor = { email: 'staff@sharethrift.onmicrosoft.com', givenName: 'Staff', familyName: 'User', + roles: ['Staff.CaseManager'], +}; + +const techAdminStaff: TestActor = { + name: 'TechAdminStaff', + externalId: '10000000-0000-4000-8000-000000000002', + email: 'tech.admin@sharethrift.onmicrosoft.com', + givenName: 'Tech', + familyName: 'Admin', + roles: ['Staff.TechAdmin'], +}; + +const caseManagerStaff: TestActor = { + name: 'CaseManagerStaff', + externalId: '10000000-0000-4000-8000-000000000003', + email: 'case.manager@sharethrift.onmicrosoft.com', + givenName: 'Case', + familyName: 'Manager', + roles: ['Staff.CaseManager'], }; export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, StaffUser: staffUser, + TechAdminStaff: techAdminStaff, + CaseManagerStaff: caseManagerStaff, Guest: guest, } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b5f0edb8..b2722c7ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,8 +70,8 @@ catalogs: specifier: 7.0.0-dev.20260428.1 version: 7.0.0-dev.20260428.1 '@vitest/coverage-istanbul': - specifier: 4.1.8 - version: 4.1.8 + specifier: 4.1.10 + version: 4.1.10 antd: specifier: 6.3.5 version: 6.3.5 @@ -118,14 +118,14 @@ catalogs: specifier: ^0.28.0 version: 0.28.0 vitest: - specifier: 4.1.8 - version: 4.1.8 + specifier: 4.1.10 + version: 4.1.10 overrides: - '@vitest/browser': 4.1.8 - '@vitest/browser-playwright': 4.1.8 + '@vitest/browser': 4.1.10 + '@vitest/browser-playwright': 4.1.10 happy-dom: 20.10.1 - axios: 1.16.0 + axios: 1.18.0 body-parser: 2.3.0 esbuild: 0.28.1 follow-redirects: ^1.16.0 @@ -140,7 +140,7 @@ overrides: serve-handler>minimatch: 3.1.5 serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 - svgo: ^3.3.3 + svgo: ^3.3.4 yaml@2.8.2: 2.8.3 yauzl@3.2.0: 3.2.1 qs: 6.15.2 @@ -163,13 +163,16 @@ overrides: postcss: 8.5.10 protobufjs: 7.6.5 ip-address: ^10.1.1 - fast-uri: ^4.0.1 + fast-uri: ^4.1.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - js-yaml@>=4.0.0: 4.3.0 + js-yaml: 4.3.0 + js-yaml@3.14.2: 3.15.0 shell-quote@<1.8.4: 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 + '@azure/monitor-opentelemetry-exporter>@azure/core-client': 1.11.0 + '@azure/monitor-opentelemetry-exporter>@azure/core-rest-pipeline': 1.25.0 ws: 8.21.1 websocket-driver: 0.7.5 shell-quote: 1.9.0 @@ -200,7 +203,7 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: ^6.3.0 - version: 6.3.0(vitest@4.1.8) + version: 6.3.0(vitest@4.1.10) '@ant-design/cli': specifier: ^6.3.5 version: 6.3.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -236,7 +239,7 @@ importers: version: 1.59.0 '@sonar/scan': specifier: ^4.3.0 - version: 4.3.2 + version: 4.3.2(supports-color@8.1.1) '@types/node': specifier: 'catalog:' version: 22.19.15 @@ -245,7 +248,7 @@ importers: version: 7.0.0-dev.20260428.1 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(supports-color@8.1.1)(vitest@4.1.8) + version: 4.1.10(supports-color@8.1.1)(vitest@4.1.10) chrome-devtools-mcp: specifier: ^1.3.0 version: 1.3.0 @@ -278,7 +281,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/api: dependencies: @@ -351,7 +354,7 @@ importers: version: link:../../packages/ocom/local-dev-config '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) archunit: specifier: 'catalog:' version: 2.1.63 @@ -369,7 +372,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/docs: dependencies: @@ -433,7 +436,7 @@ importers: version: 6.0.1(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -442,7 +445,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/server-mongodb-memory-mock: dependencies: @@ -498,7 +501,7 @@ importers: version: link:../../packages/cellix/local-dev '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -507,7 +510,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/ui-community: dependencies: @@ -583,7 +586,7 @@ importers: version: 10.4.2(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 10.4.2 version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -601,7 +604,7 @@ importers: version: 6.0.1(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) esbuild: specifier: 0.28.1 version: 0.28.1 @@ -631,7 +634,7 @@ importers: version: 0.28.0(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/ui-staff: dependencies: @@ -713,7 +716,7 @@ importers: version: 6.0.1(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) esbuild: specifier: 0.28.1 version: 0.28.1 @@ -737,7 +740,7 @@ importers: version: 0.28.0(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/api-services-spec: devDependencies: @@ -773,7 +776,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/config-rolldown: devDependencies: @@ -785,7 +788,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -797,7 +800,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/config-typescript: {} @@ -808,16 +811,16 @@ importers: version: link:../config-typescript '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@vitest/browser-playwright': - specifier: 4.1.8 - version: 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) + specifier: 4.1.10 + version: 4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) typescript: specifier: 'catalog:' version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/domain-seedwork: devDependencies: @@ -829,7 +832,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -838,7 +841,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/event-bus-seedwork-node: dependencies: @@ -857,7 +860,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -888,7 +891,7 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: ^6.3.0 - version: 6.3.0(vitest@4.1.8) + version: 6.3.0(vitest@4.1.10) '@cellix/config-typescript': specifier: workspace:* version: link:../config-typescript @@ -897,7 +900,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -906,7 +909,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/graphql-core: dependencies: @@ -925,7 +928,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -934,7 +937,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/local-dev: devDependencies: @@ -949,7 +952,7 @@ importers: version: 22.19.15 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -958,7 +961,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/mongoose-seedwork: dependencies: @@ -977,7 +980,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) mongodb: specifier: 'catalog:' version: 6.18.0 @@ -995,7 +998,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/serenity-framework: dependencies: @@ -1050,7 +1053,7 @@ importers: version: 19.2.7 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) playwright: specifier: 1.59.0 version: 1.59.0 @@ -1065,7 +1068,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/server-mongodb-memory-mock-seedwork: dependencies: @@ -1115,7 +1118,7 @@ importers: version: 5.0.5 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1124,7 +1127,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/service-blob-storage: dependencies: @@ -1146,7 +1149,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1155,7 +1158,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/service-queue-storage: dependencies: @@ -1180,7 +1183,7 @@ importers: devDependencies: '@amiceli/vitest-cucumber': specifier: ^6.3.0 - version: 6.3.0(vitest@4.1.8) + version: 6.3.0(vitest@4.1.10) '@cellix/config-typescript': specifier: workspace:* version: link:../config-typescript @@ -1189,7 +1192,7 @@ importers: version: link:../config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) execa: specifier: ^9.6.0 version: 9.6.1 @@ -1201,7 +1204,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cellix/ui-core: dependencies: @@ -1235,7 +1238,7 @@ importers: version: 10.4.2(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 10.4.2 version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -1252,14 +1255,14 @@ importers: specifier: ^19.1.6 version: 19.2.3(@types/react@19.2.7) '@vitest/browser': - specifier: 4.1.8 - version: 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) + specifier: 4.1.10 + version: 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) '@vitest/browser-playwright': - specifier: 4.1.8 - version: 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) + specifier: 4.1.10 + version: 4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) jsdom: specifier: 'catalog:' version: 26.1.0 @@ -1280,7 +1283,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom-verification/acceptance-api: dependencies: @@ -1420,6 +1423,9 @@ importers: react-oidc-context: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) + react-router-dom: + specifier: 'catalog:' + version: 7.15.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) std-env: specifier: ^4.0.0 version: 4.0.0 @@ -1471,7 +1477,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom-verification/e2e-tests: dependencies: @@ -1585,7 +1591,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1594,7 +1600,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/context-spec: dependencies: @@ -1653,7 +1659,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/domain: dependencies: @@ -1705,7 +1711,7 @@ importers: version: 3.43.0 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1714,7 +1720,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/event-handler: dependencies: @@ -1767,7 +1773,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1776,7 +1782,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/graphql-handler: dependencies: @@ -1810,7 +1816,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1819,7 +1825,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/local-dev-config: dependencies: @@ -1838,7 +1844,7 @@ importers: version: 22.19.15 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1847,7 +1853,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/persistence: dependencies: @@ -1887,7 +1893,7 @@ importers: version: link:../../ocom-verification/archunit-tests '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1896,7 +1902,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/rest: dependencies: @@ -1949,7 +1955,7 @@ importers: version: 1.1.6 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1958,7 +1964,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/service-blob-storage: dependencies: @@ -1974,7 +1980,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1983,7 +1989,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/service-mongoose: dependencies: @@ -2005,7 +2011,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -2019,8 +2025,8 @@ importers: specifier: 0.1.0 version: 0.1.0(@opentelemetry/api@1.9.0) '@azure/monitor-opentelemetry-exporter': - specifier: 1.0.0-beta.31 - version: 1.0.0-beta.31 + specifier: 1.0.0-beta.43 + version: 1.0.0-beta.43 '@cellix/api-services-spec': specifier: workspace:* version: link:../../cellix/api-services-spec @@ -2034,23 +2040,26 @@ importers: specifier: 0.48.0 version: 0.48.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': - specifier: 0.52.1 - version: 0.52.1(@opentelemetry/api@1.9.0) + specifier: 0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1) '@opentelemetry/instrumentation-mongoose': specifier: 0.47.0 version: 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: 2.9.0 + version: 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': - specifier: 0.57.2 - version: 0.57.2(@opentelemetry/api@1.9.0) + specifier: 0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': - specifier: 1.30.1 - version: 1.30.1(@opentelemetry/api@1.9.0) + specifier: 2.9.0 + version: 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': - specifier: 0.57.2 - version: 0.57.2(@opentelemetry/api@1.9.0) + specifier: 0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': - specifier: 1.30.1 - version: 1.30.1(@opentelemetry/api@1.9.0) + specifier: 2.9.0 + version: 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: 1.28.0 version: 1.28.0 @@ -2063,7 +2072,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -2072,7 +2081,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/service-queue-storage: dependencies: @@ -2088,7 +2097,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -2097,7 +2106,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/service-token-validation: dependencies: @@ -2116,7 +2125,7 @@ importers: version: link:../../cellix/config-vitest '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -2125,7 +2134,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-community-route-accounts: dependencies: @@ -2192,7 +2201,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 10.4.2 version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2219,7 +2228,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-community-route-admin: dependencies: @@ -2286,7 +2295,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: ^10.4.2 version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2313,7 +2322,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-community-route-root: dependencies: @@ -2356,7 +2365,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 'catalog:' version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2383,7 +2392,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-community-shared: dependencies: @@ -2435,7 +2444,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 'catalog:' version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2462,7 +2471,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-shared: dependencies: @@ -2520,7 +2529,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 'catalog:' version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2534,11 +2543,11 @@ importers: specifier: ^19.1.6 version: 19.2.3(@types/react@19.2.7) '@vitest/browser': - specifier: 4.1.8 - version: 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) + specifier: 4.1.10 + version: 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(vitest@4.1.8) + version: 4.1.10(vitest@4.1.10) jsdom: specifier: 'catalog:' version: 26.1.0 @@ -2559,7 +2568,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-route-community-management: dependencies: @@ -2602,7 +2611,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-route-finance: dependencies: @@ -2645,7 +2654,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-route-root: dependencies: @@ -2688,7 +2697,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-route-tech-admin: dependencies: @@ -2731,7 +2740,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-route-user-management: dependencies: @@ -2777,7 +2786,7 @@ importers: version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(esbuild@0.28.1)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(webpack@5.105.4(esbuild@0.28.1)) '@storybook/addon-vitest': specifier: 'catalog:' - version: 10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8) + version: 10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10) '@storybook/react': specifier: 'catalog:' version: 10.4.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@6.0.3) @@ -2804,7 +2813,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ocom/ui-staff-shared: dependencies: @@ -2865,7 +2874,7 @@ importers: version: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -3176,6 +3185,10 @@ packages: resolution: {integrity: sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==} engines: {node: '>=20.0.0'} + '@azure-rest/core-client@2.8.0': + resolution: {integrity: sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==} + engines: {node: '>=22.0.0'} + '@azure/abort-controller@1.1.0': resolution: {integrity: sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==} engines: {node: '>=12.0.0'} @@ -3192,9 +3205,9 @@ packages: resolution: {integrity: sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g==} engines: {node: '>=18.0.0'} - '@azure/core-client@1.10.1': - resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} - engines: {node: '>=20.0.0'} + '@azure/core-client@1.11.0': + resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} + engines: {node: '>=22.0.0'} '@azure/core-http-compat@2.3.1': resolution: {integrity: sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==} @@ -3212,9 +3225,9 @@ packages: resolution: {integrity: sha512-VxLk4AHLyqcHsfKe4MZ6IQ+D+ShuByy+RfStKfSjxJoL3WBWq17VNmrz8aT8etKzqc2nAeIyLxScjpzsS4fz8w==} engines: {node: '>=18.0.0'} - '@azure/core-rest-pipeline@1.22.2': - resolution: {integrity: sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==} - engines: {node: '>=20.0.0'} + '@azure/core-rest-pipeline@1.25.0': + resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} + engines: {node: '>=22.0.0'} '@azure/core-tracing@1.3.1': resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} @@ -3262,9 +3275,9 @@ packages: resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} - '@azure/monitor-opentelemetry-exporter@1.0.0-beta.31': - resolution: {integrity: sha512-fH/vFMWOHwN/qdJ1SxVtsJ9zWIGWJdy/tZiVVPsFbpbR4NT49J5Im72iCcTyvABRPfIkMz/zsD58Yilf0yiEZw==} - engines: {node: '>=18.0.0'} + '@azure/monitor-opentelemetry-exporter@1.0.0-beta.43': + resolution: {integrity: sha512-551lMY6gSf7TO+j15nmQ23+nG9fExb664TH9RDs9zUul1xHt3NUtfsZe90nNhOzMEeg+I1f7zn955dvH5o6yjA==} + engines: {node: '>=22.0.0'} '@azure/ms-rest-js@1.11.2': resolution: {integrity: sha512-2AyQ1IKmLGKW7DU3/x3TsTBzZLcbC9YRI+yuDPuXAQrv3zar340K9wsxU413kHFIDjkWNCo9T0w5VtwcyWxhbQ==} @@ -5270,6 +5283,14 @@ packages: resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.52.1': resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} engines: {node: '>=14'} @@ -5282,15 +5303,15 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/context-async-hooks@1.30.1': - resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} - engines: {node: '>=14'} + '@opentelemetry/configuration@0.220.0': + resolution: {integrity: sha512-glfIVKnZevRin8fY/9uES/mhRtMT1lGINLHc9MIo5fTQZXswEEHamJtgjv4MTtzgnhHGC92mIS/0lzAUZMyE0w==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/api': ^1.9.0 - '@opentelemetry/core@1.25.1': - resolution: {integrity: sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==} - engines: {node: '>=14'} + '@opentelemetry/context-async-hooks@2.9.0': + resolution: {integrity: sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -5312,69 +5333,75 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.57.2': - resolution: {integrity: sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==} - engines: {node: '>=14'} + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.220.0': + resolution: {integrity: sha512-s0sRPCSlXYqlgObOpCftomJllp3LfUL9FobQ5csg2172ydVhSEnu1ptpsVBJadazs5nUNp7vDuLE03FAFWTLOQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.57.2': - resolution: {integrity: sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==} - engines: {node: '>=14'} + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.57.2': - resolution: {integrity: sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==} - engines: {node: '>=14'} + '@opentelemetry/exporter-logs-otlp-proto@0.220.0': + resolution: {integrity: sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2': - resolution: {integrity: sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==} - engines: {node: '>=14'} + '@opentelemetry/exporter-metrics-otlp-grpc@0.220.0': + resolution: {integrity: sha512-U128izvJfX/dW9jRGP0gIfadR1Hg7ft3UEGIeRxLFK70m2BWw6AtNCOnsUygpw2zCgR/ygdWbGpcL6TmhW0ZGw==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.57.2': - resolution: {integrity: sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==} - engines: {node: '>=14'} + '@opentelemetry/exporter-metrics-otlp-http@0.220.0': + resolution: {integrity: sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.57.2': - resolution: {integrity: sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==} - engines: {node: '>=14'} + '@opentelemetry/exporter-metrics-otlp-proto@0.220.0': + resolution: {integrity: sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.217.0': - resolution: {integrity: sha512-U9MCXxJu0sBCh5aEkylYRR4xVIL8D1CW6dGwvYXbfFr0qveSorfD0XJchCAWoW6QfAAIcY/yxjf4Dj8OgkHBPw==} + '@opentelemetry/exporter-prometheus@0.220.0': + resolution: {integrity: sha512-JZD5DL/NBpVd2BHefvYosm3G40UZ/KzExLv5tc0eZe0CtrsHHtcOk3YPUxR2EINmUeBf8+w5UReTV8fFPn95lA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.57.2': - resolution: {integrity: sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==} - engines: {node: '>=14'} + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0': + resolution: {integrity: sha512-bv1xmNhmNwIM6MdUBw4yYuJeVcEViVLk3uD69vOQMwueHBnfyl/u0HnBlB1FNY/Te0UOzJzvcbyR8wN6b+iGbA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.57.2': - resolution: {integrity: sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==} - engines: {node: '>=14'} + '@opentelemetry/exporter-trace-otlp-http@0.220.0': + resolution: {integrity: sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.57.2': - resolution: {integrity: sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==} - engines: {node: '>=14'} + '@opentelemetry/exporter-trace-otlp-proto@0.220.0': + resolution: {integrity: sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@1.30.1': - resolution: {integrity: sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==} - engines: {node: '>=14'} + '@opentelemetry/exporter-zipkin@2.9.0': + resolution: {integrity: sha512-RwINoce2BH8T4obT5pMcAla2sWma1YZvYuaktWmTluQ0PkQdvv5D060rWI1+kawX+J2qBRcMbwrZJJNcMJUauQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 @@ -5390,9 +5417,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-http@0.52.1': - resolution: {integrity: sha512-dG/aevWhaP+7OLv4BQQSEKMJv8GyeOp3Wxl31NHqE8xo9/fYMfEljiZphUHIfyg4gnZ9swMyWjfOQs5GUQe54Q==} - engines: {node: '>=14'} + '@opentelemetry/instrumentation-http@0.220.0': + resolution: {integrity: sha512-Szt4dO2Boz2CDr38DaSw/lnqwhwKl+IAdgNGEGgSm2Anb+fwPtIAGmIwkhsLLN69QQZQE96JxjMKYY4rlRkYKw==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -5408,45 +5435,45 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.52.1': - resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} - engines: {node: '>=14'} + '@opentelemetry/instrumentation@0.220.0': + resolution: {integrity: sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.57.2': - resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + '@opentelemetry/instrumentation@0.52.1': + resolution: {integrity: sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.57.2': - resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} - engines: {node: '>=14'} + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.57.2': - resolution: {integrity: sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==} - engines: {node: '>=14'} + '@opentelemetry/otlp-grpc-exporter-base@0.220.0': + resolution: {integrity: sha512-/eIkBPMBTIvM3x/0mDX4aJeSkYifYClnBPr68PL1h5LV4VQv4+SV6CGrpiZ4fIWDnobVmhTWCm1J/QRdAWUfvA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.57.2': - resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} - engines: {node: '>=14'} + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/propagator-b3@1.30.1': - resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} - engines: {node: '>=14'} + '@opentelemetry/propagator-b3@2.9.0': + resolution: {integrity: sha512-WrOT1WsOUG+B7hstD2RYoMPIOK76G8E9AQHhMjUvrQaGx/oA7rPWQvvr1Rqv7+yy4R0ZMVwWLC4vW2xnkgWPAQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@1.30.1': - resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} - engines: {node: '>=14'} + '@opentelemetry/propagator-jaeger@2.9.0': + resolution: {integrity: sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -5462,33 +5489,45 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.7.1': - resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.57.2': - resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} - engines: {node: '>=14'} + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@1.30.1': - resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} - engines: {node: '>=14'} + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/api': '>=1.4.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.7.1': - resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.57.2': - resolution: {integrity: sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==} - engines: {node: '>=14'} + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.220.0': + resolution: {integrity: sha512-wHtGyHhSKHNH3fym33xRu4Ef/HXTFvX8eQ42xdQdEO9LYx9Y2qNyBDJytyqVlvmo6abWZlNYTUthuAGUMYqYnQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -5504,9 +5543,21 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-node@1.30.1': - resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} - engines: {node: '>=14'} + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.9.0': + resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.9.0': + resolution: {integrity: sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==} + engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -5516,9 +5567,11 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/semantic-conventions@1.25.1': - resolution: {integrity: sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==} - engines: {node: '>=14'} + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/semantic-conventions@1.28.0': resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} @@ -5528,6 +5581,10 @@ packages: resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} engines: {node: '>=14'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-parser/binding-android-arm-eabi@0.121.0': resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6161,15 +6218,9 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - '@protobufjs/eventemitter@1.1.1': resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - '@protobufjs/fetch@1.1.1': resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} @@ -6807,8 +6858,8 @@ packages: '@storybook/addon-vitest@10.4.2': resolution: {integrity: sha512-gq1ZVr0IOLgiS2PzaJqHxKLczwrXvA5g3W2k/FVJA19fJNFNUut1IyQCQRSJTXLLXbnNaa8I0wjGGc2BoLarJg==} peerDependencies: - '@vitest/browser': 4.1.8 - '@vitest/browser-playwright': 4.1.8 + '@vitest/browser': 4.1.10 + '@vitest/browser-playwright': 4.1.10 '@vitest/runner': ^3.0.0 || ^4.0.0 storybook: ^10.4.2 vitest: ^3.0.0 || ^4.0.0 @@ -7319,9 +7370,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - '@typespec/ts-http-runtime@0.3.2': - resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==} - engines: {node: '>=20.0.0'} + '@typespec/ts-http-runtime@0.3.7': + resolution: {integrity: sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==} + engines: {node: '>=22.0.0'} '@umijs/route-utils@4.0.3': resolution: {integrity: sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==} @@ -7352,30 +7403,30 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-playwright@4.1.8': - resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} + '@vitest/browser-playwright@4.1.10': + resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} peerDependencies: playwright: 1.59.0 - vitest: 4.1.8 + vitest: 4.1.10 - '@vitest/browser@4.1.8': - resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} + '@vitest/browser@4.1.10': + resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} peerDependencies: - vitest: 4.1.8 + vitest: 4.1.10 - '@vitest/coverage-istanbul@4.1.8': - resolution: {integrity: sha512-/h514nMZMKI6foh21mVgO1zlCH6pdDamwKMbla1uLU2GMxTlfp0PQMxovWozmzQdCIQYZ2XXEzg2zNZom2zAOg==} + '@vitest/coverage-istanbul@4.1.10': + resolution: {integrity: sha512-AyNJ5pQRFqCX7pwB9PSTmoVKPaZ4H5IEVJfJsT+q1DYkXvZMEFYgJlyk5sfStmt9rVYRyYYRRsuBeImCOc39ww==} peerDependencies: - vitest: 4.1.8 + vitest: 4.1.10 '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: 8.0.16 @@ -7388,26 +7439,26 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -7541,6 +7592,10 @@ packages: resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} engines: {node: '>=12.0'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -7783,8 +7838,8 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.18.0: + resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} azurite@3.35.0: resolution: {integrity: sha512-GzKmi+/5U0baNRjEEVtBMLpLuIKEJ0uSh0VWBzOI4qe4f5ziJyoZQmcTO7QhxZTF6+rphj7TZS3PtJY7uiiacA==} @@ -8162,6 +8217,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} @@ -8944,6 +9002,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -9124,8 +9185,8 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-uri@4.0.1: - resolution: {integrity: sha512-j0VntHrljgRKiWdbjL1xECG44OQu5sb7en4b8z2CcRvtDUEG5DAvkXlllrRhvbABR+m2mUiSgYBPvDj/PzB0dg==} + fast-uri@4.1.1: + resolution: {integrity: sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -9282,6 +9343,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -9573,10 +9637,6 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -9714,6 +9774,10 @@ packages: https-browserify@1.0.0: resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -9792,6 +9856,10 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + import-in-the-middle@3.3.1: + resolution: {integrity: sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==} + engines: {node: '>=18'} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -10551,10 +10619,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -10913,10 +10977,6 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -12401,6 +12461,10 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} @@ -12612,11 +12676,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -13108,8 +13167,8 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - svgo@3.3.3: - resolution: {integrity: sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==} + svgo@3.3.4: + resolution: {integrity: sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==} engines: {node: '>=14.0.0'} hasBin: true @@ -13245,10 +13304,6 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -13734,20 +13789,20 @@ packages: yaml: optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: 20.10.1 jsdom: '*' vite: 8.0.16 @@ -14238,13 +14293,13 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.8)': + '@amiceli/vitest-cucumber@6.3.0(vitest@4.1.10)': dependencies: callsites: 4.2.0 minimist: 1.2.8 parsecurrency: 1.1.1 ts-morph: 27.0.2 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) '@ant-design/cli@6.3.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -14410,8 +14465,8 @@ snapshots: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 '@protobufjs/inquire': 1.1.1 '@protobufjs/path': 1.1.2 @@ -14448,7 +14503,7 @@ snapshots: finalhandler: 2.1.1(supports-color@8.1.1) graphql: 16.12.0 loglevel: 1.9.2 - lru-cache: 11.3.5 + lru-cache: 11.3.6 negotiator: 1.0.0 uuid: 11.1.1 whatwg-mimetype: 4.0.0 @@ -14475,7 +14530,7 @@ snapshots: '@apollo/utils.keyvaluecache@4.0.0': dependencies: '@apollo/utils.logger': 3.0.0 - lru-cache: 11.3.5 + lru-cache: 11.3.6 '@apollo/utils.logger@3.0.0': {} @@ -14536,9 +14591,20 @@ snapshots: dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 - '@typespec/ts-http-runtime': 0.3.2 + '@typespec/ts-http-runtime': 0.3.7 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure-rest/core-client@2.8.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.3.1 + '@typespec/ts-http-runtime': 0.3.7 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -14567,11 +14633,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/core-client@1.10.1': + '@azure/core-client@1.11.0': dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 @@ -14582,8 +14648,8 @@ snapshots: '@azure/core-http-compat@2.3.1': dependencies: '@azure/abort-controller': 2.1.2 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 transitivePeerDependencies: - supports-color @@ -14613,14 +14679,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/core-rest-pipeline@1.22.2': + '@azure/core-rest-pipeline@1.25.0': dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 - '@typespec/ts-http-runtime': 0.3.2 + '@typespec/ts-http-runtime': 0.3.7 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -14632,7 +14698,7 @@ snapshots: '@azure/core-util@1.13.1': dependencies: '@azure/abort-controller': 2.1.2 - '@typespec/ts-http-runtime': 0.3.2 + '@typespec/ts-http-runtime': 0.3.7 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -14662,8 +14728,8 @@ snapshots: dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 @@ -14678,8 +14744,8 @@ snapshots: dependencies: '@azure/abort-controller': 1.1.0 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 @@ -14697,8 +14763,8 @@ snapshots: dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 @@ -14714,7 +14780,7 @@ snapshots: '@azure/core-http-compat': 2.3.1 '@azure/core-lro': 2.7.2 '@azure/core-paging': 1.6.2 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/keyvault-common': 2.0.0 @@ -14725,24 +14791,26 @@ snapshots: '@azure/logger@1.3.0': dependencies: - '@typespec/ts-http-runtime': 0.3.2 + '@typespec/ts-http-runtime': 0.3.7 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/monitor-opentelemetry-exporter@1.0.0-beta.31': + '@azure/monitor-opentelemetry-exporter@1.0.0-beta.43': dependencies: + '@azure-rest/core-client': 2.8.0 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-client': 1.11.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-util': 1.13.1 '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -14750,7 +14818,7 @@ snapshots: '@azure/ms-rest-js@1.11.2': dependencies: '@azure/core-auth': 1.10.1 - axios: 1.16.0 + axios: 1.18.0(supports-color@8.1.1) form-data: 4.0.6 tough-cookie: 4.1.3 tslib: 1.14.1 @@ -14800,11 +14868,11 @@ snapshots: dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 + '@azure/core-client': 1.11.0 '@azure/core-http-compat': 2.3.1 '@azure/core-lro': 2.7.2 '@azure/core-paging': 1.6.2 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/core-xml': 1.5.1 @@ -14820,7 +14888,7 @@ snapshots: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 '@azure/core-http-compat': 2.3.1 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 @@ -14833,10 +14901,10 @@ snapshots: dependencies: '@azure/abort-controller': 2.1.2 '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 + '@azure/core-client': 1.11.0 '@azure/core-http-compat': 2.3.1 '@azure/core-paging': 1.6.2 - '@azure/core-rest-pipeline': 1.22.2 + '@azure/core-rest-pipeline': 1.25.0 '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/core-xml': 1.5.1 @@ -15626,8 +15694,8 @@ snapshots: '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -17464,10 +17532,10 @@ snapshots: '@graphql-tools/graphql-tag-pluck@8.3.26(graphql@16.12.0)': dependencies: '@babel/core': 7.29.6(supports-color@8.1.1) - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.7 '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.29.6) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/types': 7.29.7 '@graphql-tools/utils': 10.11.0(graphql@16.12.0) graphql: 16.12.0 tslib: 2.8.1 @@ -17839,6 +17907,14 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs@0.52.1': dependencies: '@opentelemetry/api': 1.9.0 @@ -17849,14 +17925,15 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + yaml: 2.8.3 - '@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.25.1 '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -17873,111 +17950,110 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/instrumentation-dataloader@0.17.0(@opentelemetry/api@1.9.0)': dependencies: @@ -17993,13 +18069,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-http@0.52.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.220.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.52.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.25.1 - semver: 7.7.3 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1) + '@opentelemetry/semantic-conventions': 1.38.0 + forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color @@ -18023,64 +18099,60 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.52.1 - '@types/shimmer': 1.2.0 - import-in-the-middle: 1.15.0 - require-in-the-middle: 7.5.2 - semver: 7.7.4 - shimmer: 1.2.1 + '@opentelemetry/api-logs': 0.220.0 + import-in-the-middle: 3.3.1 + require-in-the-middle: 8.0.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/api-logs': 0.52.1 '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.4 + semver: 7.8.0 shimmer: 1.2.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - protobufjs: 7.6.5 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -18094,54 +18166,76 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.57.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.57.2 - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.220.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/configuration': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 transitivePeerDependencies: - supports-color @@ -18159,15 +18253,27 @@ snapshots: '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - semver: 7.7.4 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + + '@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.9.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-web@2.2.0(@opentelemetry/api@1.9.0)': dependencies: @@ -18175,12 +18281,19 @@ snapshots: '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions@1.25.1': {} + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 '@opentelemetry/semantic-conventions@1.28.0': {} '@opentelemetry/semantic-conventions@1.38.0': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-parser/binding-android-arm-eabi@0.121.0': optional: true @@ -18626,15 +18739,8 @@ snapshots: '@protobufjs/codegen@2.0.5': {} - '@protobufjs/eventemitter@1.1.0': {} - '@protobufjs/eventemitter@1.1.1': {} - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.1 - '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 @@ -19183,7 +19289,7 @@ snapshots: dependencies: '@serenity-js/core': 3.43.0 agent-base: 7.1.4 - axios: 1.16.0 + axios: 1.18.0(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 11.3.6 @@ -19198,7 +19304,7 @@ snapshots: '@serenity-js/core': 3.43.0 '@serenity-js/rest': 3.43.0 ansi-regex: 5.0.1 - axios: 1.16.0 + axios: 1.18.0(supports-color@8.1.1) chalk: 4.1.2 find-java-home: 2.0.0 progress: 2.0.3 @@ -19248,10 +19354,10 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 - '@sonar/scan@4.3.2': + '@sonar/scan@4.3.2(supports-color@8.1.1)': dependencies: adm-zip: 0.5.16 - axios: 1.16.0 + axios: 1.18.0(supports-color@8.1.1) commander: 13.1.0 fs-extra: 11.3.2 hpagent: 1.2.0 @@ -19265,6 +19371,7 @@ snapshots: - bare-abort-controller - debug - react-native-b4a + - supports-color '@standard-schema/spec@1.1.0': {} @@ -19297,16 +19404,16 @@ snapshots: dependencies: storybook: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@storybook/addon-vitest@10.4.2(@vitest/browser-playwright@4.1.8)(@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8))(@vitest/runner@4.1.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.8)': + '@storybook/addon-vitest@10.4.2(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10))(@vitest/runner@4.1.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(vitest@4.1.10)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) optionalDependencies: - '@vitest/browser': 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/browser-playwright': 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/runner': 4.1.8 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/runner': 4.1.10 + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - react - react-dom @@ -19462,7 +19569,7 @@ snapshots: '@svgr/core': 8.1.0(typescript@6.0.3) cosmiconfig: 8.3.6(typescript@6.0.3) deepmerge: 4.3.1 - svgo: 3.3.3 + svgo: 3.3.4 transitivePeerDependencies: - typescript @@ -19845,7 +19952,7 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260428.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260428.1 - '@typespec/ts-http-runtime@0.3.2': + '@typespec/ts-http-runtime@0.3.7': dependencies: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -19868,13 +19975,13 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/browser-playwright@4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8)': + '@vitest/browser-playwright@4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) playwright: 1.59.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - bufferutil - msw @@ -19882,29 +19989,29 @@ snapshots: - vite optional: true - '@vitest/browser-playwright@4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8)': + '@vitest/browser-playwright@4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) playwright: 1.59.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8)': + '@vitest/browser@4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/utils': 4.1.8 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) ws: 8.21.1 transitivePeerDependencies: - bufferutil @@ -19913,16 +20020,16 @@ snapshots: - vite optional: true - '@vitest/browser@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8)': + '@vitest/browser@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/utils': 4.1.8 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) ws: 8.21.1 transitivePeerDependencies: - bufferutil @@ -19930,7 +20037,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-istanbul@4.1.8(supports-color@8.1.1)(vitest@4.1.8)': + '@vitest/coverage-istanbul@4.1.10(supports-color@8.1.1)(vitest@4.1.10)': dependencies: '@babel/core': 7.29.6(supports-color@8.1.1) '@istanbuljs/schema': 0.1.3 @@ -19942,11 +20049,11 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - supports-color - '@vitest/coverage-istanbul@4.1.8(vitest@4.1.8)': + '@vitest/coverage-istanbul@4.1.10(vitest@4.1.10)': dependencies: '@babel/core': 7.29.6(supports-color@8.1.1) '@istanbuljs/schema': 0.1.3 @@ -19958,7 +20065,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -19970,26 +20077,26 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.8': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -19999,19 +20106,19 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.8': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 @@ -20019,7 +20126,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.10': {} '@vitest/utils@3.2.4': dependencies: @@ -20027,9 +20134,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -20188,6 +20295,12 @@ snapshots: adm-zip@0.5.16: {} + agent-base@6.0.2(supports-color@8.1.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -20226,7 +20339,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.0.1 + fast-uri: 4.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -20372,7 +20485,7 @@ snapshots: archunit@2.1.63: dependencies: '@zerollup/ts-helpers': 1.7.18(typescript@5.9.3) - minimatch: 10.2.4 + minimatch: 10.2.5 plantuml-parser: 0.4.0 typescript: 5.9.3 @@ -20499,20 +20612,22 @@ snapshots: axe-core@4.11.0: {} - axios@1.16.0: + axios@1.18.0(supports-color@8.1.1): dependencies: follow-redirects: 1.16.0(debug@4.4.3) form-data: 4.0.6 + https-proxy-agent: 5.0.1(supports-color@8.1.1) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color azurite@3.35.0(supports-color@8.1.1): dependencies: '@azure/ms-rest-js': 1.11.2 applicationinsights: 2.9.8 args: 5.0.3 - axios: 1.16.0 + axios: 1.18.0(supports-color@8.1.1) etag: 1.8.1 express: 4.22.2 fs-extra: 11.3.2 @@ -20559,7 +20674,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.29.6): dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.7 '@babel/core': 7.29.6(supports-color@8.1.1) '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.6) semver: 6.3.1 @@ -20964,7 +21079,7 @@ snapshots: chromatic@16.10.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 chrome-devtools-mcp@1.3.0: {} @@ -20980,6 +21095,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} classnames@2.5.1: {} @@ -21307,7 +21424,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.10) postcss-modules-values: 4.0.0(postcss@8.5.10) postcss-value-parser: 4.2.0 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) @@ -21801,6 +21918,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -21810,7 +21929,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -22051,7 +22170,7 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-uri@4.0.1: {} + fast-uri@4.1.1: {} fast-xml-builder@1.2.0: dependencies: @@ -22238,6 +22357,8 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -22349,20 +22470,20 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.0: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -22381,7 +22502,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.0 serialize-error: 7.0.1 global-dirs@3.0.1: @@ -22573,10 +22694,6 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -22830,6 +22947,13 @@ snapshots: https-browserify@1.0.0: {} + https-proxy-agent@5.0.1(supports-color@8.1.1): + dependencies: + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -22890,6 +23014,12 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 + import-in-the-middle@3.3.1: + dependencies: + cjs-module-lexer: 2.2.0 + es-module-lexer: 2.3.1 + module-details-from-path: 1.0.4 + import-lazy@4.0.0: {} imurmurhash@0.1.4: {} @@ -23007,7 +23137,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -23617,8 +23747,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.5: {} - lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -23639,8 +23767,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@2.1.0: @@ -23655,7 +23783,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 map-cache@0.2.2: {} @@ -24241,10 +24369,6 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.7 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -24286,7 +24410,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0 new-find-package-json: 2.0.0(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.0 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.1 @@ -24312,7 +24436,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0 new-find-package-json: 2.0.0(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.0 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.1 @@ -24952,7 +25076,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 minipass: 7.1.3 path-to-regexp@0.1.13: {} @@ -25214,7 +25338,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@6.0.3) jiti: 2.6.1 postcss: 8.5.10 - semver: 7.7.4 + semver: 7.8.0 webpack: 5.105.4(esbuild@0.28.1) transitivePeerDependencies: - typescript @@ -25490,7 +25614,7 @@ snapshots: dependencies: postcss: 8.5.10 postcss-value-parser: 4.2.0 - svgo: 3.3.3 + svgo: 3.3.4 postcss-unique-selectors@6.0.4(postcss@8.5.10): dependencies: @@ -25647,7 +25771,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 unpipe: 1.0.0 rc-resize-observer@1.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -25680,8 +25804,8 @@ snapshots: react-docgen@8.0.2: dependencies: '@babel/core': 7.29.6(supports-color@8.1.1) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -26042,6 +26166,13 @@ snapshots: transitivePeerDependencies: - supports-color + require-in-the-middle@8.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + require-like@0.1.2: {} requires-port@1.0.0: {} @@ -26258,7 +26389,7 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 semver@5.7.2: {} @@ -26266,8 +26397,6 @@ snapshots: semver@7.7.2: {} - semver@7.7.3: {} - semver@7.7.4: {} semver@7.8.0: {} @@ -26788,7 +26917,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 supports-color@5.5.0: @@ -26807,7 +26936,7 @@ snapshots: svg-parser@2.0.4: {} - svgo@3.3.3: + svgo@3.3.4: dependencies: commander: 7.2.0 css-select: 5.2.2 @@ -26915,13 +27044,13 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 10.2.4 + minimatch: 10.2.5 test-exclude@8.0.0: dependencies: '@istanbuljs/schema': 0.1.3 glob: 13.0.6 - minimatch: 10.2.4 + minimatch: 10.2.5 text-decoder@1.2.3: dependencies: @@ -26974,11 +27103,6 @@ snapshots: tinyexec@1.0.4: {} - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -27292,7 +27416,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.4 + semver: 7.8.0 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -27441,15 +27565,15 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -27466,22 +27590,22 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 22.19.15 - '@vitest/browser-playwright': 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/coverage-istanbul': 4.1.8(supports-color@8.1.1)(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/coverage-istanbul': 4.1.10(supports-color@8.1.1)(vitest@4.1.10) happy-dom: 20.10.1 jsdom: 26.1.0 transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -27498,8 +27622,8 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.1 - '@vitest/browser-playwright': 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/coverage-istanbul': 4.1.8(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.10(playwright@1.59.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.10) + '@vitest/coverage-istanbul': 4.1.10(vitest@4.1.10) happy-dom: 20.10.1 jsdom: 26.1.0 transitivePeerDependencies: @@ -27633,7 +27757,7 @@ snapshots: browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.20.0 - es-module-lexer: 2.0.0 + es-module-lexer: 2.3.1 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e613a6895..25e56ac5a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,11 @@ packages: engineStrict: true +# Disable pnpm 11's auto-install on script runs. CI explicitly runs +# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs +# from parallel turbo workers kill each other with SIGINT (#289). +verifyDepsBeforeRun: false + catalog: '@apollo/server': 5.5.0 '@azure/functions': 4.11.0 @@ -20,9 +25,9 @@ catalog: '@serenity-js/cucumber': 3.43.0 '@serenity-js/serenity-bdd': 3.43.0 '@types/node': ^22.19.5 - '@vitest/browser': 4.1.8 - '@vitest/browser-playwright': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 + '@vitest/browser': 4.1.10 + '@vitest/browser-playwright': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 antd: 6.3.5 archunit: ^2.1.63 esbuild: 0.28.1 @@ -47,7 +52,7 @@ catalog: typescript: 6.0.3 "@typescript/native-preview": 7.0.0-dev.20260428.1 vite: 8.0.16 - vitest: 4.1.8 + vitest: 4.1.10 vite-plugin-node-polyfills: ^0.28.0 auditConfig: @@ -70,10 +75,10 @@ allowBuilds: snyk: true overrides: - '@vitest/browser': 4.1.8 - '@vitest/browser-playwright': 4.1.8 + '@vitest/browser': 4.1.10 + '@vitest/browser-playwright': 4.1.10 happy-dom: 20.10.1 - axios: 1.16.0 + axios: 1.18.0 body-parser: 2.3.0 esbuild: 'catalog:' follow-redirects: ^1.16.0 @@ -88,7 +93,7 @@ overrides: 'serve-handler>minimatch': 3.1.5 'serialize-javascript@6.0.2': 7.0.5 'serialize-javascript@7.0.4': 7.0.5 - svgo: ^3.3.3 + svgo: ^3.3.4 'yaml@2.8.2': 2.8.3 'yauzl@3.2.0': 3.2.1 qs: 6.15.2 @@ -111,13 +116,16 @@ overrides: postcss: 8.5.10 protobufjs: 7.6.5 ip-address: ^10.1.1 - fast-uri: ^4.0.1 + fast-uri: ^4.1.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - 'js-yaml@>=4.0.0': 4.3.0 + js-yaml: 4.3.0 + 'js-yaml@3.14.2': 3.15.0 'shell-quote@<1.8.4': 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 + '@azure/monitor-opentelemetry-exporter>@azure/core-client': 1.11.0 + '@azure/monitor-opentelemetry-exporter>@azure/core-rest-pipeline': 1.25.0 ws: 8.21.1 websocket-driver: 0.7.5 shell-quote: 1.9.0 @@ -147,3 +155,4 @@ patchedDependencies: minimumReleaseAgeExclude: - fast-uri@3.1.3 || 4.0.1 + - body-parser@1.20.6 From a51feb21241d97658a4dd6ad324cb778ced3fe35 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 23 Jul 2026 13:57:07 -0400 Subject: [PATCH 13/15] upgrade opentemetry to support v2 --- packages/ocom/service-otel/package.json | 13 ++++++----- packages/ocom/service-otel/src/index.test.ts | 14 ++++++----- packages/ocom/service-otel/src/index.ts | 5 ++-- .../ocom/service-otel/src/otel-builder.ts | 23 ++++++++++--------- pnpm-workspace.yaml | 4 +--- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/packages/ocom/service-otel/package.json b/packages/ocom/service-otel/package.json index 18fb61910..0dc45ff5e 100644 --- a/packages/ocom/service-otel/package.json +++ b/packages/ocom/service-otel/package.json @@ -26,17 +26,18 @@ }, "dependencies": { "@azure/functions-opentelemetry-instrumentation": "0.1.0", - "@azure/monitor-opentelemetry-exporter": "1.0.0-beta.31", + "@azure/monitor-opentelemetry-exporter": "1.0.0-beta.43", "@cellix/api-services-spec": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/instrumentation-dataloader": "0.17.0", "@opentelemetry/instrumentation-graphql": "0.48.0", - "@opentelemetry/instrumentation-http": "0.52.1", + "@opentelemetry/instrumentation-http": "0.220.0", "@opentelemetry/instrumentation-mongoose": "0.47.0", - "@opentelemetry/sdk-logs": "0.57.2", - "@opentelemetry/sdk-metrics": "1.30.1", - "@opentelemetry/sdk-node": "0.57.2", - "@opentelemetry/sdk-trace-node": "1.30.1", + "@opentelemetry/sdk-logs": "0.220.0", + "@opentelemetry/sdk-metrics": "2.9.0", + "@opentelemetry/sdk-node": "0.220.0", + "@opentelemetry/sdk-trace-node": "2.9.0", + "@opentelemetry/resources": "2.9.0", "@opentelemetry/semantic-conventions": "1.28.0" }, "devDependencies": { diff --git a/packages/ocom/service-otel/src/index.test.ts b/packages/ocom/service-otel/src/index.test.ts index 0b7eed41c..1cfaf6571 100644 --- a/packages/ocom/service-otel/src/index.test.ts +++ b/packages/ocom/service-otel/src/index.test.ts @@ -5,20 +5,23 @@ import { expect, vi } from 'vitest'; import { ServiceOtel } from './index.ts'; const test = { for: describeFeature }; -vi.mock('@opentelemetry/sdk-node', () => { - // Mock Resource class and resources namespace +vi.mock('@opentelemetry/resources', () => { class Resource { private attributes: Record; constructor(attrs: Record) { this.attributes = attrs; } - static default() { - return new Resource({ default: true }); - } merge(other: Resource) { return new Resource({ ...this.attributes, ...other.attributes }); } } + return { + defaultResource: () => new Resource({ default: true }), + resourceFromAttributes: (attributes: Record) => new Resource(attributes), + }; +}); + +vi.mock('@opentelemetry/sdk-node', () => { // Mock NodeSDK and track config let lastConfig: unknown; class NodeSDK { @@ -33,7 +36,6 @@ vi.mock('@opentelemetry/sdk-node', () => { } return { NodeSDK, - resources: { Resource }, }; }); diff --git a/packages/ocom/service-otel/src/index.ts b/packages/ocom/service-otel/src/index.ts index 4a456eae5..fa8389a95 100644 --- a/packages/ocom/service-otel/src/index.ts +++ b/packages/ocom/service-otel/src/index.ts @@ -1,4 +1,5 @@ import type { SyncServiceBase } from '@cellix/api-services-spec'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; import * as opentelemetry from '@opentelemetry/sdk-node'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { OtelBuilder } from './otel-builder.ts'; @@ -30,8 +31,8 @@ export class ServiceOtel implements SyncServiceBase { metricReader: metricReader, instrumentations: instrumentations, - resource: opentelemetry.resources.Resource.default().merge( - new opentelemetry.resources.Resource({ + resource: defaultResource().merge( + resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'Cellix Demo', [ATTR_SERVICE_VERSION]: '1.0.0', }), diff --git a/packages/ocom/service-otel/src/otel-builder.ts b/packages/ocom/service-otel/src/otel-builder.ts index cd10147dc..e32ee943a 100644 --- a/packages/ocom/service-otel/src/otel-builder.ts +++ b/packages/ocom/service-otel/src/otel-builder.ts @@ -1,16 +1,16 @@ -import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; -import { AzureMonitorTraceExporter, AzureMonitorMetricExporter, AzureMonitorLogExporter } from '@azure/monitor-opentelemetry-exporter'; -import { BatchLogRecordProcessor, SimpleLogRecordProcessor, ConsoleLogRecordExporter } from '@opentelemetry/sdk-logs'; -import { BatchSpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter, type Sampler, SamplingDecision, ParentBasedSampler, AlwaysOnSampler } from '@opentelemetry/sdk-trace-node'; -import { PeriodicExportingMetricReader, ConsoleMetricExporter } from '@opentelemetry/sdk-metrics'; -import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import azureOtel from '@azure/functions-opentelemetry-instrumentation'; +import { AzureMonitorLogExporter, AzureMonitorMetricExporter, AzureMonitorTraceExporter } from '@azure/monitor-opentelemetry-exporter'; +import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; import { DataloaderInstrumentation } from '@opentelemetry/instrumentation-dataloader'; import { GraphQLInstrumentation } from '@opentelemetry/instrumentation-graphql'; -import azureOtel from '@azure/functions-opentelemetry-instrumentation'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose'; - +import { BatchLogRecordProcessor, ConsoleLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs'; +import { ConsoleMetricExporter, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; +import { AlwaysOnSampler, BatchSpanProcessor, ConsoleSpanExporter, ParentBasedSampler, type Sampler, SamplingDecision, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; import { httpInstrumentationConfig } from './http-config.ts'; -import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; + const { AzureFunctionsInstrumentation } = azureOtel; //Need to use destructuring to access AzureFunctionsInstrumentation (CommonJS module) interface Exporters { @@ -56,7 +56,7 @@ export class OtelBuilder { if (useSimpleProcessors) { return { spanProcessors: [new SimpleSpanProcessor(exporters.traceExporter)], - logRecordProcessors: [new SimpleLogRecordProcessor(exporters.logExporter)], + logRecordProcessors: [new SimpleLogRecordProcessor({ exporter: exporters.logExporter })], }; } else { const EXPORT_TIMEOUT_MILLIS = 15000; @@ -69,7 +69,8 @@ export class OtelBuilder { }), ], logRecordProcessors: [ - new BatchLogRecordProcessor(exporters.logExporter, { + new BatchLogRecordProcessor({ + exporter: exporters.logExporter, exportTimeoutMillis: EXPORT_TIMEOUT_MILLIS, maxQueueSize: MAX_QUEUE_SIZE, }), diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 25e56ac5a..61b6ad1ba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -113,7 +113,7 @@ overrides: 'ws@8.20.0': 8.21.0 playwright-core: 1.59.0 playwright: 1.59.0 - postcss: 8.5.10 + postcss: 8.5.12 protobufjs: 7.6.5 ip-address: ^10.1.1 fast-uri: ^4.1.1 @@ -124,8 +124,6 @@ overrides: 'shell-quote@<1.8.4': 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 - '@azure/monitor-opentelemetry-exporter>@azure/core-client': 1.11.0 - '@azure/monitor-opentelemetry-exporter>@azure/core-rest-pipeline': 1.25.0 ws: 8.21.1 websocket-driver: 0.7.5 shell-quote: 1.9.0 From 091a4adda62d51b776b16e81312f49c5cd934d46 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 23 Jul 2026 14:09:04 -0400 Subject: [PATCH 14/15] synchronize lock file with pnpm-workspace.yml --- pnpm-lock.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6683fedf..d440d4a84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,8 +171,6 @@ overrides: shell-quote@<1.8.4: 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 - '@azure/monitor-opentelemetry-exporter>@azure/core-client': 1.11.0 - '@azure/monitor-opentelemetry-exporter>@azure/core-rest-pipeline': 1.25.0 ws: 8.21.1 websocket-driver: 0.7.5 shell-quote: 1.9.0 From aa6c1e3564608d1241bd1546b0123b25b15d0cb0 Mon Sep 17 00:00:00 2001 From: aaron-rab Date: Thu, 23 Jul 2026 14:55:16 -0400 Subject: [PATCH 15/15] refactor e2e tasks to use interactions; refactor acceptance-api create-member task to use interaction --- .../community/questions/community-id-named.ts | 23 +++ .../community/questions/member-status.ts | 32 ---- .../assign-member-account.steps.ts | 10 +- .../step-definitions/create-member.steps.ts | 64 +++---- .../step-definitions/list-members.steps.ts | 27 +-- .../step-definitions/member-role.steps.ts | 15 +- .../step-definitions/remove-member.steps.ts | 25 +-- .../step-definitions/update-member.steps.ts | 15 +- .../contexts/community/tasks/create-member.ts | 60 +++---- .../src/shared/abilities/create-community.ts | 8 +- .../src/shared/abilities/create-member.ts | 89 ---------- .../shared/interactions/execute-graphql.ts | 15 ++ .../shared/questions/last-graphql-response.ts | 21 +++ .../contexts/community/notes/member-notes.ts | 2 - .../questions/displayed-member-role.ts | 8 - .../step-definitions/create-member.steps.tsx | 2 +- .../community/step-definitions/index.ts | 1 - .../step-definitions/member-role.steps.tsx | 39 ----- .../community/tasks/update-member-role.tsx | 64 ------- .../src/shared/page-contracts.ts | 2 +- .../abilities/community-portal-page.ts | 55 ++++++ .../fill-and-submit-member-create-form.ts | 31 ++++ .../fill-and-submit-member-profile-form.ts | 29 ++++ .../interactions/fill-member-create-form.ts | 10 ++ .../interactions/fill-member-profile-form.ts | 17 ++ .../open-community-member-list.ts | 13 ++ .../interactions/open-create-member-form.ts | 13 ++ .../open-member-account-assignment.ts | 18 ++ .../open-member-profile-editor.ts | 18 ++ .../community/interactions/remove-member.ts | 42 +++++ ...olve-community-administrator-membership.ts | 46 +++++ .../interactions/search-member-list.ts | 8 + .../select-member-end-user-account.ts | 8 + .../submit-member-account-assignment.ts | 20 +++ .../contexts/community/notes/member-notes.ts | 10 +- .../community/questions/community-id.ts | 12 ++ .../questions/member-account-linked.ts | 29 +++- .../questions/member-created-flag.ts | 4 - .../questions/member-created-in-community.ts | 19 +++ .../questions/member-listed-in-community.ts | 4 +- .../questions/member-profile-matches.ts | 37 ++++ .../questions/member-removed-flag.ts | 4 - .../member-removed-from-community.ts | 17 ++ .../community/questions/route-member-id.ts | 12 ++ .../assign-member-account.steps.ts | 17 +- .../step-definitions/create-member.steps.ts | 20 +-- .../community/step-definitions/index.ts | 1 - .../step-definitions/member-role.steps.ts | 41 ----- .../step-definitions/remove-member.steps.ts | 17 +- .../step-definitions/update-member.steps.ts | 9 +- .../community/tasks/assign-member-account.ts | 85 ++-------- .../contexts/community/tasks/create-member.ts | 149 ++--------------- .../contexts/community/tasks/list-members.ts | 76 ++------- .../contexts/community/tasks/remove-member.ts | 158 +----------------- .../community/tasks/search-member-list.ts | 20 +-- .../community/tasks/update-member-profile.ts | 149 ++--------------- .../community/tasks/update-member-role.ts | 117 ------------- .../src/servers/test-mongo-server.ts | 2 +- .../e2e-tests/src/shared/page-contracts.ts | 13 +- .../src/pages/member-accounts.page.ts | 14 ++ .../src/pages/member-create.page.ts | 2 +- .../src/pages/member-list.page.ts | 4 + .../src/pages/member-profile.page.ts | 42 +++++ .../community/member/member-lifecycle.feature | 3 +- 64 files changed, 765 insertions(+), 1172 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-id-named.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/interactions/execute-graphql.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/questions/last-graphql-response.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts delete mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx delete mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-create-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-profile-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-create-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-profile-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-member-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-member-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-account-assignment.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-profile-editor.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/remove-member.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/resolve-community-administrator-membership.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/search-member-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/select-member-end-user-account.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-member-account-assignment.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-id.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-in-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-profile-matches.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-from-community.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/questions/route-member-id.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-id-named.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-id-named.ts new file mode 100644 index 000000000..865ab6077 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-id-named.ts @@ -0,0 +1,23 @@ +import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import type { MemberNotes } from '../notes/member-notes.ts'; + +/** Resolves the ID of a community created earlier in the current scenario. */ +export class CommunityIdNamed extends Question> { + static called(communityName: string): CommunityIdNamed { + return new CommunityIdNamed(communityName); + } + + private constructor(private readonly communityName: string) { + super(`ID of community "${communityName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + const communityId = communityIdsByName[this.communityName]; + if (!communityId) { + throw new Error(`Unknown community "${this.communityName}". Ensure it was created in setup.`); + } + + return communityId; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts deleted file mode 100644 index 1097efd46..000000000 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-status.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; -import type { MemberNotes } from '../notes/member-notes.ts'; - -export class MemberStatus extends Question> { - constructor() { - super('member status'); - } - - static of(): MemberStatus { - return new MemberStatus(); - } - - override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { - const notedStatus = await this.readNote(actor, 'lastMemberStatus'); - if (!notedStatus) { - throw new Error('No member status found in actor notes. Did the actor create a member first?'); - } - return notedStatus; - } - - override toString(): string { - return 'the member status'; - } - - private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof MemberNotes): Promise { - try { - return await actor.answer(notes>().get(key)); - } catch { - return undefined; - } - } -} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts index 57f08046e..8a9c65613 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -2,6 +2,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; import { EndUserIdInCommunity } from '../questions/end-user-id-in-community.ts'; import { MemberAccountCount, MemberAccountLinked } from '../questions/member-account-linked.ts'; import { AssignMemberAccount } from '../tasks/assign-member-account.ts'; @@ -12,10 +13,9 @@ const accountDisplayNames: Record = { Given('end-user account {string} is available for assignment in {string}', async (accountLabel: string, communityName: string) => { const actor = actorInTheSpotlight(); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); const displayName = accountDisplayNames[accountLabel]; - if (!communityId || !displayName) { + if (!displayName) { throw new Error(`Unknown community or end-user account label: "${communityName}" / "${accountLabel}"`); } const endUserId = await actor.answer(EndUserIdInCommunity.withDisplayName(communityId, displayName)); @@ -25,12 +25,12 @@ Given('end-user account {string} is available for assignment in {string}', async When('{word} associates end-user account {string} to member {string} in {string}', async (actorName: string, accountLabel: string, _memberName: string, communityName: string) => { const actor = actorCalled(actorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); const endUserIds = await actor.answer(notes().get('endUserIdsByLabel')); const memberId = await actor.answer(notes().get('lastMemberId')); + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); await actor.attemptsTo(notes().set('lastValidationError', undefined as unknown as string)); try { - await actor.attemptsTo(AssignMemberAccount.to({ communityId: communityIds[communityName] ?? '', memberId, endUserId: endUserIds[accountLabel] ?? '' })); + await actor.attemptsTo(AssignMemberAccount.to({ communityId, memberId, endUserId: endUserIds[accountLabel] ?? '' })); } catch (error) { await actor.attemptsTo(notes().set('lastValidationError', error instanceof Error ? error.message : String(error))); } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts index ae4c7c9e2..2805fdaa8 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -1,9 +1,13 @@ import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; +import { Ensure, equals, isPresent } from '@serenity-js/assertions'; import { actorCalled, notes } from '@serenity-js/core'; +import { LastGraphQLResponse } from '../../../shared/questions/last-graphql-response.ts'; import type { MemberNotes } from '../notes/member-notes.ts'; -import { MemberStatus } from '../questions/member-status.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; +import { MemberById } from '../questions/member-by-id.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; import { CreateMember } from '../tasks/create-member.ts'; @@ -44,55 +48,50 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm const memberName = details.memberName?.trim() ?? ''; - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - await actor.attemptsTo( - CreateMember.with({ - memberName, - communityId, - }), - ); + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(communityId)); + await actor.attemptsTo(CreateMember({ memberName, communityId, principalMemberId })); }); When('{word} attempts to create a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); - await actor.attemptsTo( - notes().set('lastMemberId', undefined as unknown as string), - notes().set('lastMemberStatus', undefined as unknown as string), - notes().set('lastValidationError', undefined as unknown as string), - ); + await actor.attemptsTo(notes().set('lastMemberId', undefined as unknown as string), notes().set('lastValidationError', undefined as unknown as string)); try { - await actor.attemptsTo(CreateMember.with({ memberName: details.memberName?.trim() ?? '', communityId })); + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(communityId)); + await actor.attemptsTo(CreateMember({ memberName: details.memberName?.trim() ?? '', communityId, principalMemberId })); } catch (error) { await actor.attemptsTo(notes().set('lastValidationError', error instanceof Error ? error.message : String(error))); } }); -Then('the member should be created successfully in {string}', async (_communityName: string) => { +Then('the member should be created successfully in {string}', async (communityName: string) => { const actor = actorCalled(lastActorName); - const status = await actor.answer(MemberStatus.of()); - if (status !== 'SUCCESS') { - throw new Error(`Expected member status "SUCCESS" but got "${status}"`); - } + await actor.attemptsTo(Ensure.that(LastGraphQLResponse.field('memberCreate.status.success'), equals(true)), Ensure.that(LastGraphQLResponse.field('memberCreate.member.id'), isPresent())); + + const memberId = await actor.answer(LastGraphQLResponse.field('memberCreate.member.id')); + const memberName = await actor.answer(notes().get('lastMemberName')); + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); + const persistedMember = await actor.answer(MemberById.withId(memberId)); + + await actor.attemptsTo( + Ensure.that(persistedMember, isPresent()), + Ensure.that(persistedMember?.id, equals(memberId)), + Ensure.that(persistedMember?.memberName, equals(memberName)), + Ensure.that(persistedMember?.community?.id, equals(communityId)), + ); }); Then('she should see a member error for {string}', async (fieldName: string) => { const actor = actorCalled(lastActorName); - const error = await actor.answer(notes().get('lastValidationError')).catch(() => ''); + const responseError = await actor.answer(LastGraphQLResponse.field('memberCreate.status.errorMessage')).catch(() => ''); + const capturedError = await actor.answer(notes().get('lastValidationError')).catch(() => ''); + const error = responseError || capturedError; const isFieldMentioned = error.toLowerCase().includes(fieldName.toLowerCase()); if (!error || (!isFieldMentioned && !/cannot be empty|required|missing|invalid|must not be empty|too short|too long|already associated/i.test(error))) { throw new Error(`Expected a validation error related to "${fieldName}", but got: "${error}"`); @@ -102,8 +101,9 @@ Then('she should see a member error for {string}', async (fieldName: string) => Then('no new member should be created in {string}', async (_communityName: string) => { const actor = actorCalled(lastActorName); const memberId = await actor.answer(notes().get('lastMemberId')).catch(() => ''); - const validationError = await actor.answer(notes().get('lastValidationError')).catch(() => ''); - if (memberId || !validationError) { + const success = await actor.answer(LastGraphQLResponse.field('memberCreate.status.success')).catch(() => undefined); + const capturedError = await actor.answer(notes().get('lastValidationError')).catch(() => ''); + if (memberId || success === true || (success === undefined && !capturedError)) { throw new Error('Expected member creation to be blocked by validation'); } }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts index fabdac63b..519efdec1 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts @@ -1,55 +1,44 @@ import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; import { MemberVisibleInList } from '../questions/member-visible-in-list.ts'; +import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; import { CreateMember } from '../tasks/create-member.ts'; import { ListMembers } from '../tasks/list-members.ts'; Given('{word} is an authenticated community admin for {string}', async (actorName: string, communityName: string) => { const owner = actorInTheSpotlight(); + await owner.answer(CommunityIdNamed.called(communityName)); const communityIdsByName = await owner.answer(notes().get('communityIdsByName')); - if (!communityIdsByName[communityName]) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } await actorCalled(actorName).attemptsTo(notes().set('communityIdsByName', communityIdsByName)); }); Given('the following members exist in {string}:', async (communityName: string, dataTable: DataTable) => { const actor = actorInTheSpotlight(); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); for (const details of dataTable.hashes()) { const { memberName } = details; if (!memberName) { throw new Error('memberName is required when creating listed members'); } - await actor.attemptsTo(CreateMember.with({ memberName: memberName.trim(), communityId })); + const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(communityId)); + await actor.attemptsTo(CreateMember({ memberName: memberName.trim(), communityId, principalMemberId })); } }); When('{word} lists members for {string}', async (actorName: string, communityName: string) => { const actor = actorCalled(actorName); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); await actor.attemptsTo(ListMembers.inCommunity(communityId)); }); When('{word} searches the member list in {string} for {string}', async (actorName: string, communityName: string, searchTerm: string) => { const actor = actorCalled(actorName); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); await actor.attemptsTo(ListMembers.inCommunity(communityId, searchTerm)); }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts index 1f555d79c..30b8f6667 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/member-role.steps.ts @@ -2,6 +2,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; import { MemberRoleName } from '../questions/member-role-name.ts'; import { SeedEndUserRole } from '../tasks/seed-end-user-role.ts'; import { UpdateMemberRole } from '../tasks/update-member-role.ts'; @@ -10,33 +11,29 @@ let lastActorName = actors.CommunityOwner.name; Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { const actor = actorInTheSpotlight(); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; - if (!communityId) throw new Error(`Unknown community "${communityName}"`); + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); await actor.attemptsTo(SeedEndUserRole.named(roleName, communityName, communityId)); }); When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); - const communityId = communityIds[communityName]; + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); const roleId = roleIds[communityName]?.[roleName]; - if (!communityId || !roleId) throw new Error(`Missing role "${roleName}" in "${communityName}"`); + if (!roleId) throw new Error(`Missing role "${roleName}" in "${communityName}"`); await actor.attemptsTo(UpdateMemberRole.with(roleId, communityId)); }); When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); - const communityId = communityIds[communityName]; + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); const roleId = Object.values(roleIds) .map((roles) => roles[roleName]) .find(Boolean); - if (!communityId || !roleId) throw new Error(`Missing role "${roleName}"`); + if (!roleId) throw new Error(`Missing role "${roleName}"`); try { await actor.attemptsTo(UpdateMemberRole.with(roleId, communityId)); } catch (error) { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts index 466df1bc8..1d634071e 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts @@ -2,6 +2,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import type { MemberNotes } from '../notes/member-notes.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; import { HasNoMemberForCommunity } from '../questions/has-no-member-for-community.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; @@ -12,13 +13,10 @@ let managementOwnerActorName = actors.CommunityOwner.name; Given('{word} is signed in without membership in {string}', async (actorName: string, communityName: string) => { const owner = actorInTheSpotlight(); managementOwnerActorName = owner.name; + const communityId = await owner.answer(CommunityIdNamed.called(communityName)); const communityIds = await owner.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; const targetMemberId = await owner.answer(notes().get('lastMemberId')); const targetMemberName = await owner.answer(notes().get('lastMemberName')); - if (!communityId) { - throw new Error(`Unknown community "${communityName}"`); - } const actor = actorCalled(actorName); const authorizationToken = actors.CommunityMember.email; @@ -39,10 +37,9 @@ Given('{word} is signed in without membership in {string}', async (actorName: st When('{word} removes member {string} from {string}', async (actorName: string, _memberName: string, communityName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); const memberId = await actor.answer(notes().get('lastMemberId')); - const communityId = communityIds[communityName]; - if (!communityId || !memberId) { + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); + if (!memberId) { throw new Error(`Missing member or community state for removal from "${communityName}"`); } await actor.attemptsTo(notes().set('lastMemberRemoved', false), RemoveMember.withId(memberId, communityId)); @@ -58,11 +55,7 @@ Then('the member should be removed successfully from {string}', async (_communit Then('member {string} should not appear in member listings for {string}', async (memberName: string, communityName: string) => { const actor = actorCalled(lastActorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}"`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); if (await actor.answer(MemberListedInCommunity.named(memberName, communityId))) { throw new Error(`Expected member "${memberName}" not to appear in listings for "${communityName}"`); } @@ -71,10 +64,10 @@ Then('member {string} should not appear in member listings for {string}', async When('{word} attempts to remove member {string} from {string}', async (actorName: string, _memberName: string, communityName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - const communityIds = await actor.answer(notes().get('communityIdsByName')); const memberId = await actor.answer(notes().get('lastMemberId')); + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); try { - await actor.attemptsTo(RemoveMember.withId(memberId, communityIds[communityName] ?? '')); + await actor.attemptsTo(RemoveMember.withId(memberId, communityId)); } catch (error) { await actor.attemptsTo(notes().set('lastAuthorizationError', error instanceof Error ? error.message : String(error))); } @@ -91,8 +84,8 @@ Then('{word} should receive an authorization error for member management', async Then('member {string} should remain in {string}', async (memberName: string, communityName: string) => { const owner = actorCalled(managementOwnerActorName); - const communityIds = await owner.answer(notes().get('communityIdsByName')); - if (!(await owner.answer(MemberListedInCommunity.named(memberName, communityIds[communityName] ?? '')))) { + const communityId = await owner.answer(CommunityIdNamed.called(communityName)); + if (!(await owner.answer(MemberListedInCommunity.named(memberName, communityId)))) { throw new Error(`Expected member "${memberName}" to remain in "${communityName}"`); } }); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts index 7a64a8fe2..1da721cc6 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-member.steps.ts @@ -3,6 +3,7 @@ import { type DataTable, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import type { MemberNotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { CommunityIdNamed } from '../questions/community-id-named.ts'; import { MemberById, type MemberByIdResult } from '../questions/member-by-id.ts'; import { UpdateMember } from '../tasks/update-member-profile.ts'; @@ -27,12 +28,7 @@ When('{word} updates member {string} in {string} with:', async (actorName: strin const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record); - - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const communityId = await actor.answer(CommunityIdNamed.called(communityName)); const expectedProfile: MemberProfileExpectation = { ...(details.name ? { name: details.name } : {}), @@ -70,11 +66,7 @@ Then('the member should be updated successfully in {string}', async (communityNa const status = await actor.answer(notes().get('lastMemberStatus')); const memberId = await actor.answer(notes().get('lastMemberId')); const validationError = await actor.answer(notes().get('lastValidationError')); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - - if (!communityIdsByName[communityName]) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } + const expectedCommunityId = await actor.answer(CommunityIdNamed.called(communityName)); if (validationError) { throw new Error(`Expected member update to succeed for "${communityName}", but got validation error: "${validationError}"`); @@ -93,7 +85,6 @@ Then('the member should be updated successfully in {string}', async (communityNa throw new Error(`Expected member "${memberId}" to exist after update, but no member was returned by API`); } - const expectedCommunityId = communityIdsByName[communityName]; if (String(updatedMember.community?.id ?? '') !== String(expectedCommunityId)) { throw new Error(`Expected updated member to belong to "${communityName}"`); } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts index c63f3ce65..a40fd9f7a 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts @@ -1,46 +1,24 @@ -import { type Actor, notes, Task } from '@serenity-js/core'; -import { CreateMember as CreateMemberAbility, type CreateMemberDetails } from '../../../shared/abilities/create-member.ts'; +import { notes, Task } from '@serenity-js/core'; +import { MEMBER_CREATE_MUTATION } from '../../../shared/graphql/member-operations.ts'; +import { ExecuteGraphQL } from '../../../shared/interactions/execute-graphql.ts'; +import { LastGraphQLResponse } from '../../../shared/questions/last-graphql-response.ts'; import type { MemberNotes } from '../notes/member-notes.ts'; -import { PrincipalMemberIdForCommunity } from '../questions/principal-member-id-for-community.ts'; -interface CreateMemberTaskDetails { +interface CreateMemberDetails { memberName: string; - communityId?: string; + communityId: string; + principalMemberId: string; } -export class CreateMember extends Task { - static withName(memberName: string) { - return new CreateMember({ memberName }); - } - - static with(details: CreateMemberTaskDetails) { - return new CreateMember(details); - } - - private constructor(private readonly details: CreateMemberTaskDetails) { - super(`creates a member named "${details.memberName}"`); - } - - async performAs(actor: Actor): Promise { - if (!this.details.communityId) { - throw new Error('communityId is required to create a member'); - } - - const principalMemberId = await actor.answer(PrincipalMemberIdForCommunity.inCommunity(this.details.communityId)); - const createDetails: CreateMemberDetails = { - memberName: this.details.memberName, - communityId: this.details.communityId, - principalMemberId, - }; - const member = await CreateMemberAbility.as(actor).performAs(actor, createDetails); - - await actor.attemptsTo( - notes().set('lastMemberId', member.id ?? ''), - notes().set('lastMemberCommunityId', this.details.communityId), - notes().set('lastMemberName', member.memberName), - notes().set('lastMemberStatus', 'SUCCESS'), - ); - } - - override toString = () => `creates a member named "${this.details.memberName}"`; -} +export const CreateMember = (details: CreateMemberDetails): Task => + Task.where( + `#actor creates a member named "${details.memberName}"`, + ExecuteGraphQL.operation( + MEMBER_CREATE_MUTATION, + { input: { memberName: details.memberName, communityId: details.communityId } }, + { headers: { 'x-community-id': details.communityId, 'x-member-id': details.principalMemberId } }, + ), + notes().set('lastMemberId', LastGraphQLResponse.field('memberCreate.member.id')), + notes().set('lastMemberCommunityId', details.communityId), + notes().set('lastMemberName', LastGraphQLResponse.field('memberCreate.member.memberName')), + ); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts index 90268b188..2106eed6a 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-community.ts @@ -36,10 +36,10 @@ export class CreateCommunity extends Ability { export function createCommunityAbility(): CreateCommunity { return CreateCommunity.using(async (actor, details) => { const graphql = GraphQLClient.as(actor); - // const currentEndUserResponse = await graphql.execute(CURRENT_END_USER_QUERY); - // if (!(currentEndUserResponse.data['currentEndUserAndCreateIfNotExists'] as Record | undefined)?.['id']) { - // throw new Error('API did not establish the authenticated end user before community creation'); - // } + const currentEndUserResponse = await graphql.execute(CURRENT_END_USER_QUERY); + if (!(currentEndUserResponse.data['currentEndUserAndCreateIfNotExists'] as Record | undefined)?.['id']) { + throw new Error('API did not establish the authenticated end user before community creation'); + } const response = await graphql.execute(COMMUNITY_CREATE_MUTATION, { input: { name: details.name }, }); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts deleted file mode 100644 index 8ca0323db..000000000 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; -import { Ability, type Actor } from '@serenity-js/core'; -import { MEMBER_CREATE_MUTATION } from '../graphql/member-operations.ts'; - -/** Member details accepted by API verification flows. */ -export interface CreateMemberDetails { - memberName: string; - communityId: string; - principalMemberId: string; -} - -/** Result returned by the API member creation flow. */ -interface CreateMemberResult { - id?: string; - memberName: string; -} - -/** Handler that performs member creation for an API actor. */ -type CreateMemberHandler = (actor: Actor, details: CreateMemberDetails) => Promise; - -type ExecuteWithRequestOptions = = Record>( - query: string, - variables?: Record, - options?: { headers?: Record }, -) => Promise<{ data: TData; errors?: Array<{ message?: string }> }>; - -/** - * Serenity ability that creates members through the API verification server. - */ -export class CreateMember extends Ability { - constructor(private readonly handler: CreateMemberHandler) { - super(); - } - - static using(handler: CreateMemberHandler): CreateMember { - return new CreateMember(handler); - } - - async performAs(actor: Actor, details: CreateMemberDetails): Promise { - return await this.handler(actor, details); - } -} - -export function createMemberAbility(): CreateMember { - return CreateMember.using(async (actor, details) => { - const graphql = GraphQLClient.as(actor) as GraphQLClient & { execute: ExecuteWithRequestOptions }; - if (!details.communityId) { - throw new Error('communityId is required to create a member'); - } - if (!details.principalMemberId) { - throw new Error('principalMemberId is required to create a member'); - } - - const response = await graphql.execute( - MEMBER_CREATE_MUTATION, - { - input: { - memberName: details.memberName, - communityId: details.communityId, - }, - }, - { - headers: { - 'x-community-id': details.communityId, - 'x-member-id': details.principalMemberId, - }, - }, - ); - - const mutationResult = response.data?.['memberCreate'] as Record | undefined; - const status = mutationResult?.['status'] as Record | undefined; - const member = mutationResult?.['member'] as Record | undefined; - - if (status?.['success'] !== true) { - throw new Error(String(status?.['errorMessage'] ?? 'Failed to create member')); - } - - const memberId = String(member?.['id'] ?? ''); - const createdName = String(member?.['memberName'] ?? ''); - if (!memberId) { - throw new Error('API memberCreate returned a member without an id'); - } - - return { - id: memberId, - memberName: createdName || details.memberName, - }; - }); -} diff --git a/packages/ocom-verification/acceptance-api/src/shared/interactions/execute-graphql.ts b/packages/ocom-verification/acceptance-api/src/shared/interactions/execute-graphql.ts new file mode 100644 index 000000000..9d10151d1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/interactions/execute-graphql.ts @@ -0,0 +1,15 @@ +import { GraphQLClient, type GraphQLExecuteOptions, type GraphQLResponse } from '@cellix/serenity-framework/clients/graphql'; +import { Interaction, TakeNotes } from '@serenity-js/core'; + +export interface GraphQLResponseNotes { + lastGraphQLResponse: GraphQLResponse; +} + +/** Executes a GraphQL operation and records its response for the current actor. */ +export const ExecuteGraphQL = { + operation: (query: string, variables?: Record, options?: GraphQLExecuteOptions) => + Interaction.where('#actor executes a GraphQL operation', async (actor) => { + const response = await GraphQLClient.as(actor).execute(query, variables, options); + TakeNotes.as(actor).notepad.set('lastGraphQLResponse', response); + }), +} as const; diff --git a/packages/ocom-verification/acceptance-api/src/shared/questions/last-graphql-response.ts b/packages/ocom-verification/acceptance-api/src/shared/questions/last-graphql-response.ts new file mode 100644 index 000000000..0b269bf6a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/questions/last-graphql-response.ts @@ -0,0 +1,21 @@ +import { notes, Question } from '@serenity-js/core'; +import type { GraphQLResponseNotes } from '../interactions/execute-graphql.ts'; + +/** Reads a field from the GraphQL response most recently recorded for the actor. */ +export const LastGraphQLResponse = { + field: (path: string) => + Question.about(`the "${path}" field of the last GraphQL response`, async (actor) => { + const response = await actor.answer(notes().get('lastGraphQLResponse')); + return getByPath(response.data, path) as TValue; + }), +} as const; + +function getByPath(value: unknown, path: string): unknown { + return path.split('.').reduce((current, segment) => { + if (!current || typeof current !== 'object') { + return undefined; + } + + return (current as Record)[segment]; + }, value); +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts index 82a59e99c..3a7fab4a4 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -1,9 +1,7 @@ export interface MemberUiNotes { endUserIdsByLabel: Record; membersByCommunityName: Record; - rolesByCommunityName: Record; lastMemberId: string; - lastMemberRoleName: string; lastLinkedEndUserId: string; memberCreated: boolean; memberRemoved: boolean; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts deleted file mode 100644 index d8862839e..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/displayed-member-role.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; -import { type Actor, Question } from '@serenity-js/core'; - -export const DisplayedMemberRole = (roleName: string) => - Question.about(`whether the member detail view displays role "${roleName}"`, (serenityActor) => { - const actor = serenityActor as unknown as Actor; - return (RenderInDom.as(actor).container.textContent ?? '').includes(roleName); - }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx index cdc265dda..6a049401a 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -99,7 +99,7 @@ When('{word} attempts to create a member in {string} with:', async (actorName: s const details = GherkinDataTable.from(dataTable).rowsHash(); await actor.attemptsTo(notes().set('memberCreated', false), notes().set('memberValidationError', ''), CreateMember(details.memberName?.trim() ?? '')); const page: AcceptanceUiMemberCreatePage = new MemberCreatePage(new DomPageAdapter(RenderInDom.as(actor).container)); - await actor.attemptsTo(notes().set('memberValidationError', (await page.firstValidationError.textContent()) ?? '')); + await actor.attemptsTo(notes().set('memberValidationError', (await page.validationError.textContent()) ?? '')); }); Then('she should see a member error for {string}', async (fieldName: string) => { diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index 5b89bdd89..4d3489e7d 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -2,7 +2,6 @@ import './create-community.steps.tsx'; import './create-member.steps.tsx'; import './list-members.steps.tsx'; -import './member-role.steps.tsx'; import './remove-member.steps.tsx'; import './assign-member-account.steps.tsx'; import './update-member.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx deleted file mode 100644 index 6a309be28..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/member-role.steps.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Given, Then, When } from '@cucumber/cucumber'; -import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; -import type { MemberUiNotes } from '../notes/member-notes.ts'; -import { DisplayedMemberRole } from '../questions/displayed-member-role.ts'; -import { SeedEndUserRole, UpdateMemberRole } from '../tasks/update-member-role.tsx'; - -let lastActorName = ''; - -Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { - await actorInTheSpotlight().attemptsTo(SeedEndUserRole(roleName, communityName)); -}); - -When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { - lastActorName = actorName; - await actorCalled(actorName).attemptsTo(UpdateMemberRole.with(roleName, communityName)); -}); - -When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - try { - await actor.attemptsTo(UpdateMemberRole.with(roleName, communityName)); - } catch (error) { - await actor.attemptsTo(notes().set('memberValidationError', error instanceof Error ? error.message : String(error))); - } -}); - -Then('member {string} should have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { - if (!(await actorCalled(lastActorName).answer(DisplayedMemberRole(roleName)))) { - throw new Error(`Expected member role "${roleName}" to be displayed`); - } -}); - -Then('member {string} should not have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { - const actor = actorCalled(lastActorName); - if (await actor.answer(DisplayedMemberRole(roleName))) { - throw new Error(`Expected member not to display role "${roleName}"`); - } -}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx deleted file mode 100644 index 574064782..000000000 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-member-role.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; -import { TaskStep } from '@cellix/serenity-framework/serenity'; -import { type Actor, notes, Task } from '@serenity-js/core'; -import React from 'react'; -import { MembersDetail } from '../../../../../../ocom/ui-community-route-admin/src/components/members-detail.tsx'; -import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; -import type { MemberUiNotes } from '../notes/member-notes.ts'; - -const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; -reactGlobal.React ??= React; - -export class UpdateMemberRole extends Task { - static with(roleName: string, communityName: string): UpdateMemberRole { - return new UpdateMemberRole(roleName, communityName); - } - - private constructor( - private readonly roleName: string, - private readonly communityName: string, - ) { - super(`assigns role "${roleName}" in "${communityName}"`); - } - - async performAs(actor: Actor): Promise { - const rolesByCommunityName = await actor.answer(notes().get('rolesByCommunityName')); - if (!rolesByCommunityName[this.communityName]?.includes(this.roleName)) { - throw new Error(`Role "${this.roleName}" does not belong to "${this.communityName}"`); - } - - await actor.attemptsTo( - notes().set('lastMemberRoleName', this.roleName), - notes().set('memberValidationError', ''), - Render.component( - ().get('lastMemberId')), - memberName: 'Charlie Walker', - role: { __typename: 'EndUserRole', id: `role-${this.roleName}`, roleName: this.roleName }, - createdAt: new Date(), - updatedAt: new Date(), - }, - }} - />, - { wrapper: wrapOcomComponent() }, - ), - ); - } -} - -export const SeedEndUserRole = (roleName: string, communityName: string) => - Task.where( - `#actor seeds role "${roleName}" in "${communityName}"`, - new TaskStep('#actor records the available member role', async (actor) => { - const rolesByCommunityName = await actor.answer(notes().get('rolesByCommunityName')).catch(() => ({}) as Record); - await actor.attemptsTo( - notes().set('rolesByCommunityName', { - ...rolesByCommunityName, - [communityName]: [...(rolesByCommunityName[communityName] ?? []), roleName], - }), - ); - }), - ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index 00551067d..732ec8c44 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -4,7 +4,7 @@ export type AcceptanceUiHomePage = Pick; -export type AcceptanceUiMemberCreatePage = Pick; +export type AcceptanceUiMemberCreatePage = Pick; export type AcceptanceUiMemberListPage = Pick; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts new file mode 100644 index 000000000..6bde74596 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts @@ -0,0 +1,55 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { MemberAccountsPage, MemberCreatePage, MemberListPage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; +import type { Actor } from '@serenity-js/core'; +import type { Page } from 'playwright'; + +export const communityPortalPageOf = (actor: Actor): Page => BrowseTheWeb.withActor(actor).page; + +export const memberCreatePageOn = (page: Page): MemberCreatePage => new MemberCreatePage(new PlaywrightPageAdapter(page)); + +export const memberListPageOn = (page: Page): MemberListPage => new MemberListPage(new PlaywrightPageAdapter(page)); + +export const memberProfilePageOn = (page: Page): MemberProfilePage => new MemberProfilePage(new PlaywrightPageAdapter(page)); + +export const memberAccountsPageOn = (page: Page): MemberAccountsPage => new MemberAccountsPage(new PlaywrightPageAdapter(page)); + +/** Navigate to a community member list and wait for its search control to render. */ +export const openMemberListFor = async (page: Page, communityId: string, routeMemberId: string): Promise => { + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(routeMemberId)}/members`, { waitUntil: 'networkidle' }); + const memberListPage = memberListPageOn(page); + await memberListPage.searchInput.waitFor({ state: 'visible', timeout: 15_000 }); + return memberListPage; +}; + +/** Navigate to a community member create form and wait for its name input. */ +export const openMemberCreateFormFor = async (page: Page, communityId: string, routeMemberId: string): Promise => { + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(routeMemberId)}/members/create`, { waitUntil: 'networkidle' }); + const memberCreatePage = memberCreatePageOn(page); + await memberCreatePage.memberNameInput.waitFor({ state: 'visible', timeout: 15_000 }); + return memberCreatePage; +}; + +/** Navigate to a member profile and open its editor. */ +export const openMemberProfileEditorFor = async (page: Page, communityId: string, routeMemberId: string, memberId: string): Promise => { + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(routeMemberId)}/members/${encodeURIComponent(memberId)}/profile`, { waitUntil: 'networkidle' }); + const memberProfilePage = memberProfilePageOn(page); + await memberProfilePage.clickEditProfile(); + return memberProfilePage; +}; + +/** Navigate to a member account-assignment form and wait for its end-user selector. */ +export const openMemberAccountAssignmentFor = async (page: Page, communityId: string, routeMemberId: string, memberId: string): Promise => { + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(routeMemberId)}/members/${encodeURIComponent(memberId)}/accounts/add`, { waitUntil: 'networkidle' }); + const memberAccountsPage = memberAccountsPageOn(page); + await memberAccountsPage.endUserSelect.waitFor({ state: 'visible', timeout: 15_000 }); + return memberAccountsPage; +}; + +/** Navigate to a member account list and wait for the account table to render. */ +export const openMemberAccountsListFor = async (page: Page, communityId: string, routeMemberId: string, memberId: string): Promise => { + await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(routeMemberId)}/members/${encodeURIComponent(memberId)}/accounts`, { waitUntil: 'networkidle' }); + const memberAccountsPage = memberAccountsPageOn(page); + await memberAccountsPage.addAccountButton.waitFor({ state: 'visible', timeout: 15_000 }); + return memberAccountsPage; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-create-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-create-form.ts new file mode 100644 index 000000000..22b026d8f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-create-form.ts @@ -0,0 +1,31 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberCreatePageOn } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const SubmitMemberCreateForm = (actor: Actor, memberName: string) => + Interaction.where(the`#actor submits the member create form for "${memberName}"`, async () => { + const page = communityPortalPageOf(actor); + const memberCreatePage = memberCreatePageOn(page); + await memberCreatePage.clickCreateMember(); + + await memberCreatePage.validationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + if (await memberCreatePage.validationError.isVisible().catch(() => false)) { + const errorText = await memberCreatePage.validationError.textContent(); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('errorMessage', errorText || 'Validation error')); + return; + } + + await page.waitForURL(/\/members\/[^/]+$/, { timeout: 15_000 }).catch(async () => { + await memberCreatePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + const message = (await memberCreatePage.errorToast.textContent().catch(() => null)) || `Member creation did not navigate to a member detail page for "${memberName}"`; + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('errorMessage', message)); + throw new Error(message); + }); + + const memberId = new URL(page.url()).pathname.match(/\/members\/([^/]+)$/)?.[1]; + if (!memberId) { + throw new Error(`Member creation navigated to an unexpected URL: ${page.url()}`); + } + + await actor.attemptsTo(notes().set('lastMemberId', decodeURIComponent(memberId)), notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-profile-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-profile-form.ts new file mode 100644 index 000000000..83d92885f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-and-submit-member-profile-form.ts @@ -0,0 +1,29 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberProfilePageOn } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const SubmitMemberProfileForm = (actor: Actor) => + Interaction.where(the`#actor submits the member profile form`, async () => { + const page = communityPortalPageOf(actor); + const memberProfilePage = memberProfilePageOn(page); + + await memberProfilePage.clickSaveProfile(); + await memberProfilePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + if (await memberProfilePage.firstValidationError.isVisible().catch(() => false)) { + const errorText = await memberProfilePage.firstValidationError.textContent(); + await actor.attemptsTo(notes().set('errorMessage', errorText || 'Validation error')); + return; + } + + const didSave = await Promise.race([ + memberProfilePage.editProfileButton.waitFor({ state: 'visible', timeout: 15_000 }).then(() => true), + memberProfilePage.errorToast.waitFor({ state: 'visible', timeout: 15_000 }).then(() => false), + ]); + if (!didSave) { + const message = (await memberProfilePage.errorToast.textContent()) || 'Member profile update failed'; + await actor.attemptsTo(notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-create-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-create-form.ts new file mode 100644 index 000000000..7737758cc --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-create-form.ts @@ -0,0 +1,10 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberCreatePageOn } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const FillMemberCreateForm = (actor: Actor, memberName: string) => + Interaction.where(the`#actor fills the member create form for "${memberName}"`, async () => { + const memberCreatePage = memberCreatePageOn(communityPortalPageOf(actor)); + await memberCreatePage.fillMemberName(memberName); + await actor.attemptsTo(notes().set('lastMemberName', memberName)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-profile-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-profile-form.ts new file mode 100644 index 000000000..a479dcf32 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-member-profile-form.ts @@ -0,0 +1,17 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberProfilePageOn } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; + +export const FillMemberProfileForm = (actor: Actor, profile: MemberProfileExpectation) => + Interaction.where(the`#actor fills the member profile form`, async () => { + const memberProfilePage = memberProfilePageOn(communityPortalPageOf(actor)); + if (profile.name !== undefined) await memberProfilePage.fillDisplayName(profile.name); + if (profile.email !== undefined) await memberProfilePage.fillEmail(profile.email); + if (profile.bio !== undefined) await memberProfilePage.fillBio(profile.bio); + if (profile.showInterests !== undefined) await memberProfilePage.setShowInterests(profile.showInterests); + if (profile.showEmail !== undefined) await memberProfilePage.setShowEmail(profile.showEmail); + if (profile.showProfile !== undefined) await memberProfilePage.setShowProfile(profile.showProfile); + if (profile.showLocation !== undefined) await memberProfilePage.setShowLocation(profile.showLocation); + if (profile.showProperties !== undefined) await memberProfilePage.setShowProperties(profile.showProperties); + await actor.attemptsTo(notes().set('lastExpectedMemberProfile', profile)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-member-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-member-list.ts new file mode 100644 index 000000000..cd86fc36c --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-member-list.ts @@ -0,0 +1,13 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberListFor } from '../abilities/community-portal-page.ts'; +import { CommunityId } from '../questions/community-id.ts'; +import { RouteMemberId } from '../questions/route-member-id.ts'; + +export const OpenCommunityMemberList = (actor: Actor, communityName: string) => + Interaction.where(the`#actor opens the member list for "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + const routeMemberId = await actor.answer(RouteMemberId(communityName)); + + await openMemberListFor(page, communityId, routeMemberId); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-member-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-member-form.ts new file mode 100644 index 000000000..eb4ee67bb --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-member-form.ts @@ -0,0 +1,13 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberCreateFormFor } from '../abilities/community-portal-page.ts'; +import { CommunityId } from '../questions/community-id.ts'; +import { RouteMemberId } from '../questions/route-member-id.ts'; + +export const OpenCreateMemberForm = (actor: Actor, communityName: string) => + Interaction.where(the`#actor opens the create member form for "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + const routeMemberId = await actor.answer(RouteMemberId(communityName)); + + await openMemberCreateFormFor(page, communityId, routeMemberId); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-account-assignment.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-account-assignment.ts new file mode 100644 index 000000000..098885b61 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-account-assignment.ts @@ -0,0 +1,18 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberAccountAssignmentFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from '../questions/community-id.ts'; +import { RouteMemberId } from '../questions/route-member-id.ts'; + +export const OpenMemberAccountAssignment = (actor: Actor, communityName: string) => + Interaction.where(the`#actor opens member account assignment in "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + const memberId = await actor.answer(notes().get('lastMemberId')); + if (!memberId) { + throw new Error(`Missing member state for account assignment in "${communityName}"`); + } + const routeMemberId = await actor.answer(RouteMemberId(communityName)); + + await openMemberAccountAssignmentFor(page, communityId, routeMemberId, memberId); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-profile-editor.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-profile-editor.ts new file mode 100644 index 000000000..0f2c5e57b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-member-profile-editor.ts @@ -0,0 +1,18 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberProfileEditorFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from '../questions/community-id.ts'; +import { RouteMemberId } from '../questions/route-member-id.ts'; + +export const OpenMemberProfileEditor = (actor: Actor, communityName: string, memberName: string) => + Interaction.where(the`#actor opens the profile editor for "${memberName}" in "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + const memberId = await actor.answer(notes().get('lastMemberId')); + if (!memberId) { + throw new Error(`Missing member state for profile update in "${communityName}"`); + } + const routeMemberId = await actor.answer(RouteMemberId(communityName)); + + await openMemberProfileEditorFor(page, communityId, routeMemberId, memberId); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/remove-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/remove-member.ts new file mode 100644 index 000000000..f1d3fbc21 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/remove-member.ts @@ -0,0 +1,42 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberListFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from '../questions/community-id.ts'; +import { HasNoMemberForCommunity } from '../questions/has-no-member-for-community.ts'; +import { RouteMemberId } from '../questions/route-member-id.ts'; + +export const RemoveMember = (actor: Actor, communityName: string, memberName: string) => + Interaction.where(the`#actor removes member "${memberName}" from "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + const routeMemberId = await actor.answer(RouteMemberId(communityName)); + + let memberListPage: Awaited>; + try { + memberListPage = await openMemberListFor(page, communityId, routeMemberId); + } catch (error) { + if (await actor.answer(HasNoMemberForCommunity.inCommunity(communityId))) { + const message = `Unauthorized: no community membership for "${communityName}"`; + await actor.attemptsTo(notes().set('errorMessage', message)); + throw new Error(message); + } + throw error; + } + await memberListPage.memberName(memberName).waitFor({ state: 'visible', timeout: 15_000 }); + await memberListPage.clickRemoveMember(memberName); + + const outcome = await Promise.race([ + memberListPage + .memberName(memberName) + .waitFor({ state: 'hidden', timeout: 15_000 }) + .then(() => 'removed' as const), + memberListPage.errorToast.waitFor({ state: 'visible', timeout: 15_000 }).then(() => 'denied' as const), + ]); + if (outcome === 'denied') { + const message = (await memberListPage.errorToast.textContent()) || 'Member removal was denied'; + await actor.attemptsTo(notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/resolve-community-administrator-membership.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/resolve-community-administrator-membership.ts new file mode 100644 index 000000000..71db1f81d --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/resolve-community-administrator-membership.ts @@ -0,0 +1,46 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Response } from 'playwright'; +import { communityPortalPageOf } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from '../questions/community-id.ts'; + +const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; + +type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +type MembersForCurrentEndUserPayload = GraphqlPayload<{ + membersForCurrentEndUser?: Array<{ + id: string; + isAdmin?: boolean | null; + community?: { id: string } | null; + }>; +}>; + +const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); + +const selectPayload = (payload: GraphqlPayload | GraphqlPayload[] | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +export const ResolveCommunityAdministratorMembership = (actor: Actor, communityName: string) => + Interaction.where(the`#actor resolves administrator membership for "${communityName}"`, async () => { + const page = communityPortalPageOf(actor); + const communityId = await actor.answer(CommunityId(communityName)); + + const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); + await page.goto('/community/accounts', { waitUntil: 'networkidle' }); + const membershipsPayload = selectPayload((await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); + const routeMemberId = membershipsPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; + if (!routeMemberId) { + throw new Error(`No administrator membership found for community "${communityName}"`); + } + + await actor.attemptsTo(notes().set('routeMemberId', routeMemberId)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/search-member-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/search-member-list.ts new file mode 100644 index 000000000..77a3c2745 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/search-member-list.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberListPageOn } from '../abilities/community-portal-page.ts'; + +export const SearchMemberList = (actor: Actor, searchTerm: string) => + Interaction.where(the`#actor searches the member list for "${searchTerm}"`, async () => { + const memberListPage = memberListPageOn(communityPortalPageOf(actor)); + await memberListPage.searchByMemberName(searchTerm); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/select-member-end-user-account.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/select-member-end-user-account.ts new file mode 100644 index 000000000..9c86dba2b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/select-member-end-user-account.ts @@ -0,0 +1,8 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberAccountsPageOn } from '../abilities/community-portal-page.ts'; + +export const SelectMemberEndUserAccount = (actor: Actor, displayName: string) => + Interaction.where(the`#actor selects end-user account "${displayName}"`, async () => { + const memberAccountsPage = memberAccountsPageOn(communityPortalPageOf(actor)); + await memberAccountsPage.selectEndUser(displayName); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-member-account-assignment.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-member-account-assignment.ts new file mode 100644 index 000000000..9a40c168f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-member-account-assignment.ts @@ -0,0 +1,20 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, memberAccountsPageOn } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const SubmitMemberAccountAssignment = (actor: Actor) => + Interaction.where(the`#actor submits the member account assignment`, async () => { + const page = communityPortalPageOf(actor); + const memberAccountsPage = memberAccountsPageOn(page); + const accountsListNavigation = page.waitForURL(/\/members\/[^/]+\/accounts\/?$/, { timeout: 15_000 }); + const assignmentError = memberAccountsPage.errorToast.waitFor({ state: 'visible', timeout: 15_000 }); + await memberAccountsPage.clickAddMemberAccount(); + const outcome = await Promise.race([accountsListNavigation.then(() => 'navigated' as const), assignmentError.then(() => 'error' as const)]).catch(() => 'timed-out' as const); + if (outcome !== 'navigated') { + const message = (await memberAccountsPage.errorToast.textContent().catch(() => null)) || `Member account assignment did not complete; current URL: ${page.url()}`; + await actor.attemptsTo(notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts index 2b8b82b4e..97ba400ca 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -13,22 +13,16 @@ export interface MemberProfileExpectation { export interface MemberE2ENotes { communityIdsByName: Record; - roleIdsByCommunityName: Record>; endUserEmailsByLabel: Record; endUserDisplayNamesByLabel: Record; endUserExternalIdsByLabel: Record; lastMemberCommunityId: string | null; + lastMemberCommunityName: string | null; lastMemberId: string | null; lastMemberName: string; - lastMemberRoleName: string | null; lastExpectedMemberProfile?: MemberProfileExpectation; lastUpdatedMemberProfile?: MemberProfileExpectation; lastMemberStatus: string | null; - memberUpdated: boolean; - memberCreated: boolean; - memberRemoved: boolean; - memberAccountLinked: boolean; - memberAccountCount: number; errorMessage: string | null; - principalMemberId: string | null; + routeMemberId: string | null; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-id.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-id.ts new file mode 100644 index 000000000..269568184 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-id.ts @@ -0,0 +1,12 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const CommunityId = (communityName: string) => + Question.about(`the ID of community "${communityName}"`, async (actor) => { + const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); + const communityId = communityIdsByName[communityName]; + if (!communityId) { + throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); + } + return communityId; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts index f39681598..7199f9dfc 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts @@ -1,17 +1,28 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; import { type Actor, notes, Question } from '@serenity-js/core'; -import type { E2EMemberAccountsPage } from '../../../shared/page-contracts.ts'; +import { communityPortalPageOf, openMemberAccountsListFor } from '../abilities/community-portal-page.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from './community-id.ts'; -export const MemberAccountLinked = (email: string) => - Question.about(`whether the member account list contains "${email}"`, async (serenityActor) => { +const openAccountsList = async (actor: Actor, communityName: string) => { + const memberId = await actor.answer(notes().get('lastMemberId')); + const routeMemberId = await actor.answer(notes().get('routeMemberId')); + if (!memberId || !routeMemberId) { + throw new Error(`Missing member or route member state for account verification in "${communityName}"`); + } + + return openMemberAccountsListFor(communityPortalPageOf(actor), await actor.answer(CommunityId(communityName)), routeMemberId, memberId); +}; + +export const MemberAccountLinked = (communityName: string, email: string) => + Question.about(`whether the member account list contains "${email}" in "${communityName}"`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const memberAccountsPage: E2EMemberAccountsPage = new MemberAccountsPage(new PlaywrightPageAdapter(page)); + const memberAccountsPage = await openAccountsList(actor, communityName); await memberAccountsPage.linkedAccountEmail(email).waitFor({ state: 'visible', timeout: 15_000 }); return await memberAccountsPage.linkedAccountEmail(email).isVisible(); }); -export const MemberAccountCount = (email: string) => Question.about(`the number of confirmed member account links for "${email}"`, (actor) => actor.answer(notes().get('memberAccountCount'))); +export const MemberAccountCount = (communityName: string, email: string) => + Question.about(`the number of rendered member account links for "${email}" in "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + return (await openAccountsList(actor, communityName)).linkedAccountCount(email); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts deleted file mode 100644 index 1ab7dd553..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-flag.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { notes, Question } from '@serenity-js/core'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -export const MemberCreatedFlag = () => Question.about('whether the member was created', (actor) => actor.answer(notes().get('memberCreated'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-in-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-in-community.ts new file mode 100644 index 000000000..3f52285f1 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-created-in-community.ts @@ -0,0 +1,19 @@ +import { type Actor, notes, Question } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberListFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from './community-id.ts'; + +export const MemberCreatedInCommunity = (communityName: string) => + Question.about(`whether the created member appears in "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const memberName = await actor.answer(notes().get('lastMemberName')); + const routeMemberId = await actor.answer(notes().get('routeMemberId')); + if (!memberName || !routeMemberId) { + throw new Error(`Missing created member or route member state for "${communityName}"`); + } + + const communityId = await actor.answer(CommunityId(communityName)); + const memberListPage = await openMemberListFor(communityPortalPageOf(actor), communityId, routeMemberId); + await memberListPage.memberName(memberName).waitFor({ state: 'visible', timeout: 15_000 }); + return true; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts index d2c79b924..04bebe24f 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts @@ -3,12 +3,12 @@ import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; import { MemberListPage } from '@ocom-verification/verification-shared/pages'; import { type Actor, Question } from '@serenity-js/core'; -export const MemberListedInCommunity = (memberName: string, route?: { communityId: string; principalMemberId: string }) => +export const MemberListedInCommunity = (memberName: string, route?: { communityId: string; routeMemberId: string }) => Question.about(`whether member "${memberName}" appears in the browser member list`, async (serenityActor) => { const actor = serenityActor as unknown as Actor; const { page } = BrowseTheWeb.withActor(actor); if (route) { - await page.goto(`/community/${encodeURIComponent(route.communityId)}/admin/${encodeURIComponent(route.principalMemberId)}/members`, { waitUntil: 'networkidle' }); + await page.goto(`/community/${encodeURIComponent(route.communityId)}/admin/${encodeURIComponent(route.routeMemberId)}/members`, { waitUntil: 'networkidle' }); } const memberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); return await memberListPage.memberName(memberName).isVisible(); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-profile-matches.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-profile-matches.ts new file mode 100644 index 000000000..4e71bfae5 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-profile-matches.ts @@ -0,0 +1,37 @@ +import { type Actor, notes, Question } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberProfileEditorFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { CommunityId } from './community-id.ts'; + +const assertProfileValue = (name: string, actual: string | boolean, expected: string | boolean) => { + if (actual !== expected) { + throw new Error(`Expected member profile ${name} to be "${expected}", got "${actual}"`); + } +}; + +export const MemberProfileMatches = (communityName: string) => + Question.about(`whether the member profile matches the expected values in "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const profile = await actor.answer(notes().get('lastExpectedMemberProfile')); + const memberId = await actor.answer(notes().get('lastMemberId')); + const routeMemberId = await actor.answer(notes().get('routeMemberId')); + if (!profile || !memberId || !routeMemberId) { + throw new Error(`Missing profile, member, or route member state for "${communityName}"`); + } + + const communityId = await actor.answer(CommunityId(communityName)); + const page = await openMemberProfileEditorFor(communityPortalPageOf(actor), communityId, routeMemberId, memberId); + await verifyProfile(page, profile); + return true; + }); + +const verifyProfile = async (page: Awaited>, profile: MemberProfileExpectation) => { + if (profile.name !== undefined) assertProfileValue('name', await page.displayNameValue(), profile.name); + if (profile.email !== undefined) assertProfileValue('email', await page.emailValue(), profile.email); + if (profile.bio !== undefined) assertProfileValue('bio', await page.bioValue(), profile.bio); + if (profile.showInterests !== undefined) assertProfileValue('showInterests', await page.showInterestsValue(), profile.showInterests); + if (profile.showEmail !== undefined) assertProfileValue('showEmail', await page.showEmailValue(), profile.showEmail); + if (profile.showProfile !== undefined) assertProfileValue('showProfile', await page.showProfileValue(), profile.showProfile); + if (profile.showLocation !== undefined) assertProfileValue('showLocation', await page.showLocationValue(), profile.showLocation); + if (profile.showProperties !== undefined) assertProfileValue('showProperties', await page.showPropertiesValue(), profile.showProperties); +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts deleted file mode 100644 index 4b204ea0a..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-flag.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { notes, Question } from '@serenity-js/core'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -export const MemberRemovedFlag = () => Question.about('whether the member was removed', (actor) => actor.answer(notes().get('memberRemoved'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-from-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-from-community.ts new file mode 100644 index 000000000..65da1f125 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-removed-from-community.ts @@ -0,0 +1,17 @@ +import { type Actor, notes, Question } from '@serenity-js/core'; +import { communityPortalPageOf, openMemberListFor } from '../abilities/community-portal-page.ts'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; +import { CommunityId } from './community-id.ts'; + +export const MemberRemovedFromCommunity = (communityName: string, memberName: string) => + Question.about(`whether member "${memberName}" is absent from "${communityName}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const routeMemberId = await actor.answer(notes().get('routeMemberId')); + if (!routeMemberId) { + throw new Error(`Missing route member state for member removal verification in "${communityName}"`); + } + + const memberListPage = await openMemberListFor(communityPortalPageOf(actor), await actor.answer(CommunityId(communityName)), routeMemberId); + await memberListPage.memberName(memberName).waitFor({ state: 'hidden', timeout: 15_000 }); + return true; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/route-member-id.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/route-member-id.ts new file mode 100644 index 000000000..f0f5797e3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/route-member-id.ts @@ -0,0 +1,12 @@ +import { notes, Question } from '@serenity-js/core'; +import type { MemberE2ENotes } from '../notes/member-notes.ts'; + +export const RouteMemberId = (communityName: string) => + Question.about(`the administrator route member ID for "${communityName}"`, async (actor) => { + const routeMemberId = await actor.answer(notes().get('routeMemberId')); + if (!routeMemberId) { + throw new Error(`Missing administrator route member state for "${communityName}"`); + } + + return routeMemberId; + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts index 871c91fd6..1e989059a 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -13,7 +13,7 @@ const accountDetails: Record { +Given('end-user account {string} is available for assignment in {string}', async (accountLabel: string, communityName: string) => { const actor = actorInTheSpotlight(); const account = accountDetails[accountLabel]; if (!account) { @@ -23,8 +23,7 @@ Given('end-user account {string} is available for assignment in {string}', async notes().set('endUserDisplayNamesByLabel', { [accountLabel]: account.displayName }), notes().set('endUserEmailsByLabel', { [accountLabel]: account.email }), notes().set('endUserExternalIdsByLabel', { [accountLabel]: account.externalId }), - notes().set('memberAccountLinked', false), - notes().set('memberAccountCount', 0), + notes().set('lastMemberCommunityName', communityName), ); }); @@ -48,7 +47,11 @@ Given('member {string} is already linked to end-user account {string}', async (_ Then('member {string} should be linked to end-user account {string}', async (_memberName: string, accountLabel: string) => { const actor = actorInTheSpotlight(); const emails = await actor.answer(notes().get('endUserEmailsByLabel')); - if (!(await actor.answer(MemberAccountLinked(emails[accountLabel] ?? '')))) { + const communityName = await actor.answer(notes().get('lastMemberCommunityName')); + if (!communityName) { + throw new Error('Missing community state for account verification'); + } + if (!(await actor.answer(MemberAccountLinked(communityName, emails[accountLabel] ?? '')))) { throw new Error(`Expected the member account list to contain end-user account "${accountLabel}"`); } }); @@ -56,7 +59,11 @@ Then('member {string} should be linked to end-user account {string}', async (_me Then('member {string} should remain linked to end-user account {string} only once', async (_memberName: string, accountLabel: string) => { const actor = actorInTheSpotlight(); const emails = await actor.answer(notes().get('endUserEmailsByLabel')); - const count = await actor.answer(MemberAccountCount(emails[accountLabel] ?? '')); + const communityName = await actor.answer(notes().get('lastMemberCommunityName')); + if (!communityName) { + throw new Error('Missing community state for account verification'); + } + const count = await actor.answer(MemberAccountCount(communityName, emails[accountLabel] ?? '')); if (count !== 1) { throw new Error(`Expected one account association for "${accountLabel}" but found ${count}`); } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts index 4f4d3f494..f6bc297f7 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts @@ -6,7 +6,7 @@ import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; import { clearKnownQueueMessages } from '../../../shared/support/queue-storage.ts'; import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { MemberCreatedFlag } from '../questions/member-created-flag.ts'; +import { MemberCreatedInCommunity } from '../questions/member-created-in-community.ts'; import { MemberErrorMessage } from '../questions/member-error-message.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; import { CreateMember } from '../tasks/create-member.ts'; @@ -55,24 +55,21 @@ When('{word} creates a member in {string} with:', async (actorName: string, comm throw new Error('memberName is required'); } - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', null)); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('errorMessage', null)); await actor.attemptsTo(CreateMember(communityName, memberName)); }); -Then('the member should be created successfully in {string}', async (_communityName: string) => { +Then('the member should be created successfully in {string}', async (communityName: string) => { const actor = actorCalled(lastActorName); - const created = await actor.answer(MemberCreatedFlag()); - if (!created) { - throw new Error('Expected member creation to succeed'); - } + await actor.answer(MemberCreatedInCommunity(communityName)); }); When('{word} attempts to create a member in {string} with:', async (actorName: string, communityName: string, dataTable: DataTable) => { lastActorName = actorName; const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', null)); + await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('errorMessage', null)); try { await actor.attemptsTo(CreateMember(communityName, details.memberName?.trim() ?? '')); } catch (error) { @@ -89,7 +86,10 @@ Then('she should see a member error for {string}', async (fieldName: string) => }); Then('no new member should be created in {string}', async (_communityName: string) => { - if (await actorCalled(lastActorName).answer(MemberCreatedFlag())) { - throw new Error('Expected no member to be created, but one was created'); + const memberId = await actorCalled(lastActorName) + .answer(notes().get('lastMemberId')) + .catch(() => null); + if (memberId) { + throw new Error('Expected no member to be created'); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index 47733d53d..02ccfb616 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -2,7 +2,6 @@ import './create-community.steps.ts'; import './create-member.steps.ts'; import './list-members.steps.ts'; -import './member-role.steps.ts'; import './remove-member.steps.ts'; import './assign-member-account.steps.ts'; import './update-member.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts deleted file mode 100644 index 167ac05e4..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/member-role.steps.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Given, Then, When } from '@cucumber/cucumber'; -import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; -import { SeedEndUserRole, UpdateMemberRole } from '../tasks/update-member-role.ts'; - -let lastActorName = ''; - -Given('a role {string} exists in {string}', async (roleName: string, communityName: string) => { - const actor = actorInTheSpotlight(); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; - if (!communityId) throw new Error(`Unknown community "${communityName}"`); - await actor.attemptsTo(SeedEndUserRole(roleName, communityName, communityId)); -}); - -When('{word} changes member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { - lastActorName = actorName; - await actorCalled(actorName).attemptsTo(UpdateMemberRole(communityName, roleName)); -}); - -When('{word} attempts to change member {string} in {string} to role {string}', async (actorName: string, _memberName: string, communityName: string, roleName: string) => { - lastActorName = actorName; - const actor = actorCalled(actorName); - try { - await actor.attemptsTo(UpdateMemberRole(communityName, roleName)); - } catch (error) { - await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error))); - } -}); - -Then('member {string} should have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { - const role = await actorCalled(lastActorName).answer(notes().get('lastMemberRoleName')); - if (role !== roleName) throw new Error(`Expected member role "${roleName}", got "${role ?? 'none'}"`); -}); - -Then('member {string} should not have role {string} in {string}', async (_memberName: string, roleName: string, _communityName: string) => { - const role = await actorCalled(lastActorName) - .answer(notes().get('lastMemberRoleName')) - .catch(() => null); - if (role === roleName) throw new Error(`Expected member not to have role "${roleName}"`); -}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts index b9a1ff750..84e38fc4d 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -6,7 +6,7 @@ import { SwitchOAuth2User } from '../../../shared/abilities/oauth2-login.ts'; import type { MemberE2ENotes } from '../notes/member-notes.ts'; import { HasNoMemberForCommunity } from '../questions/has-no-member-for-community.ts'; import { MemberListedInCommunity } from '../questions/member-listed-in-community.ts'; -import { MemberRemovedFlag } from '../questions/member-removed-flag.ts'; +import { MemberRemovedFromCommunity } from '../questions/member-removed-from-community.ts'; import { RemoveMember } from '../tasks/remove-member.ts'; let lastActorName = actors.CommunityOwner.name; @@ -24,8 +24,7 @@ Given('{word} is signed in without membership in {string}', async (actorName: st await actor.attemptsTo( notes().set('communityIdsByName', communityIds), notes().set('lastMemberId', targetMemberId), - notes().set('principalMemberId', targetMemberId), - notes().set('memberRemoved', false), + notes().set('routeMemberId', targetMemberId), notes().set('errorMessage', null), SwitchOAuth2User('test@example.com'), ); @@ -40,11 +39,13 @@ Given('{word} is signed in without membership in {string}', async (actorName: st When('{word} removes member {string} from {string}', async (actorName: string, memberName: string, communityName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); - await actor.attemptsTo(notes().set('memberRemoved', false), RemoveMember(communityName, memberName)); + await actor.attemptsTo(RemoveMember(communityName, memberName)); }); -Then('the member should be removed successfully from {string}', async (_communityName: string) => { - if (!(await actorCalled(lastActorName).answer(MemberRemovedFlag()))) { +Then('the member should be removed successfully from {string}', async (communityName: string) => { + const actor = actorCalled(lastActorName); + const memberName = await actor.answer(notes().get('lastMemberName')); + if (!memberName || !(await actor.answer(MemberRemovedFromCommunity(communityName, memberName)))) { throw new Error('Expected member removal to succeed'); } }); @@ -61,7 +62,7 @@ When('{word} attempts to remove member {string} from {string}', async (actorName try { await actor.attemptsTo(RemoveMember(communityName, memberName)); } catch (error) { - await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error)), notes().set('memberRemoved', false)); + await actor.attemptsTo(notes().set('errorMessage', error instanceof Error ? error.message : String(error))); } }); @@ -76,7 +77,7 @@ Then('member {string} should remain in {string}', async (memberName: string, com const owner = actorCalled(managementOwnerActorName); const communityIds = await owner.answer(notes().get('communityIdsByName')); await owner.attemptsTo(SwitchOAuth2User(actors.CommunityOwner.email)); - if (!(await owner.answer(MemberListedInCommunity(memberName, { communityId: communityIds[communityName] ?? '', principalMemberId: managementOwnerMemberId })))) { + if (!(await owner.answer(MemberListedInCommunity(memberName, { communityId: communityIds[communityName] ?? '', routeMemberId: managementOwnerMemberId })))) { throw new Error(`Expected member "${memberName}" to remain in "${communityName}"`); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts index e0d08704b..6b56ce9c9 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts @@ -3,6 +3,7 @@ import { type DataTable, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { MemberProfileMatches } from '../questions/member-profile-matches.ts'; import { UpdateMemberProfile } from '../tasks/update-member-profile.ts'; let lastActorName = actors.CommunityOwner.name; @@ -43,7 +44,7 @@ When('{word} updates member {string} in {string} with:', async (actorName: strin ...(details.showProperties !== undefined ? { showProperties: parseBoolean(details.showProperties) } : {}), }; - await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', null)); + await actor.attemptsTo(notes().set('errorMessage', null)); await actor.attemptsTo( UpdateMemberProfile({ @@ -55,9 +56,5 @@ When('{word} updates member {string} in {string} with:', async (actorName: strin }); Then('the member should be updated successfully in {string}', async (_communityName: string) => { - const actor = actorCalled(lastActorName); - const updated = await actor.answer(notes().get('memberUpdated')); - if (!updated) { - throw new Error('Expected member update to succeed'); - } + await actorCalled(lastActorName).answer(MemberProfileMatches(_communityName)); }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts index 94fd42881..236c9a588 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts @@ -1,75 +1,14 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberAccountsPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import type { E2EMemberAccountsPage } from '../../../shared/page-contracts.ts'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -const endUsersOperationName = 'AdminMembersAccountsAddContainerEndUsersByCommunity'; -const createAccountOperationName = 'AdminMembersAccountsAddContainerMemberCreateAccount'; -const memberAccountsOperationName = 'AdminMembersAccountsListContainerMember'; - -type GraphqlPayload = { - data?: TData; - errors?: Array<{ message?: string }>; -}; - -type CreateAccountPayload = GraphqlPayload<{ - memberCreateAccount?: { - status?: { success?: boolean; errorMessage?: string | null }; - member?: { id?: string | null } | null; - }; -}>; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); - -const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; -}; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { OpenMemberAccountAssignment } from '../interactions/open-member-account-assignment.ts'; +import { ResolveCommunityAdministratorMembership } from '../interactions/resolve-community-administrator-membership.ts'; +import { SelectMemberEndUserAccount } from '../interactions/select-member-end-user-account.ts'; +import { SubmitMemberAccountAssignment } from '../interactions/submit-member-account-assignment.ts'; export const AssignMemberAccount = (communityName: string, displayName: string) => - Interaction.where(the`#actor assigns end-user account "${displayName}" in "${communityName}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; - const memberId = await actor.answer(notes().get('lastMemberId')); - const adminMemberId = new URL(page.url()).pathname.match(/\/admin\/([^/]+)\/members\//)?.[1]; - if (!communityId || !memberId || !adminMemberId) { - throw new Error(`Missing community, member, or administrator state for account assignment in "${communityName}"`); - } - - const endUsersResponse = page.waitForResponse(hasGraphqlOperation(endUsersOperationName), { timeout: 15_000 }); - await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/${encodeURIComponent(memberId)}/accounts/add`, { waitUntil: 'networkidle' }); - await endUsersResponse; - - const memberAccountsPage: E2EMemberAccountsPage = new MemberAccountsPage(new PlaywrightPageAdapter(page)); - await memberAccountsPage.selectEndUser(displayName); - - const mutationResponse = page.waitForResponse(hasGraphqlOperation(createAccountOperationName), { timeout: 15_000 }); - const accountsResponse = page.waitForResponse(hasGraphqlOperation(memberAccountsOperationName), { timeout: 15_000 }).catch(() => null); - await memberAccountsPage.clickAddMemberAccount(); - - const response = await mutationResponse; - const payload = selectGraphqlPayload((await response.json()) as CreateAccountPayload | CreateAccountPayload[], (data) => Boolean(data?.memberCreateAccount)); - const result = payload?.data?.memberCreateAccount; - if (!response.ok() || payload?.errors?.length || result?.status?.success !== true || result.member?.id !== memberId) { - const message = - result?.status?.errorMessage ?? - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; ') ?? - 'Member account assignment failed'; - await actor.attemptsTo(notes().set('memberAccountLinked', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await accountsResponse; - const accountCount = await actor.answer(notes().get('memberAccountCount')).catch(() => 0); - await actor.attemptsTo(notes().set('memberAccountLinked', true), notes().set('memberAccountCount', accountCount + 1), notes().set('errorMessage', null)); - }); + Task.where( + the`#actor assigns end-user account "${displayName}" in "${communityName}"`, + new TaskStep('#actor assigns an end-user account through the community portal', async (actor) => { + await actor.attemptsTo(ResolveCommunityAdministratorMembership(actor, communityName), OpenMemberAccountAssignment(actor, communityName), SelectMemberEndUserAccount(actor, displayName), SubmitMemberAccountAssignment(actor)); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts index 72c4867e5..a98546aeb 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts @@ -1,139 +1,14 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberCreatePage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import type { E2EMemberCreatePage } from '../../../shared/page-contracts.ts'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -const createMemberOperationName = 'AdminMembersCreateContainerMemberCreate'; -const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; - -type GraphqlPayload = { - data?: TData; - errors?: Array<{ message?: string }>; -}; - -type MemberCreateGraphqlPayload = GraphqlPayload<{ - memberCreate?: { - status?: { - success?: boolean; - errorMessage?: string | null; - }; - member?: { - id?: string | null; - memberName?: string | null; - } | null; - }; -}>; - -type MembersForCurrentEndUserPayload = GraphqlPayload<{ - membersForCurrentEndUser?: Array<{ - id: string; - isAdmin?: boolean | null; - community?: { id: string } | null; - }>; -}>; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => { - if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { - return false; - } - - return response.request().postData()?.includes(operationName) ?? false; -}; - -const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - - return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; -}; - -const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; '); +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { SubmitMemberCreateForm } from '../interactions/fill-and-submit-member-create-form.ts'; +import { FillMemberCreateForm } from '../interactions/fill-member-create-form.ts'; +import { OpenCreateMemberForm } from '../interactions/open-create-member-form.ts'; +import { ResolveCommunityAdministratorMembership } from '../interactions/resolve-community-administrator-membership.ts'; export const CreateMember = (communityName: string, memberName: string) => - Interaction.where(the`#actor creates member "${memberName}" in "${communityName}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - const communityId = communityIdsByName[communityName] ?? null; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - // get owner member id - const membersResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); - await page.goto('/community/accounts', { waitUntil: 'networkidle' }); - const membersPayload = selectGraphqlPayload((await (await membersResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); - const adminMemberId = membersPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; - if (!adminMemberId) { - throw new Error(`No administrator membership found for community "${communityId}"`); - } - - await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/create`, { waitUntil: 'networkidle' }); - await page.getByPlaceholder('Member Name').first().waitFor({ state: 'visible', timeout: 15_000 }); - - const adapter = new PlaywrightPageAdapter(page); - const memberCreatePage: E2EMemberCreatePage = new MemberCreatePage(adapter); - - await memberCreatePage.fillMemberName(memberName); - - const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createMemberOperationName), { timeout: 15_000 }).catch(() => null); - await memberCreatePage.clickCreateMember(); - - await memberCreatePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - const validationError = await memberCreatePage.firstValidationError.isVisible().catch(() => false); - if (validationError) { - const errorText = await memberCreatePage.firstValidationError.textContent(); - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', errorText || 'Validation error')); - return; - } - - const mutationResponse = await createMutationResponse; - if (mutationResponse) { - const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberCreateGraphqlPayload | MemberCreateGraphqlPayload[] | null, (data) => Boolean(data?.memberCreate)); - const graphqlError = graphqlErrors(payload); - const mutationResult = payload?.data?.memberCreate; - const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - const createdName = mutationResult?.member?.memberName ?? null; - - if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== memberName)) { - const message = - mutationError || - (mutationResult?.status?.success !== true - ? `${createMemberOperationName} did not report success: ${JSON.stringify(payload)}` - : createdName !== memberName - ? `Expected created member name "${memberName}" but GraphQL returned "${createdName ?? 'null'}"` - : `Member create GraphQL request failed with HTTP ${mutationResponse.status()}`); - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const memberId = mutationResult?.member?.id ?? null; - if (!memberId) { - const message = `${createMemberOperationName} succeeded but returned no member id`; - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await actor.attemptsTo(notes().set('lastMemberId', memberId)); - } - - await page.waitForURL(new RegExp(`/community/${communityId}/admin/[^/]+/members/[^/?#]+(?:/.*)?(?:\\?.*)?$`), { timeout: 15_000 }).catch(() => undefined); - await memberCreatePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); - const hasErrorToast = await memberCreatePage.errorToast.isVisible().catch(() => false); - if (hasErrorToast) { - const errorText = await memberCreatePage.errorToast.textContent(); - const message = errorText || 'Member creation failed'; - await actor.attemptsTo(notes().set('lastMemberId', null), notes().set('memberCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await actor.attemptsTo(notes().set('memberCreated', true), notes().set('errorMessage', null)); - }); + Task.where( + the`#actor creates member "${memberName}" in "${communityName}" via UI`, + new TaskStep('#actor creates a member through the community portal', async (actor) => { + await actor.attemptsTo(ResolveCommunityAdministratorMembership(actor, communityName), OpenCreateMemberForm(actor, communityName), FillMemberCreateForm(actor, memberName), SubmitMemberCreateForm(actor, memberName)); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts index 1024225b4..11813b929 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/list-members.ts @@ -1,68 +1,12 @@ -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; -const membersOperationName = 'AdminMemberListContainerMembers'; - -type GraphqlPayload = { - data?: TData; - errors?: Array<{ message?: string }>; -}; - -type MembersForCurrentEndUserPayload = GraphqlPayload<{ - membersForCurrentEndUser?: Array<{ - id: string; - isAdmin?: boolean | null; - community?: { id: string } | null; - }>; -}>; - -type MembersPayload = GraphqlPayload<{ - membersByCommunityId?: Array<{ id: string; memberName?: string | null }>; -}>; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); - -const selectPayload = (payload: GraphqlPayload | GraphqlPayload[] | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - - return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; -}; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { OpenCommunityMemberList } from '../interactions/open-community-member-list.ts'; +import { ResolveCommunityAdministratorMembership } from '../interactions/resolve-community-administrator-membership.ts'; export const ListMembers = (communityName: string) => - Interaction.where(the`#actor lists members in "${communityName}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const communityIdsByName = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIdsByName[communityName]; - if (!communityId) { - throw new Error(`Unknown community "${communityName}". Ensure it was created in setup.`); - } - - const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); - await page.goto('/community/accounts', { waitUntil: 'networkidle' }); - const membershipsPayload = selectPayload((await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); - const principalMemberId = membershipsPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; - if (!principalMemberId) { - throw new Error(`No administrator membership found for community "${communityName}"`); - } - - const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 15_000 }); - await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(principalMemberId)}/members`, { waitUntil: 'networkidle' }); - const response = await membersResponse; - const payload = selectPayload((await response.json().catch(() => null)) as MembersPayload | MembersPayload[] | null, (data) => Array.isArray(data?.membersByCommunityId)); - if (!response.ok() || payload?.errors?.length || !Array.isArray(payload?.data?.membersByCommunityId)) { - const message = - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; ') || `Failed to list members in "${communityName}"`; - throw new Error(message); - } - - await actor.attemptsTo(notes().set('principalMemberId', principalMemberId)); - }); + Task.where( + the`#actor lists members in "${communityName}" via UI`, + new TaskStep('#actor opens the community member list', async (actor) => { + await actor.attemptsTo(ResolveCommunityAdministratorMembership(actor, communityName), OpenCommunityMemberList(actor, communityName)); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts index da94db71b..4ae684589 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts @@ -1,151 +1,11 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberListPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import { buildUrl, getHostnames } from '../../../shared/environment/test-environment.ts'; -import type { E2EMemberListPage } from '../../../shared/page-contracts.ts'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -const membersOperationName = 'AdminMemberListContainerMembers'; -const removeMemberOperationName = 'AdminMemberListContainerRemoveMember'; -const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; -const apiUrl = buildUrl(getHostnames().api, '/api/graphql'); - -const removeMemberMutation = ` - mutation RemoveMember($input: RemoveMemberInput!) { - removeMember(input: $input) { - status { - success - errorMessage - } - } - } -`; - -type RemoveMemberPayload = { - data?: { removeMember?: { status?: { success?: boolean; errorMessage?: string | null } } }; - errors?: Array<{ message?: string }>; -}; - -type MembersForCurrentEndUserPayload = { - data?: { - membersForCurrentEndUser?: Array<{ - id: string; - isAdmin?: boolean | null; - community?: { id: string } | null; - }>; - }; - errors?: Array<{ message?: string }>; -}; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => response.url().includes('/api/graphql') && response.request().method() === 'POST' && (response.request().postData()?.includes(operationName) ?? false); - -const selectPayload = (payload: RemoveMemberPayload | RemoveMemberPayload[] | null): RemoveMemberPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - return payload.find((item) => item.data?.removeMember) ?? payload.find((item) => item.errors?.length) ?? null; -}; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { RemoveMember as RemoveMemberInteraction } from '../interactions/remove-member.ts'; export const RemoveMember = (communityName: string, memberName: string) => - Interaction.where(the`#actor removes member "${memberName}" from "${communityName}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const communityId = communityIds[communityName]; - if (!communityId) { - throw new Error(`Missing community state for removal from "${communityName}"`); - } - - // get adminMemberId; if not found still attempt to remove member and server should reject request - const membershipsResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); - await page.goto('/community/accounts', { waitUntil: 'networkidle' }); - const membershipsPayload = (await (await membershipsResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[]; - const selectedMembershipsPayload = Array.isArray(membershipsPayload) ? membershipsPayload.find((item) => item.data?.membersForCurrentEndUser) : membershipsPayload; - const adminMemberId = selectedMembershipsPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; - if (!adminMemberId) { - const memberId = await actor.answer(notes().get('lastMemberId')); - const accessToken = await page.evaluate(() => { - for (const storage of [sessionStorage, localStorage]) { - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index); - if (!key?.startsWith('oidc.user:')) continue; - const value = storage.getItem(key); - if (!value) continue; - const user = JSON.parse(value) as { access_token?: unknown }; - if (typeof user.access_token === 'string') return user.access_token; - } - } - return null; - }); - if (!memberId || !accessToken) { - throw new Error(`Missing authenticated removal request state for "${communityName}"`); - } - - const response = await page.request.post(apiUrl, { - data: { query: removeMemberMutation, variables: { input: { memberId } } }, - headers: { - Authorization: `Bearer ${accessToken}`, - 'x-community-id': communityId, - }, - }); - const payload = selectPayload((await response.json().catch(() => null)) as RemoveMemberPayload | RemoveMemberPayload[] | null); - const result = payload?.data?.removeMember; - if (response.ok() && !payload?.errors?.length && result?.status?.success === true) { - await actor.attemptsTo(notes().set('memberRemoved', true), notes().set('errorMessage', null)); - throw new Error('Expected the server to reject member removal'); - } - const message = - result?.status?.errorMessage ?? - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; ') ?? - `Member removal request failed with HTTP ${response.status()}`; - await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const membersResponse = page.waitForResponse(hasGraphqlOperation(membersOperationName), { timeout: 5_000 }).catch(() => null); - await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members`, { waitUntil: 'networkidle' }); - if (!(await membersResponse)) { - const message = 'User is not authorized for member management'; - await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const memberListPage: E2EMemberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); - const memberVisible = await memberListPage - .memberName(memberName) - .waitFor({ state: 'visible', timeout: 3_000 }) - .then( - () => true, - () => false, - ); - if (!memberVisible) { - const message = 'User is not authorized for member management'; - await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); - throw new Error(message); - } - const mutationResponse = page.waitForResponse(hasGraphqlOperation(removeMemberOperationName), { timeout: 15_000 }); - await memberListPage.clickRemoveMember(memberName); - - const response = await mutationResponse; - const payload = selectPayload((await response.json().catch(() => null)) as RemoveMemberPayload | RemoveMemberPayload[] | null); - const result = payload?.data?.removeMember; - if (!response.ok() || payload?.errors?.length || result?.status?.success !== true) { - const message = - result?.status?.errorMessage ?? - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; ') ?? - 'Member removal failed'; - await actor.attemptsTo(notes().set('memberRemoved', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await memberListPage.memberName(memberName).waitFor({ state: 'hidden', timeout: 15_000 }); - await actor.attemptsTo(notes().set('memberRemoved', true), notes().set('errorMessage', null)); - }); + Task.where( + the`#actor removes member "${memberName}" from "${communityName}"`, + new TaskStep('#actor removes a member through the community portal', async (actor) => { + await actor.attemptsTo(RemoveMemberInteraction(actor, communityName, memberName)); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts index 8ad6aaf61..79c4d8caf 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/search-member-list.ts @@ -1,13 +1,11 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberListPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, the } from '@serenity-js/core'; -import type { E2EMemberListPage } from '../../../shared/page-contracts.ts'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { SearchMemberList as SearchMemberListInteraction } from '../interactions/search-member-list.ts'; export const SearchMemberList = (searchTerm: string) => - Interaction.where(the`#actor searches the member list for "${searchTerm}"`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const memberListPage: E2EMemberListPage = new MemberListPage(new PlaywrightPageAdapter(page)); - await memberListPage.searchByMemberName(searchTerm); - }); + Task.where( + the`#actor searches the member list for "${searchTerm}"`, + new TaskStep('#actor searches the open member list', async (actor) => { + await actor.attemptsTo(SearchMemberListInteraction(actor, searchTerm)); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts index 9d0dca612..f20d16a98 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts @@ -1,10 +1,10 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { MemberProfilePage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import type { E2EMemberProfilePage } from '../../../shared/page-contracts.ts'; -import type { MemberE2ENotes, MemberProfileExpectation } from '../notes/member-notes.ts'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, Task, the } from '@serenity-js/core'; +import { SubmitMemberProfileForm } from '../interactions/fill-and-submit-member-profile-form.ts'; +import { FillMemberProfileForm } from '../interactions/fill-member-profile-form.ts'; +import { OpenMemberProfileEditor } from '../interactions/open-member-profile-editor.ts'; +import { ResolveCommunityAdministratorMembership } from '../interactions/resolve-community-administrator-membership.ts'; +import type { MemberProfileExpectation } from '../notes/member-notes.ts'; interface UpdateMemberProfileDetails { communityName: string; @@ -12,128 +12,15 @@ interface UpdateMemberProfileDetails { profile: MemberProfileExpectation; } -type GraphqlPayload = { - data?: TData; - errors?: Array<{ message?: string }>; -}; - -const memberUpdateProfileOperationName = 'SharedMemberProfileContainerMemberUpdateProfile'; -const membersForCurrentEndUserOperationName = 'AccountsCommunityListContainerMembersForCurrentEndUser'; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => { - if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { - return false; - } - - return response.request().postData()?.includes(operationName) ?? false; -}; - -const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; '); - -const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - - return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; -}; - -type MemberUpdateProfileMutationPayload = GraphqlPayload<{ - memberUpdateProfile?: { - status?: { - success?: boolean; - errorMessage?: string | null; - }; - member?: { - id?: string | null; - } | null; - }; -}>; - -type MembersForCurrentEndUserPayload = GraphqlPayload<{ - membersForCurrentEndUser?: Array<{ - id: string; - isAdmin?: boolean | null; - community?: { id: string } | null; - }>; -}>; - export const UpdateMemberProfile = (details: UpdateMemberProfileDetails) => - Interaction.where(the`#actor updates member profile for "${details.memberName}" in "${details.communityName}"`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - const memberProfilePage: E2EMemberProfilePage = new MemberProfilePage(new PlaywrightPageAdapter(page)); - const communityIdsByName = (await actor.answer(notes().get('communityIdsByName')).catch(() => ({}) as Record)) as Record; - const communityId = communityIdsByName[details.communityName] ?? null; - if (!communityId) { - throw new Error(`Unknown community "${details.communityName}". Ensure it was created in setup.`); - } - - const memberId = (await actor.answer(notes().get('lastMemberId')).catch(() => null)) as string | null; - if (!memberId) { - throw new Error('Cannot update member profile because no member id is available in notes'); - } - - // get owner member id - const membersResponse = page.waitForResponse(hasGraphqlOperation(membersForCurrentEndUserOperationName), { timeout: 15_000 }); - await page.goto('/community/accounts', { waitUntil: 'networkidle' }); - const membersPayload = selectGraphqlPayload((await (await membersResponse).json()) as MembersForCurrentEndUserPayload | MembersForCurrentEndUserPayload[], (data) => Boolean(data?.membersForCurrentEndUser)); - const adminMemberId = membersPayload?.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; - if (!adminMemberId) { - throw new Error(`No administrator membership found for community "${communityId}"`); - } - - await page.goto(`/community/${encodeURIComponent(communityId)}/admin/${encodeURIComponent(adminMemberId)}/members/${encodeURIComponent(memberId)}/profile`, { waitUntil: 'networkidle' }); - await memberProfilePage.clickEditProfile(); - - if (details.profile.name !== undefined) await memberProfilePage.fillDisplayName(details.profile.name); - if (details.profile.email !== undefined) await memberProfilePage.fillEmail(details.profile.email); - if (details.profile.bio !== undefined) await memberProfilePage.fillBio(details.profile.bio); - if (details.profile.showInterests !== undefined) await memberProfilePage.setShowInterests(details.profile.showInterests); - if (details.profile.showEmail !== undefined) await memberProfilePage.setShowEmail(details.profile.showEmail); - if (details.profile.showProfile !== undefined) await memberProfilePage.setShowProfile(details.profile.showProfile); - if (details.profile.showLocation !== undefined) await memberProfilePage.setShowLocation(details.profile.showLocation); - if (details.profile.showProperties !== undefined) await memberProfilePage.setShowProperties(details.profile.showProperties); - - const mutationResponsePromise = page.waitForResponse(hasGraphqlOperation(memberUpdateProfileOperationName), { timeout: 15_000 }).catch(() => null); - await memberProfilePage.clickSaveProfile(); - - await memberProfilePage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - const validationError = await memberProfilePage.firstValidationError.isVisible().catch(() => false); - if (validationError) { - const errorText = await memberProfilePage.firstValidationError.textContent(); - await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', errorText || 'Validation error')); - return; - } - - const mutationResponse = await mutationResponsePromise; - if (mutationResponse) { - const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as MemberUpdateProfileMutationPayload | MemberUpdateProfileMutationPayload[] | null, (data) => Boolean(data?.memberUpdateProfile)); - const graphqlError = graphqlErrors(payload); - const mutationResult = payload?.data?.memberUpdateProfile; - const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - - if (!mutationResponse.ok() || graphqlError || mutationResult?.status?.success !== true) { - const message = - mutationError || - (mutationResult?.status?.success !== true ? `${memberUpdateProfileOperationName} did not report success: ${JSON.stringify(payload)}` : `Member update GraphQL request failed with HTTP ${mutationResponse.status()}`); - await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - } - - await memberProfilePage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); - const hasErrorToast = await memberProfilePage.errorToast.isVisible().catch(() => false); - if (hasErrorToast) { - const errorText = await memberProfilePage.errorToast.textContent(); - const message = errorText || 'Member profile update failed'; - await actor.attemptsTo(notes().set('memberUpdated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await actor.attemptsTo(notes().set('memberUpdated', true), notes().set('errorMessage', null)); - }); + Task.where( + the`#actor updates member profile for "${details.memberName}" in "${details.communityName}"`, + new TaskStep('#actor updates the member profile through the community portal', async (actor) => { + await actor.attemptsTo( + ResolveCommunityAdministratorMembership(actor, details.communityName), + OpenMemberProfileEditor(actor, details.communityName, details.memberName), + FillMemberProfileForm(actor, details.profile), + SubmitMemberProfileForm(actor), + ); + }), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts deleted file mode 100644 index 54f5a4bfa..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-role.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { TaskStep } from '@cellix/serenity-framework/serenity'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { seedEndUserRole } from '@ocom-verification/verification-shared/test-data'; -import { type Actor, Interaction, notes, Task, the } from '@serenity-js/core'; -import { mongoDbName, testMongoServer } from '../../../servers/test-mongo-server.ts'; -import { buildUrl, getHostnames } from '../../../shared/environment/test-environment.ts'; -import type { MemberE2ENotes } from '../notes/member-notes.ts'; - -const apiUrl = buildUrl(getHostnames().api, '/api/graphql'); - -const membersForCurrentEndUserQuery = ` - query MembersForCurrentEndUser { - membersForCurrentEndUser { - id - isAdmin - community { id } - } - } -`; - -const memberRoleUpdateMutation = ` - mutation MemberRoleUpdate($input: UpdateMemberRoleInput!) { - memberRoleUpdate(input: $input) { - status { success errorMessage } - member { role { roleName } } - } - } -`; - -type MembershipPayload = { - data?: { membersForCurrentEndUser?: Array<{ id?: string; isAdmin?: boolean; community?: { id?: string } }> }; - errors?: Array<{ message?: string }>; -}; - -type MemberRoleUpdatePayload = { - data?: { memberRoleUpdate?: { status?: { success?: boolean; errorMessage?: string | null }; member?: { role?: { roleName?: string | null } | null } | null } }; - errors?: Array<{ message?: string }>; -}; - -function errorMessage(payload: { errors?: Array<{ message?: string }> } | null): string | null { - return ( - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; ') ?? null - ); -} - -async function accessToken(actor: Actor): Promise { - const { page } = BrowseTheWeb.withActor(actor); - const token = await page.evaluate(() => { - for (const storage of [sessionStorage, localStorage]) { - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index); - if (!key?.startsWith('oidc.user:')) continue; - const value = storage.getItem(key); - if (!value) continue; - const user = JSON.parse(value) as { access_token?: unknown }; - if (typeof user.access_token === 'string') return user.access_token; - } - } - return null; - }); - if (!token) throw new Error('Missing browser OAuth access token'); - return token; -} - -export const SeedEndUserRole = (roleName: string, communityName: string, communityId: string) => - Task.where( - the`#actor seeds role "${roleName}" in "${communityName}"`, - new TaskStep('#actor records the seeded role', async (actor) => { - const roleId = await seedEndUserRole({ connectionString: testMongoServer.getConnectionString(), dbName: mongoDbName }, { communityId, roleName }); - const roleIdsByCommunityName = await actor.answer(notes().get('roleIdsByCommunityName')).catch(() => ({}) as Record>); - await actor.attemptsTo( - notes().set('roleIdsByCommunityName', { - ...roleIdsByCommunityName, - [communityName]: { ...roleIdsByCommunityName[communityName], [roleName]: roleId }, - }), - ); - }), - ); - -export const UpdateMemberRole = (communityName: string, roleName: string) => - Interaction.where(the`#actor assigns role "${roleName}" in "${communityName}"`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const communityIds = await actor.answer(notes().get('communityIdsByName')); - const roleIds = await actor.answer(notes().get('roleIdsByCommunityName')); - const memberId = await actor.answer(notes().get('lastMemberId')); - const communityId = communityIds[communityName]; - const roleId = Object.values(roleIds) - .map((roles) => roles[roleName]) - .find(Boolean); - if (!communityId || !memberId || !roleId) throw new Error(`Missing role update state for "${roleName}" in "${communityName}"`); - - const token = await accessToken(actor); - const membershipsResponse = await BrowseTheWeb.withActor(actor).page.request.post(apiUrl, { - data: { query: membersForCurrentEndUserQuery }, - headers: { Authorization: `Bearer ${token}` }, - }); - const membershipsPayload = (await membershipsResponse.json()) as MembershipPayload; - const principalMemberId = membershipsPayload.data?.membersForCurrentEndUser?.find((member) => member.isAdmin && member.community?.id === communityId)?.id; - if (!principalMemberId) throw new Error(`No administrator membership found for community "${communityId}"`); - - const response = await BrowseTheWeb.withActor(actor).page.request.post(apiUrl, { - data: { query: memberRoleUpdateMutation, variables: { input: { memberId, roleId, reason: 'Role assignment verification' } } }, - headers: { Authorization: `Bearer ${token}`, 'x-community-id': communityId, 'x-member-id': principalMemberId }, - }); - const payload = (await response.json().catch(() => null)) as MemberRoleUpdatePayload | null; - const result = payload?.data?.memberRoleUpdate; - const message = result?.status?.errorMessage ?? errorMessage(payload) ?? `Member role update request failed with HTTP ${response.status()}`; - if (!response.ok() || result?.status?.success !== true) { - await actor.attemptsTo(notes().set('errorMessage', message)); - throw new Error(message); - } - - await actor.attemptsTo(notes().set('lastMemberRoleName', result.member?.role?.roleName ?? null), notes().set('errorMessage', null)); - }); diff --git a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts index 326d1a645..c967a0b89 100644 --- a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts +++ b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts @@ -3,7 +3,7 @@ import { getMongoPort } from '@ocom-verification/verification-shared/environment import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { appPaths } from '../shared/environment/app-paths.ts'; -export const mongoDbName = 'owner-community'; +const mongoDbName = 'owner-community'; const mongoReplSetName = 'globaldb'; export const testMongoServer = new MongoMemoryProcessTestServer({ diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index af887f461..677ab56e7 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -1,16 +1,5 @@ -import type { CommunityPage, HomePage, MemberAccountsPage, MemberCreatePage, MemberListPage, MemberProfilePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; export type E2EHomePage = Pick; export type E2ECommunityPage = Pick; - -export type E2EMemberCreatePage = Pick; - -export type E2EMemberListPage = Pick; - -export type E2EMemberAccountsPage = Pick; - -export type E2EMemberProfilePage = Pick< - MemberProfilePage, - 'clickEditProfile' | 'clickSaveProfile' | 'fillDisplayName' | 'fillEmail' | 'fillBio' | 'setShowInterests' | 'setShowEmail' | 'setShowProfile' | 'setShowLocation' | 'setShowProperties' | 'firstValidationError' | 'errorToast' ->; diff --git a/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts index 08d8fd464..8db72039e 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-accounts.page.ts @@ -9,10 +9,24 @@ export class MemberAccountsPage extends AdapterBackedPageObject { return this.adapter.getByRole('button', { name: /Add Member Account/i }); } + get addAccountButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Add Account/i }); + } + + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, .ant-notification-notice-error'); + } + linkedAccountEmail(email: string): ElementHandle { return this.adapter.getByText(email); } + async linkedAccountCount(email: string): Promise { + const cells = await this.adapter.locatorAll('tbody td'); + const texts = await Promise.all(cells.map((cell) => cell.textContent())); + return texts.filter((text) => text?.trim() === email).length; + } + async selectEndUser(displayName: string): Promise { await this.endUserSelect.click(); await this.endUserSelect.fill(displayName); diff --git a/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts index ad5032b9c..b6c1775cf 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-create.page.ts @@ -9,7 +9,7 @@ export class MemberCreatePage extends AdapterBackedPageObject { return this.adapter.getByRole('button', { name: /Create Member/i }); } - get firstValidationError(): ElementHandle { + get validationError(): ElementHandle { return this.adapter.locator('.ant-form-item-explain-error'); } diff --git a/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts index a81a452a7..59930eaa5 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts @@ -9,6 +9,10 @@ export class MemberListPage extends AdapterBackedPageObject { return this.adapter.getByText(name); } + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, .ant-notification-notice-error'); + } + async searchByMemberName(name: string): Promise { await this.searchInput.fill(name); } diff --git a/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts index 37e9efd98..85831d9de 100644 --- a/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/member-profile.page.ts @@ -49,6 +49,18 @@ export class MemberProfilePage extends AdapterBackedPageObject { await this.bioInput.fill(value); } + async displayNameValue(): Promise { + return (await this.displayNameInput.inputValue()) ?? ''; + } + + async emailValue(): Promise { + return (await this.emailInput.inputValue()) ?? ''; + } + + async bioValue(): Promise { + return (await this.bioInput.inputValue()) ?? ''; + } + async setShowInterests(value: boolean): Promise { await this.setSwitchAtIndex(0, value); } @@ -69,6 +81,26 @@ export class MemberProfilePage extends AdapterBackedPageObject { await this.setSwitchAtIndex(4, value); } + async showInterestsValue(): Promise { + return await this.switchValueAtIndex(0); + } + + async showEmailValue(): Promise { + return await this.switchValueAtIndex(1); + } + + async showProfileValue(): Promise { + return await this.switchValueAtIndex(2); + } + + async showLocationValue(): Promise { + return await this.switchValueAtIndex(3); + } + + async showPropertiesValue(): Promise { + return await this.switchValueAtIndex(4); + } + private async setSwitchAtIndex(index: number, expectedChecked: boolean): Promise { const switches = await this.adapter.locatorAll('button[role="switch"]'); const target = switches[index]; @@ -82,4 +114,14 @@ export class MemberProfilePage extends AdapterBackedPageObject { await target.click(); } } + + private async switchValueAtIndex(index: number): Promise { + const switches = await this.adapter.locatorAll('button[role="switch"]'); + const target = switches[index]; + if (!target) { + throw new Error(`Expected switch at index ${index} but none was found`); + } + + return (await target.getAttribute('aria-checked')) === 'true'; + } } diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature index 5612e7d8d..3b2a3a174 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -37,6 +37,7 @@ Feature: Community member management When Alice associates end-user account "Charlie" to member "Charlie Walker" in "Green Oaks" Then member "Charlie Walker" should be linked to end-user account "Charlie" + @api-only Scenario: Update a member role with a role from the same community Given Alice creates a member in "Green Oaks" with: | memberName | Charlie Walker | @@ -44,7 +45,7 @@ Feature: Community member management When Alice changes member "Charlie Walker" in "Green Oaks" to role "board member" Then member "Charlie Walker" should have role "board member" in "Green Oaks" - @validation + @api-only @validation Scenario: Cannot assign a role from another community Given Alice creates a member in "Green Oaks" with: | memberName | Charlie Walker |