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
19 changes: 10 additions & 9 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand All @@ -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'
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,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,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<Promise<string>> {
constructor() {
super('member status');
}

static of(): MemberStatus {
return new MemberStatus();
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<string> {
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<string | undefined> {
try {
return await actor.answer(notes<Record<typeof key, string>>().get(key));
} catch {
return undefined;
}
}
}
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);
}
}
Loading
Loading