Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
65020a8
add acceptance-api scenarios and implementations; add ability to pass…
aaron-rabinowitz Jul 10, 2026
48b083d
.snyk file change
aaron-rabinowitz Jul 10, 2026
33f3343
add initial acceptance-ui and e2e steps
aaron-rabinowitz Jul 10, 2026
188048c
add scenario implementations for updating member profiles, assigning …
aaron-rabinowitz Jul 14, 2026
5e8e546
added scenarios implementations for testing member and end user permi…
aaron-rabinowitz Jul 14, 2026
4f10cfb
added scenarios implementations for listing and filtering comunity me…
aaron-rabinowitz Jul 14, 2026
40e98df
added scenarios implementations for updating and verifying user roles
aaron-rabinowitz Jul 15, 2026
ef94071
remove unused role field
aaron-rabinowitz Jul 15, 2026
91f874a
override websocket-driver to 0.7. and ws to 8.21.1; removed unnecesar…
aaron-rabinowitz Jul 16, 2026
e42e738
ensure user is not in community
aaron-rabinowitz Jul 16, 2026
2974dec
remove unused file
aaron-rabinowitz Jul 16, 2026
caf334a
merge main into current branch
aaron-rabinowitz Jul 23, 2026
9d8e2a4
merge main into branch
aaron-rabinowitz Jul 23, 2026
a51feb2
upgrade opentemetry to support v2
aaron-rabinowitz Jul 23, 2026
091a4ad
synchronize lock file with pnpm-workspace.yml
aaron-rabinowitz Jul 23, 2026
aa6c1e3
refactor e2e tasks to use interactions; refactor acceptance-api creat…
aaron-rabinowitz Jul 23, 2026
83bc846
merge changes from main; fix build errors
aaron-rabinowitz Aug 5, 2026
740f09b
merge main into branch
aaron-rabinowitz Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/cellix/serenity-framework/src/clients/graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

/**
* Serenity ability for executing GraphQL operations over HTTP.
*
Expand Down Expand Up @@ -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<TData extends Record<string, unknown> = Record<string, unknown>>(query: string, variables: Record<string, unknown> = {}): Promise<GraphQLResponse<TData>> {
async execute<TData extends Record<string, unknown> = Record<string, unknown>>(query: string, variables: Record<string, unknown> = {}, options: GraphQLExecuteOptions = {}): Promise<GraphQLResponse<TData>> {
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 }),
});
Expand Down
1 change: 1 addition & 0 deletions packages/ocom-verification/acceptance-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>;
roleIdsByCommunityName: Record<string, Record<string, string>>;
listedMemberNames: string[];
endUserIdsByLabel: Record<string, string>;
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core';
import type { MemberNotes } from '../notes/member-notes.ts';

/** Resolves the ID of a community created earlier in the current scenario. */
export class CommunityIdNamed extends Question<Promise<string>> {
static called(communityName: string): CommunityIdNamed {
return new CommunityIdNamed(communityName);
}

private constructor(private readonly communityName: string) {
super(`ID of community "${communityName}"`);
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<string> {
const communityIdsByName = await actor.answer(notes<MemberNotes>().get('communityIdsByName')).catch(() => ({}) as Record<string, string>);
const communityId = communityIdsByName[this.communityName];
if (!communityId) {
throw new Error(`Unknown community "${this.communityName}". Ensure it was created in setup.`);
}

return communityId;
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<string>> {
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<string> {
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<Record<string, unknown>> | 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']);
}
}
Original file line number Diff line number Diff line change
@@ -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 = <TData extends Record<string, unknown> = Record<string, unknown>>(query: string, variables?: Record<string, unknown>, options?: { headers?: Record<string, string> }) => Promise<{ data: TData }>;

export class HasNoMemberForCommunity extends Question<Promise<boolean>> {
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<boolean> {
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));
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<boolean>> {
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<boolean> {
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<Promise<number>> {
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<number> {
const member = await actor.answer(MemberById.withId(this.memberId));
return member?.accounts?.filter((account) => account.user?.id === this.endUserId).length ?? 0;
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<MemberByIdResult | undefined>> {
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<MemberByIdResult | undefined> {
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<string, unknown> | undefined;
if (!member) {
return undefined;
}

const profile = member['profile'] as Record<string, unknown> | undefined;
const accounts = Array.isArray(member['accounts'])
? (member['accounts'] as Array<Record<string, unknown>>).map((account) => ({
user: {
id: (account['user'] as Record<string, unknown> | undefined)?.['id'] !== undefined ? String((account['user'] as Record<string, unknown>)['id']) : undefined,
},
}))
: undefined;
const interests = Array.isArray(profile?.['interests']) ? (profile?.['interests'] as Array<unknown>).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<string, unknown> | undefined)?.['id'] !== undefined ? String((member['community'] as Record<string, unknown>)['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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<boolean>> {
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<boolean> {
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<string, unknown>)['memberName'] === this.memberName);
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<string | null>> {
static fromLastUpdate(): MemberRoleName {
return new MemberRoleName();
}

private constructor() {
super('role name returned by the member update');
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<string | null> {
const roleName = await actor.answer(notes<MemberNotes>().get('lastMemberRoleName'));
return roleName || null;
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<boolean>> {
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<boolean> {
const memberNames = await actor.answer(notes<MemberNotes>().get('listedMemberNames'));
return memberNames.includes(this.memberName);
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<string>> {
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<string> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
Loading
Loading