Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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