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 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..d5cc0d99f --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,39 @@ +// 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; + roleIdsByCommunityName: Record>; + listedMemberNames: string[]; + endUserIdsByLabel: Record; + lastMemberCommunityId: string; + lastMemberId: string; + lastMemberName: string; + lastMemberRoleName: string; + lastMemberRemoved: boolean; + lastLinkedEndUserId: string; + lastExpectedMemberProfile?: MemberProfileExpectation; + lastMemberProfileEmail?: string; + lastExpectedMemberProfileEmail?: string; + lastMemberStatus: string; + lastValidationError: string; + lastAuthorizationError: string; + principalMemberId: string; + authorizationToken: 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/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/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..b1c8e7050 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,38 @@ +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; + } +} + +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/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..5165e588a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/member-by-id.ts @@ -0,0 +1,83 @@ +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; + }; + accounts?: Array<{ + user?: { + 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 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 { + 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, + }, + accounts, + 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/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-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/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/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/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/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..57f08046e --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -0,0 +1,64 @@ +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 { MemberAccountCount, 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(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) => { + 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}"`); + } +}); + +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 new file mode 100644 index 000000000..ae4c7c9e2 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-member.steps.ts @@ -0,0 +1,109 @@ +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 { 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; + email?: string; +} + +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)); + + 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, + }), + ); +}); + +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() ?? ''; + + 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( + CreateMember.with({ + memberName, + communityId, + }), + ); +}); + +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.`); + } + + 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}"`); + } +}); + +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 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}"`); + } +}); + +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 c04c54d61..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 @@ -1,2 +1,8 @@ // Community context step definitions 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/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..fabdac63b --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/list-members.steps.ts @@ -0,0 +1,80 @@ +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'; + +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()) { + const { memberName } = details; + if (!memberName) { + throw new Error('memberName is required when creating listed members'); + } + await actor.attemptsTo(CreateMember.with({ memberName: memberName.trim(), communityId })); + } +}); + +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 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`); + } + } +}); + +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/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/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..466df1bc8 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/remove-member.steps.ts @@ -0,0 +1,98 @@ +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'; + +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); + 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', authorizationToken), + notes().set('lastAuthorizationError', undefined as unknown as string), + ); +}); + +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}"`); + } +}); + +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/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/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 new file mode 100644 index 000000000..c63f3ce65 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/create-member.ts @@ -0,0 +1,46 @@ +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; + 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, + }; + 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}"`; +} 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/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..7e966abc6 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/remove-member.ts @@ -0,0 +1,29 @@ +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(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/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-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/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/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 7b162160a..fb8ee2a0e 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'; @@ -22,8 +23,8 @@ const communityCreationMessages: RecordedCommunityCreationMessage[] = []; 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, @@ -120,6 +121,7 @@ function createRecordingQueueStorageService(): QueueStorageOperations { export function createMockApplicationServicesFactory(serviceMongoose: ServiceMongoose): ApplicationServicesFactory { const dataSourcesFactory = Persistence(serviceMongoose); + RegisterEventHandlers(dataSourcesFactory.withSystemPassport().domainDataSource); const blobStorageService = createNoOpBlobStorageService(); const clientOperationsService = createNoOpClientOperationsService(); const queueStorageService = createRecordingQueueStorageService(); @@ -136,8 +138,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/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/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..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 @@ -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/create-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts new file mode 100644 index 000000000..8ca0323db --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-member.ts @@ -0,0 +1,89 @@ +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/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index 2ed5ecf3a..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,2 +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/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/shared/abilities/remove-member.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts new file mode 100644 index 000000000..c77d5f113 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/remove-member.ts @@ -0,0 +1,52 @@ +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; + authorizationToken?: 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, + ...(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) { + 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/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/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/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 new file mode 100644 index 000000000..403dbc64a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/member-operations.ts @@ -0,0 +1,141 @@ +export const MEMBER_CREATE_MUTATION = ` + mutation MemberCreate($input: MemberCreateInput!) { + memberCreate(input: $input) { + status { + success + errorMessage + } + member { + id + memberName + } + } + } +`; + +export const REMOVE_MEMBER_MUTATION = ` + mutation RemoveMember($input: RemoveMemberInput!) { + removeMember(input: $input) { + status { + success + errorMessage + } + } + } +`; + +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) { + status { + success + errorMessage + } + member { + id + profile { + name + email + bio + avatarDocumentId + interests + showInterests + showEmail + showProfile + showLocation + showProperties + } + } + } + } +`; + +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 { + 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 + } + accounts { + user { + 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..86252c0c0 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -3,8 +3,14 @@ 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 { 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, @@ -16,7 +22,16 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility()], + abilities: [ + () => createGraphQLClientAbility(graphqlUrl(state)), + () => createCommunityAbility(), + () => createMemberAbility(), + () => listMembersAbility(), + () => 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 new file mode 100644 index 000000000..82a59e99c --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,30 @@ +export interface MemberUiNotes { + endUserIdsByLabel: Record; + membersByCommunityName: Record; + rolesByCommunityName: Record; + lastMemberId: string; + lastMemberRoleName: string; + lastLinkedEndUserId: string; + memberCreated: boolean; + memberRemoved: boolean; + memberValidationError: string; + memberAccountLinked: boolean; + memberAccountCount: number; + memberAuthorizationError: string; + 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/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/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..2dadf242a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,11 @@ +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; + }); + +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/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/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..c4f6008b2 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/assign-member-account.steps.tsx @@ -0,0 +1,91 @@ +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 { 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 }; +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 => { + 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( + , + { 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(notes().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) => { + 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}"`); + } +}); + +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 new file mode 100644 index 000000000..cdc265dda --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-member.steps.tsx @@ -0,0 +1,117 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +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 { 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'; + +const reactGlobal = globalThis as typeof globalThis & { React?: typeof React }; +reactGlobal.React ??= React; + +interface MemberDetails { + memberName?: string; +} + +Given('{word} is signed in as a community owner for member management', async (actorName: string) => { + const actor = actorCalled(actorName); + + const onSave = async (): Promise => { + await actor.attemptsTo(notes().set('memberCreated', true)); + }; + + await actor.attemptsTo(notes().set('memberCreated', false), Render.component(, { wrapper: wrapOcomComponent() })); +}); + +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) => { + 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(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)); + 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('memberUpdated', false), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + ); +}); + +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 form to be submitted'); + } +}); + +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('she should see a member error for {string}', async (fieldName: string) => { + const error = await actorInTheSpotlight().answer(notes().get('memberValidationError')); + 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}"`); + } +}); + +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 bc657d814..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 @@ -1,2 +1,8 @@ // Community context step definitions 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/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/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/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..8d72b1cc0 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/remove-member.steps.tsx @@ -0,0 +1,112 @@ +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +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'; +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'; + +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')); + 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`); + } +}); + +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/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/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/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/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/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/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/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/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index b7512789f..00551067d 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,16 @@ -import type { CommunityPage, HomePage } 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 new file mode 100644 index 000000000..2b8b82b4e --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/member-notes.ts @@ -0,0 +1,34 @@ +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; + 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; + memberUpdated: boolean; + 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/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/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..f39681598 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-account-linked.ts @@ -0,0 +1,17 @@ +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 type { MemberE2ENotes } from '../notes/member-notes.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(); + }); + +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-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/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..d2c79b924 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/member-listed-in-community.ts @@ -0,0 +1,15 @@ +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, 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/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..871c91fd6 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/assign-member-account.steps.ts @@ -0,0 +1,63 @@ +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 { MemberAccountCount, 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), + 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(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) => { + 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}"`); + } +}); + +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 new file mode 100644 index 000000000..4f4d3f494 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-member.steps.ts @@ -0,0 +1,95 @@ +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 { 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'; + +interface MemberDetails { + memberName?: string; +} + +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, + }), + ); +}); + +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('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 created = await actor.answer(MemberCreatedFlag()); + if (!created) { + throw new Error('Expected member creation to succeed'); + } +}); + +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))); + } +}); + +Then('she should see a member error for {string}', async (fieldName: string) => { + const error = await actorCalled(lastActorName).answer(MemberErrorMessage()); + 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}"`); + } +}); + +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 c04c54d61..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 @@ -1,2 +1,8 @@ // Community context step definitions 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/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/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/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..b9a1ff750 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/remove-member.steps.ts @@ -0,0 +1,82 @@ +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, 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'; + +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('test@example.com'), + ); + 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) => { + 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`); + } +}); + +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|cannot remove|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/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..e0d08704b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-member.steps.ts @@ -0,0 +1,63 @@ +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('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 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/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..94fd42881 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/assign-member-account.ts @@ -0,0 +1,75 @@ +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 }).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)); + }); 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..72c4867e5 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-member.ts @@ -0,0 +1,139 @@ +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('; '); + +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)); + }); 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 new file mode 100644 index 000000000..da94db71b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/remove-member.ts @@ -0,0 +1,151 @@ +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; +}; + +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)); + }); 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/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..9d0dca612 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-member-profile.ts @@ -0,0 +1,139 @@ +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'; + +interface UpdateMemberProfileDetails { + communityName: string; + memberName: string; + 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)); + }); 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/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/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index 677ab56e7..af887f461 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,16 @@ -import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, HomePage, MemberAccountsPage, MemberCreatePage, MemberListPage, MemberProfilePage } 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/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 63aa501c8..253c07ead 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,2 +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 new file mode 100644 index 000000000..ad5032b9c --- /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.getByLabel('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-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts new file mode 100644 index 000000000..a81a452a7 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/member-list.page.ts @@ -0,0 +1,32 @@ +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'); + 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/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(); + } + } +} 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..5612e7d8d --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/member/member-lifecycle.feature @@ -0,0 +1,114 @@ +Feature: Community member management + + As a community owner or administrator + 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" + + 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" + + 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" + + 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 + 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" + And the following members exist in "Green Oaks": + | memberName | + | Charlie Walker | + | Dana Ortiz | + 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: Filter the community member list by member name + Given the following members exist in "Green Oaks": + | 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 | + | 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: + | 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 + 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 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 + 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" \ No newline at end of file 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(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb196c84..9b5f0edb8 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,16 +161,17 @@ 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 - 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 @@ -1335,6 +1336,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 @@ -1379,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 @@ -2133,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 @@ -2227,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 @@ -2385,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 @@ -2467,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 @@ -2546,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 @@ -2736,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) @@ -2809,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 @@ -6175,9 +6179,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 +7876,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 +8376,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==} @@ -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 @@ -9734,10 +9735,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 +9743,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} @@ -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==} @@ -10200,12 +10201,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 +12049,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 +12123,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 +13433,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==} @@ -13880,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: @@ -13982,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 @@ -14385,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 @@ -14402,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: @@ -14445,7 +14442,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 +16484,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 +16930,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 +16955,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 @@ -17379,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 @@ -17410,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 @@ -17534,7 +17531,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 @@ -17584,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' @@ -17631,7 +17628,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 +18070,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 +18643,6 @@ snapshots: '@protobufjs/inquire@1.1.1': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -19910,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 @@ -19928,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 @@ -20351,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 @@ -20625,34 +20620,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 +21170,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 +21217,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 +21999,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 @@ -22096,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: @@ -22512,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: @@ -22526,7 +22506,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 @@ -22545,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 @@ -22865,15 +22845,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 @@ -23216,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: {} @@ -23283,12 +23263,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 @@ -23314,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 @@ -23416,7 +23396,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 +25565,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 +25573,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 +25643,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 @@ -26540,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: {} @@ -26641,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 @@ -26663,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: @@ -27162,9 +27134,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 @@ -27579,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 @@ -27624,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: @@ -27689,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 @@ -27830,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 b48bb6c08..e613a6895 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,16 +109,17 @@ 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 - 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