From 1e5dadacd38c33d003917d126e3eff3fbd9e5bbd Mon Sep 17 00:00:00 2001 From: Kristaps Fabians Geikins Date: Mon, 29 Jul 2024 11:21:59 +0300 Subject: [PATCH] feat(server): modularized mocks + workspace mocks for Mike (#2534) * modularized mocks foundation + updated workspaces gql * base queries done * mutations done * cleaner API for mock helpers * greatly improved mock definition DX --- .../lib/common/generated/gql/graphql.ts | 222 ++++++++ packages/server/.env-example | 4 + .../typedefs/workspaces.graphql | 29 +- .../modules/automate/graph/mocks/automate.ts | 381 +++++++++++++ .../modules/core/graph/generated/graphql.ts | 36 +- .../server/modules/core/graph/mocks/core.ts | 31 ++ .../graph/generated/graphql.ts | 24 +- packages/server/modules/index.js | 39 +- packages/server/modules/mocks.ts | 507 ++++-------------- .../modules/shared/helpers/envHelper.ts | 8 + .../server/modules/shared/helpers/mocks.ts | 206 +++++++ .../workspaces/graph/mocks/workspaces.ts | 229 ++++++++ packages/server/package.json | 2 +- .../server/test/graphql/generated/graphql.ts | 24 +- yarn.lock | 10 +- 15 files changed, 1327 insertions(+), 425 deletions(-) create mode 100644 packages/server/modules/automate/graph/mocks/automate.ts create mode 100644 packages/server/modules/core/graph/mocks/core.ts create mode 100644 packages/server/modules/shared/helpers/mocks.ts create mode 100644 packages/server/modules/workspaces/graph/mocks/workspaces.ts diff --git a/packages/frontend-2/lib/common/generated/gql/graphql.ts b/packages/frontend-2/lib/common/generated/gql/graphql.ts index 2678d75c0..3221a5d86 100644 --- a/packages/frontend-2/lib/common/generated/gql/graphql.ts +++ b/packages/frontend-2/lib/common/generated/gql/graphql.ts @@ -68,6 +68,7 @@ export type AdminQueries = { projectList: ProjectCollection; serverStatistics: ServerStatistics; userList: AdminUserList; + workspaceList: WorkspaceCollection; }; @@ -94,6 +95,13 @@ export type AdminQueriesUserListArgs = { role?: InputMaybe; }; + +export type AdminQueriesWorkspaceListArgs = { + cursor?: InputMaybe; + limit?: Scalars['Int']['input']; + query?: InputMaybe; +}; + export type AdminUserList = { __typename?: 'AdminUserList'; cursor?: Maybe; @@ -1305,6 +1313,7 @@ export type Mutation = { webhookDelete: Scalars['String']['output']; /** Updates an existing webhook */ webhookUpdate: Scalars['String']['output']; + workspaceMutations: WorkspaceMutations; }; @@ -1686,6 +1695,23 @@ export type PendingStreamCollaborator = { user?: Maybe; }; +export type PendingWorkspaceCollaborator = { + __typename?: 'PendingWorkspaceCollaborator'; + id: Scalars['ID']['output']; + inviteId: Scalars['String']['output']; + invitedBy: LimitedUser; + /** Target workspace role */ + role: Scalars['String']['output']; + /** E-mail address or name of the invited user */ + title: Scalars['String']['output']; + /** Only available if the active user is the pending workspace collaborator */ + token?: Maybe; + /** Set only if user is registered */ + user?: Maybe; + workspaceId: Scalars['String']['output']; + workspaceName: Scalars['String']['output']; +}; + export type Project = { __typename?: 'Project'; allowPublicComments: Scalars['Boolean']['output']; @@ -1737,6 +1763,7 @@ export type Project = { viewerResources: Array; visibility: ProjectVisibility; webhooks: WebhookCollection; + workspace?: Maybe; }; @@ -2383,6 +2410,14 @@ export type Query = { * The query looks for matches in name & email */ userSearch: UserSearchResultCollection; + workspace: Workspace; + /** + * Look for an invitation to a workspace, for the current user (authed or not). If token + * isn't specified, the server will look for any valid invite. + * + * If token is specified, it will return the corresponding invite even if it belongs to a different user. + */ + workspaceInvite?: Maybe; }; @@ -2508,6 +2543,17 @@ export type QueryUserSearchArgs = { query: Scalars['String']['input']; }; + +export type QueryWorkspaceArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryWorkspaceInviteArgs = { + token?: InputMaybe; + workspaceId: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -2610,6 +2656,7 @@ export type ServerInfo = { serverRoles: Array; termsOfService?: Maybe; version?: Maybe; + workspaces: ServerWorkspacesInfo; }; export type ServerInfoUpdateInput = { @@ -2678,6 +2725,15 @@ export type ServerStats = { userHistory?: Maybe>>; }; +export type ServerWorkspacesInfo = { + __typename?: 'ServerWorkspacesInfo'; + /** + * This is a backend control variable for the workspaces feature set. + * Since workspaces need a backend logic to be enabled, this is not enough as a feature flag. + */ + workspacesEnabled: Scalars['Boolean']['output']; +}; + export type SmartTextEditorValue = { __typename?: 'SmartTextEditorValue'; /** File attachments, if any */ @@ -3298,6 +3354,10 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get all invitations to workspaces that the active user has */ + workspaceInvites: Array; + /** Get the workspaces for the user */ + workspaces: WorkspaceCollection; }; @@ -3385,6 +3445,17 @@ export type UserVersionsArgs = { limit?: Scalars['Int']['input']; }; + +/** + * Full user type, should only be used in the context of admin operations or + * when a user is reading/writing info about himself + */ +export type UserWorkspacesArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + export type UserAutomateInfo = { __typename?: 'UserAutomateInfo'; availableGithubOrgs: Array; @@ -3435,6 +3506,10 @@ export type UserUpdateInput = { name?: InputMaybe; }; +export type UserWorkspacesFilter = { + search?: InputMaybe; +}; + export type Version = { __typename?: 'Version'; authorUser?: Maybe; @@ -3647,6 +3722,153 @@ export type WebhookUpdateInput = { url?: InputMaybe; }; +export type Workspace = { + __typename?: 'Workspace'; + createdAt: Scalars['DateTime']['output']; + description?: Maybe; + id: Scalars['ID']['output']; + /** Only available to workspace owners */ + invitedTeam?: Maybe>; + name: Scalars['String']['output']; + projects: ProjectCollection; + /** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */ + role?: Maybe; + team: Array; + updatedAt: Scalars['DateTime']['output']; +}; + + +export type WorkspaceProjectsArgs = { + cursor?: InputMaybe; + filter?: InputMaybe; + limit?: Scalars['Int']['input']; +}; + +export type WorkspaceCollaborator = { + __typename?: 'WorkspaceCollaborator'; + id: Scalars['ID']['output']; + role: Scalars['String']['output']; + user: LimitedUser; +}; + +export type WorkspaceCollection = { + __typename?: 'WorkspaceCollection'; + cursor?: Maybe; + items: Array; + totalCount: Scalars['Int']['output']; +}; + +export type WorkspaceCreateInput = { + description?: InputMaybe; + logoUrl?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type WorkspaceInviteCreateInput = { + /** Either this or userId must be filled */ + email?: InputMaybe; + /** Defaults to the member role, if not specified */ + role?: InputMaybe; + /** Either this or email must be filled */ + userId?: InputMaybe; +}; + +export type WorkspaceInviteMutations = { + __typename?: 'WorkspaceInviteMutations'; + batchCreate: Workspace; + cancel: Workspace; + create: Workspace; + use: Scalars['Boolean']['output']; +}; + + +export type WorkspaceInviteMutationsBatchCreateArgs = { + input: Array; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCancelArgs = { + inviteId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsCreateArgs = { + input: WorkspaceInviteCreateInput; + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceInviteMutationsUseArgs = { + input: WorkspaceInviteUseInput; +}; + +export type WorkspaceInviteUseInput = { + accept: Scalars['Boolean']['input']; + token: Scalars['String']['input']; +}; + +export type WorkspaceMutations = { + __typename?: 'WorkspaceMutations'; + create: Workspace; + delete: Workspace; + deleteRole: Scalars['Boolean']['output']; + invites: WorkspaceInviteMutations; + update: Workspace; + /** TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes */ + updateRole: Scalars['Boolean']['output']; +}; + + +export type WorkspaceMutationsCreateArgs = { + input: WorkspaceCreateInput; +}; + + +export type WorkspaceMutationsDeleteArgs = { + workspaceId: Scalars['String']['input']; +}; + + +export type WorkspaceMutationsDeleteRoleArgs = { + input: WorkspaceRoleDeleteInput; +}; + + +export type WorkspaceMutationsUpdateArgs = { + input: WorkspaceUpdateInput; +}; + + +export type WorkspaceMutationsUpdateRoleArgs = { + input: WorkspaceRoleUpdateInput; +}; + +export enum WorkspaceRole { + Admin = 'ADMIN', + Guest = 'GUEST', + Member = 'MEMBER' +} + +export type WorkspaceRoleDeleteInput = { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceRoleUpdateInput = { + role: WorkspaceRole; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}; + +export type WorkspaceUpdateInput = { + description?: InputMaybe; + id: Scalars['String']['input']; + logoUrl?: InputMaybe; + name?: InputMaybe; +}; + export type AuthRegisterPanelServerInfoFragment = { __typename?: 'ServerInfo', inviteOnly?: boolean | null }; export type RegisterPanelServerInviteQueryVariables = Exact<{ diff --git a/packages/server/.env-example b/packages/server/.env-example index af91bbb01..d0c801fdd 100644 --- a/packages/server/.env-example +++ b/packages/server/.env-example @@ -13,6 +13,10 @@ SESSION_SECRET="-> FILL IN <-" # Redis connection: default for local development environment REDIS_URL="redis://127.0.0.1:6379" +# Enable GraphQL API mocks for specific speckle modules by specifying them in a comma delimited list +# Example: MOCKED_API_MODULES=core,automate +MOCKED_API_MODULES= + ############################################################ # Frontend 2.0 settings # Settings for making the server work with Frontend 2.0 diff --git a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql index 33939886e..1b13f7cc4 100644 --- a/packages/server/assets/workspacesCore/typedefs/workspaces.graphql +++ b/packages/server/assets/workspacesCore/typedefs/workspaces.graphql @@ -2,6 +2,14 @@ extend type Query { workspace(id: String!): Workspace! @hasServerRole(role: SERVER_USER) @hasScope(scope: "workspace:read") + + """ + Look for an invitation to a workspace, for the current user (authed or not). If token + isn't specified, the server will look for any valid invite. + + If token is specified, it will return the corresponding invite even if it belongs to a different user. + """ + workspaceInvite(workspaceId: String!, token: String): PendingWorkspaceCollaborator } input WorkspaceCreateInput { @@ -36,14 +44,14 @@ type WorkspaceMutations { create(input: WorkspaceCreateInput!): Workspace! @hasServerRole(role: SERVER_ADMIN) @hasScope(scope: "workspace:create") - delete(workspaceId: String!): Workspace! @hasScope(scope: "workspace:delete") + delete(workspaceId: String!): Boolean! @hasScope(scope: "workspace:delete") update(input: WorkspaceUpdateInput!): Workspace! @hasScope(scope: "workspace:update") """ TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes """ - updateRole(input: WorkspaceRoleUpdateInput!): Boolean! + updateRole(input: WorkspaceRoleUpdateInput!): Workspace! @hasScope(scope: "workspace:update") - deleteRole(input: WorkspaceRoleDeleteInput!): Boolean! + deleteRole(input: WorkspaceRoleDeleteInput!): Workspace! @hasScope(scope: "workspace:update") invites: WorkspaceInviteMutations! } @@ -64,7 +72,6 @@ input WorkspaceInviteCreateInput { } input WorkspaceInviteUseInput { - workspaceId: String! token: String! accept: Boolean! } @@ -93,6 +100,9 @@ type Workspace { """ role: String team: [WorkspaceCollaborator!]! + """ + Only available to workspace owners + """ invitedTeam: [PendingWorkspaceCollaborator!] projects( limit: Int! = 25 @@ -116,6 +126,9 @@ type PendingWorkspaceCollaborator { E-mail address or name of the invited user """ title: String! + """ + Target workspace role + """ role: String! invitedBy: LimitedUser! """ @@ -143,6 +156,14 @@ extend type User { cursor: String = null filter: UserWorkspacesFilter ): WorkspaceCollection! @isOwner + + """ + Get all invitations to workspaces that the active user has + """ + workspaceInvites: [PendingWorkspaceCollaborator!]! + @hasServerRole(role: SERVER_GUEST) + @hasScope(scope: "workspace:read") + @isOwner } extend type Project { diff --git a/packages/server/modules/automate/graph/mocks/automate.ts b/packages/server/modules/automate/graph/mocks/automate.ts new file mode 100644 index 000000000..d0a7cfd23 --- /dev/null +++ b/packages/server/modules/automate/graph/mocks/automate.ts @@ -0,0 +1,381 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + AutomationNotFoundError, + FunctionNotFoundError +} from '@/modules/automate/errors/management' +import { functionTemplateRepos } from '@/modules/automate/helpers/executionEngine' +import { + AutomationRevisionTriggerDefinitionGraphQLReturn, + AutomationRunTriggerGraphQLReturn +} from '@/modules/automate/helpers/graphTypes' +import { VersionCreationTriggerType } from '@/modules/automate/helpers/types' +import { BranchCommits, Branches, Commits } from '@/modules/core/dbSchema' +import { AutomateRunStatus } from '@/modules/core/graph/generated/graphql' +import { SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { faker } from '@faker-js/faker' +import { Automate, isNullOrUndefined, SourceAppNames } from '@speckle/shared' +import dayjs from 'dayjs' +import { times } from 'lodash' + +const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags() + +const getRandomModelVersion = async (offset?: number) => { + const versionQ = Commits.knex() + .join(BranchCommits.name, BranchCommits.col.commitId, Commits.col.id) + .first() + if (offset) versionQ.offset(offset) + const version = await versionQ + + if (!version) { + throw new Error("Couldn't find even one commit in the DB, please create some") + } + + const model = await Branches.knex() + .join(BranchCommits.name, BranchCommits.col.branchId, Branches.col.id) + .where(BranchCommits.col.commitId, version.id) + .first() + + if (!model) { + throw new Error( + `Couldn't find branch for first commit #${version.id}, please create one ` + ) + } + + return { + model, + version + } +} + +const mocks: SpeckleModuleMocksConfig = FF_AUTOMATE_MODULE_ENABLED + ? { + resolvers: ({ store }) => ({ + AutomationRevisionTriggerDefinition: { + __resolveType: () => 'VersionCreatedTriggerDefinition' + }, + AutomationRunTrigger: { + __resolveType: () => 'VersionCreatedTrigger' + }, + VersionCreatedTriggerDefinition: { + model: store.get('Model') as any + }, + VersionCreatedTrigger: { + model: store.get('Model') as any, + version: store.get('Version') as any + }, + Query: { + automateFunctions: (_parent, args) => { + const forceZero = false + const count = forceZero ? 0 : faker.number.int({ min: 0, max: 20 }) + + const isFeatured = args.filter?.featuredFunctionsOnly + + return { + cursor: null, + totalCount: count, + items: times(count, () => store.get('AutomateFunction', { isFeatured })) + } as any + }, + automateFunction: (_parent, args) => { + const id = args.id + if (id === '404') { + throw new FunctionNotFoundError() + } + + return store.get('AutomateFunction', { id }) as any + } + }, + Project: { + automations: () => { + const forceAutomations = false + const forceNoAutomations = false + + const limit = faker.number.int({ min: 0, max: 20 }) + let count + if (forceNoAutomations) { + count = 0 + } else { + count = forceAutomations ? limit : faker.datatype.boolean() ? limit : 0 + } + + return { + cursor: null, + totalCount: count, + items: times(count, () => store.get('Automation')) + } as any + }, + automation: (_parent, args) => { + if (args.id === '404') { + throw new AutomationNotFoundError() + } + + return store.get('Automation', { id: args.id }) as any + }, + blob: () => { + return store.get('BlobMetadata') as any + } + }, + Model: { + automationsStatus: async () => { + const random = faker.datatype.boolean() + return (random ? store.get('TriggeredAutomationsStatus') : null) as any + } + }, + Version: { + automationsStatus: async () => { + const random = faker.datatype.boolean() + return (random ? store.get('TriggeredAutomationsStatus') : null) as any + } + }, + Automation: { + creationPublicKeys: () => { + // Random sized array of string keys + return [...new Array(faker.number.int({ min: 0, max: 5 }))].map(() => + faker.string.uuid() + ) + }, + runs: () => { + const forceZero = false + const count = forceZero ? 0 : faker.number.int({ min: 0, max: 20 }) + + return { + cursor: null, + totalCount: count, + items: times(count, () => store.get('AutomateRun')) + } as any + }, + currentRevision: () => store.get('AutomationRevision') as any + }, + AutomationRevision: { + triggerDefinitions: async (parent) => { + const rand = faker.number.int({ min: 0, max: 2 }) + const res = ( + await Promise.all([getRandomModelVersion(), getRandomModelVersion(1)]) + ).slice(0, rand) + + return res.map( + (i): AutomationRevisionTriggerDefinitionGraphQLReturn => ({ + triggerType: VersionCreationTriggerType, + triggeringId: i.model.id, + automationRevisionId: parent.id + }) + ) + }, + functions: () => [store.get('AutomateFunction') as any] + }, + AutomationRevisionFunction: { + parameters: () => ({}), + release: () => store.get('AutomateFunctionRelease') as any + }, + AutomateRun: { + trigger: async (parent) => { + const { version } = await getRandomModelVersion() + + return { + triggerType: VersionCreationTriggerType, + triggeringId: version.id, + automationRunId: parent.id + } + }, + automation: () => store.get('Automation') as any, + status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) + }, + AutomateFunctionRun: { + function: () => store.get('AutomateFunction') as any, + status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) + }, + ProjectAutomationMutations: { + create: (_parent, args) => { + const { + input: { name, enabled } + } = args + const automation = store.get('Automation') as any + return { + ...automation, + name, + enabled + } + }, + update: (_parent, args) => { + const { + input: { id, name, enabled } + } = args + const automation = store.get('Automation') as any + return { + ...automation, + id, + ...(name?.length ? { name } : {}), + ...(isNullOrUndefined(enabled) ? {} : { enabled }) + } + }, + trigger: () => faker.string.sample(10), + createRevision: () => store.get('AutomationRevision') as any + }, + UserAutomateInfo: { + hasAutomateGithubApp: () => { + return faker.datatype.boolean() + }, + availableGithubOrgs: () => { + // Random string array + return [...new Array(faker.number.int({ min: 0, max: 5 }))].map(() => + faker.company.name() + ) + } + }, + AutomateFunction: { + // creator: async (_parent, args, ctx) => { + // const rand = faker.datatype.boolean() + // const activeUser = ctx.userId + // ? await ctx.loaders.users.getUser.load(ctx.userId) + // : null + + // return rand ? (store.get('LimitedUser') as any) : activeUser + // } + releases: () => store.get('AutomateFunctionReleaseCollection') as any, + automationCount: () => faker.number.int({ min: 0, max: 99 }) + }, + AutomateFunctionRelease: { + function: () => store.get('AutomateFunction') as any + }, + TriggeredAutomationsStatus: { + status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) + }, + AutomateMutations: { + createFunction: () => store.get('AutomateFunction') as any, + updateFunction: (_parent, args) => { + const { + input: { id, name, description, supportedSourceApps, tags } + } = args + const func = store.get('AutomateFunction', { id }) as any + return { + ...func, + id, + ...(name?.length ? { name } : {}), + ...(description?.length ? { description } : {}), + ...(supportedSourceApps?.length ? { supportedSourceApps } : {}), + ...(tags?.length ? { tags } : {}) + } + } + } + }), + mocks: { + TriggeredAutomationsStatus: () => ({ + automationRuns: () => [ + ...new Array(faker.datatype.number({ min: 1, max: 5 })) + ] + }), + AutomationRevision: () => ({ + functions: () => [undefined] // array of 1 always, + }), + Automation: () => ({ + name: () => faker.company.name(), + enabled: () => faker.datatype.boolean() + }), + AutomateFunction: () => ({ + name: () => faker.commerce.productName(), + isFeatured: () => faker.datatype.boolean(), + logo: () => { + const random = faker.datatype.boolean() + return random + ? faker.image.imageUrl(undefined, undefined, undefined, true) + : null + }, + repoUrl: () => + 'https://github.com/specklesystems/speckle-automate-code-compliance-window-safety', + automationCount: () => faker.number.int({ min: 0, max: 99 }), + description: () => { + // Example markdown description + return `# ${faker.commerce.productName()}\n${faker.lorem.paragraphs( + 1, + '\n\n' + )}\n## Features \n- ${faker.lorem.sentence()}\n - ${faker.lorem.sentence()}\n - ${faker.lorem.sentence()}` + }, + supportedSourceApps: () => { + const base = SourceAppNames + + // Random assortment from base + return base.filter(() => faker.datatype.boolean()) + }, + tags: () => { + // Random string array + return [...new Array(faker.number.int({ min: 0, max: 5 }))].map(() => + faker.lorem.word() + ) + } + }), + AutomateFunctionRelease: () => ({ + versionTag: () => { + // Fake semantic version + return `${faker.number.int({ + min: 0, + max: 9 + })}.${faker.number.int({ + min: 0, + max: 9 + })}.${faker.number.int({ min: 0, max: 9 })}` + }, + commitId: () => '0c259d384a4df3cce3f24667560e5124e68f202f', + inputSchema: () => { + // random fro 1 to 3 + const rand = faker.number.int({ min: 1, max: 3 }) + switch (rand) { + case 1: + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://example.com/product.schema.json', + title: 'Product', + description: "A product from Acme's catalog", + type: 'object', + properties: { + name: { + desciption: 'Random name', + type: 'string' + }, + productId: { + description: 'The unique identifier for a product', + type: 'integer' + } + }, + required: ['productId'] + } + default: + return null + } + } + }), + AutomateRun: () => ({ + reason: () => faker.lorem.sentence(), + id: () => faker.string.alphanumeric(20), + createdAt: () => + faker.date + .recent(undefined, dayjs().subtract(1, 'day').toDate()) + .toISOString(), + updatedAt: () => faker.date.recent().toISOString(), + functionRuns: () => [...new Array(faker.number.int({ min: 1, max: 5 }))], + statusMessage: () => faker.lorem.sentence() + }), + AutomateFunctionRun: () => ({ + contextView: () => `/`, + elapsed: () => faker.number.int({ min: 0, max: 600 }), + statusMessage: () => faker.lorem.sentence(), + results: (): Automate.AutomateTypes.ResultsSchema => { + return { + version: Automate.AutomateTypes.RESULTS_SCHEMA_VERSION, + values: { + objectResults: [], + blobIds: [...new Array(faker.number.int({ min: 0, max: 5 }))].map(() => + faker.string.uuid() + ) + } + } + } + }), + ServerAutomateInfo: () => ({ + availableFunctionTemplates: () => functionTemplateRepos + }) + } + } + : {} +export default mocks diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index 56657e9b5..c741ab7b9 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -1714,6 +1714,7 @@ export type PendingWorkspaceCollaborator = { id: Scalars['ID']['output']; inviteId: Scalars['String']['output']; invitedBy: LimitedUser; + /** Target workspace role */ role: Scalars['String']['output']; /** E-mail address or name of the invited user */ title: Scalars['String']['output']; @@ -2424,6 +2425,13 @@ export type Query = { */ userSearch: UserSearchResultCollection; workspace: Workspace; + /** + * Look for an invitation to a workspace, for the current user (authed or not). If token + * isn't specified, the server will look for any valid invite. + * + * If token is specified, it will return the corresponding invite even if it belongs to a different user. + */ + workspaceInvite?: Maybe; }; @@ -2554,6 +2562,12 @@ export type QueryWorkspaceArgs = { id: Scalars['String']['input']; }; + +export type QueryWorkspaceInviteArgs = { + token?: InputMaybe; + workspaceId: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -3354,6 +3368,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get all invitations to workspaces that the active user has */ + workspaceInvites: Array; /** Get the workspaces for the user */ workspaces: WorkspaceCollection; }; @@ -3725,6 +3741,7 @@ export type Workspace = { createdAt: Scalars['DateTime']['output']; description?: Maybe; id: Scalars['ID']['output']; + /** Only available to workspace owners */ invitedTeam?: Maybe>; name: Scalars['String']['output']; projects: ProjectCollection; @@ -3804,18 +3821,17 @@ export type WorkspaceInviteMutationsUseArgs = { export type WorkspaceInviteUseInput = { accept: Scalars['Boolean']['input']; token: Scalars['String']['input']; - workspaceId: Scalars['String']['input']; }; export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; create: Workspace; - delete: Workspace; - deleteRole: Scalars['Boolean']['output']; + delete: Scalars['Boolean']['output']; + deleteRole: Workspace; invites: WorkspaceInviteMutations; update: Workspace; /** TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes */ - updateRole: Scalars['Boolean']['output']; + updateRole: Workspace; }; @@ -4130,7 +4146,7 @@ export type ResolversTypes = { UpdateAutomateFunctionInput: UpdateAutomateFunctionInput; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; - User: ResolverTypeWrapper & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaces: ResolversTypes['WorkspaceCollection'] }>; + User: ResolverTypeWrapper & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaceInvites: Array, workspaces: ResolversTypes['WorkspaceCollection'] }>; UserAutomateInfo: ResolverTypeWrapper; UserDeleteInput: UserDeleteInput; UserProjectsFilter: UserProjectsFilter; @@ -4347,7 +4363,7 @@ export type ResolversParentTypes = { UpdateAutomateFunctionInput: UpdateAutomateFunctionInput; UpdateModelInput: UpdateModelInput; UpdateVersionInput: UpdateVersionInput; - User: Omit & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaces: ResolversParentTypes['WorkspaceCollection'] }; + User: Omit & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe, projectInvites: Array, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaceInvites: Array, workspaces: ResolversParentTypes['WorkspaceCollection'] }; UserAutomateInfo: UserAutomateInfoGraphQLReturn; UserDeleteInput: UserDeleteInput; UserProjectsFilter: UserProjectsFilter; @@ -5263,6 +5279,7 @@ export type QueryResolvers>; userSearch?: Resolver>; workspace?: Resolver>; + workspaceInvite?: Resolver, ParentType, ContextType, RequireFields>; }; export type ResourceIdentifierResolvers = { @@ -5541,6 +5558,7 @@ export type UserResolvers; verified?: Resolver, ParentType, ContextType>; versions?: Resolver>; + workspaceInvites?: Resolver, ParentType, ContextType>; workspaces?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -5708,11 +5726,11 @@ export type WorkspaceInviteMutationsResolvers = { create?: Resolver>; - delete?: Resolver>; - deleteRole?: Resolver>; + delete?: Resolver>; + deleteRole?: Resolver>; invites?: Resolver; update?: Resolver>; - updateRole?: Resolver>; + updateRole?: Resolver>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/server/modules/core/graph/mocks/core.ts b/packages/server/modules/core/graph/mocks/core.ts new file mode 100644 index 000000000..8f817f84f --- /dev/null +++ b/packages/server/modules/core/graph/mocks/core.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks' +import { faker } from '@faker-js/faker' + +// TODO: Some of these might make better sense in the base config, adjust as needed +const mocks: SpeckleModuleMocksConfig = { + resolvers: ({ store }) => ({ + Project: { + blob: () => { + return store.get('BlobMetadata') as any + } + } + }), + mocks: { + BlobMetadata: () => ({ + fileName: () => faker.system.fileName(), + fileType: () => faker.system.mimeType(), + fileSize: () => faker.number.int({ min: 1, max: 1000 }) + }), + Model: () => ({ + id: () => faker.string.uuid(), + name: () => faker.commerce.productName(), + previewUrl: () => faker.image.imageUrl() + }), + Version: () => ({ + id: () => faker.string.alphanumeric(10) + }) + } +} +export default mocks diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index d19ca8df6..c527b2ce4 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -1703,6 +1703,7 @@ export type PendingWorkspaceCollaborator = { id: Scalars['ID']['output']; inviteId: Scalars['String']['output']; invitedBy: LimitedUser; + /** Target workspace role */ role: Scalars['String']['output']; /** E-mail address or name of the invited user */ title: Scalars['String']['output']; @@ -2413,6 +2414,13 @@ export type Query = { */ userSearch: UserSearchResultCollection; workspace: Workspace; + /** + * Look for an invitation to a workspace, for the current user (authed or not). If token + * isn't specified, the server will look for any valid invite. + * + * If token is specified, it will return the corresponding invite even if it belongs to a different user. + */ + workspaceInvite?: Maybe; }; @@ -2543,6 +2551,12 @@ export type QueryWorkspaceArgs = { id: Scalars['String']['input']; }; + +export type QueryWorkspaceInviteArgs = { + token?: InputMaybe; + workspaceId: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -3343,6 +3357,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get all invitations to workspaces that the active user has */ + workspaceInvites: Array; /** Get the workspaces for the user */ workspaces: WorkspaceCollection; }; @@ -3714,6 +3730,7 @@ export type Workspace = { createdAt: Scalars['DateTime']['output']; description?: Maybe; id: Scalars['ID']['output']; + /** Only available to workspace owners */ invitedTeam?: Maybe>; name: Scalars['String']['output']; projects: ProjectCollection; @@ -3793,18 +3810,17 @@ export type WorkspaceInviteMutationsUseArgs = { export type WorkspaceInviteUseInput = { accept: Scalars['Boolean']['input']; token: Scalars['String']['input']; - workspaceId: Scalars['String']['input']; }; export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; create: Workspace; - delete: Workspace; - deleteRole: Scalars['Boolean']['output']; + delete: Scalars['Boolean']['output']; + deleteRole: Workspace; invites: WorkspaceInviteMutations; update: Workspace; /** TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes */ - updateRole: Scalars['Boolean']['output']; + updateRole: Workspace; }; diff --git a/packages/server/modules/index.js b/packages/server/modules/index.js index 0630465ad..074f36bb6 100644 --- a/packages/server/modules/index.js +++ b/packages/server/modules/index.js @@ -1,14 +1,14 @@ -'use strict' const fs = require('fs') const path = require('path') const { appRoot, packageRoot } = require('@/bootstrap') -const { values, merge, camelCase } = require('lodash') +const { values, merge, camelCase, intersection } = require('lodash') const baseTypeDefs = require('@/modules/core/graph/schema/baseTypeDefs') const { scalarResolvers } = require('./core/graph/scalars') const { makeExecutableSchema } = require('@graphql-tools/schema') const { moduleLogger } = require('@/logging/logging') const { addMocksToSchema } = require('@graphql-tools/mock') const { getFeatureFlags } = require('@/modules/shared/helpers/envHelper') +const { isNonNullable } = require('@speckle/shared') /** * Cached speckle module requires @@ -208,3 +208,38 @@ exports.graphSchema = (mocksConfig) => { return schema } + +/** + * Load GQL mock configs from speckle modules + * @param {string[]} moduleWhitelist + * @returns {Record} + */ +exports.moduleMockConfigs = (moduleWhitelist) => { + const enabledModuleNames = intersection(getEnabledModuleNames(), moduleWhitelist) + + // Config default exports keyed by module name + const mockConfigs = {} + if (!enabledModuleNames.length) return mockConfigs + + // load code modules from /modules + const codeModuleDirs = fs.readdirSync(`${appRoot}/modules`) + codeModuleDirs.forEach((moduleName) => { + const fullPath = path.join(`${appRoot}/modules`, moduleName) + if (!enabledModuleNames.includes(moduleName)) return + + // load mock config + const mocksFolderPath = path.join(fullPath, 'graph', 'mocks') + if (fs.existsSync(mocksFolderPath)) { + // We only take the first mocks.ts file we find (for now) + const mainConfig = values(autoloadFromDirectory(mocksFolderPath)) + .map((l) => l.default) + .filter(isNonNullable)[0] + + if (mainConfig && Object.values(mainConfig).length) { + mockConfigs[moduleName] = mainConfig + } + } + }) + + return mockConfigs +} diff --git a/packages/server/modules/mocks.ts b/packages/server/modules/mocks.ts index 07dde277b..b2c5a8163 100644 --- a/packages/server/modules/mocks.ts +++ b/packages/server/modules/mocks.ts @@ -1,52 +1,77 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ProjectVisibility, Resolvers } from '@/modules/core/graph/generated/graphql' import { - AutomateRunStatus, - LimitedUser, - Resolvers -} from '@/modules/core/graph/generated/graphql' -import { isTestEnv } from '@/modules/shared/helpers/envHelper' -import { Automate, Roles, SourceAppNames, isNullOrUndefined } from '@speckle/shared' -import { times } from 'lodash' + mockedApiModules, + isProdEnv, + isTestEnv +} from '@/modules/shared/helpers/envHelper' +import { has, reduce } from 'lodash' import { IMockStore, IMocks } from '@graphql-tools/mock' -import dayjs from 'dayjs' -import { BranchCommits, Branches, Commits } from '@/modules/core/dbSchema' + +import { moduleMockConfigs } from '@/modules' +import { isNonNullable, Roles, SourceAppNames } from '@speckle/shared' import { - AutomationNotFoundError, - FunctionNotFoundError -} from '@/modules/automate/errors/management' -import { functionTemplateRepos } from '@/modules/automate/helpers/executionEngine' -import { - AutomationRevisionTriggerDefinitionGraphQLReturn, - AutomationRunTriggerGraphQLReturn -} from '@/modules/automate/helpers/graphTypes' -import { VersionCreationTriggerType } from '@/modules/automate/helpers/types' + getRandomDbRecords, + mockStoreHelpers, + SpeckleModuleMocksConfig +} from '@/modules/shared/helpers/mocks' +import { Streams } from '@/modules/core/dbSchema' -const getRandomModelVersion = async (offset?: number) => { - const versionQ = Commits.knex() - .join(BranchCommits.name, BranchCommits.col.commitId, Commits.col.id) - .first() - if (offset) versionQ.offset(offset) - const version = await versionQ - - if (!version) { - throw new Error("Couldn't find even one commit in the DB, please create some") - } - - const model = await Branches.knex() - .join(BranchCommits.name, BranchCommits.col.branchId, Branches.col.id) - .where(BranchCommits.col.commitId, version.id) - .first() - - if (!model) { - throw new Error( - `Couldn't find branch for first commit #${version.id}, please create one ` - ) - } +/** + * Base config that always needs to be loaded, cause it sets up core primitives + */ +const buildBaseConfig = async (): Promise => { + // Async import so that we only import this when envs actually have mocks on + const faker = (await import('@faker-js/faker')).faker return { - model, - version + resolvers: ({ helpers: { getFieldValue } }) => ({ + LimitedUser: { + role: (parent) => + getFieldValue( + { type: 'LimitedUser', id: getFieldValue(parent, 'id') }, + 'role' + ) + }, + ProjectCollection: { + items: async (parent) => { + // In case a real project collection was built, we skip mocking it + if (has(parent, 'items')) return parent.items + + const count = getFieldValue(parent, 'totalCount') + + // To avoid having to mock projects fully, we pull real ones from the DB + return await getRandomDbRecords({ tableName: Streams.name, min: count }) + } + } + }), + mocks: { + // Primitives + JSONObject: () => ({}), + ID: () => faker.string.uuid(), + DateTime: () => faker.date.recent().toISOString(), + Boolean: () => faker.datatype.boolean(), + // Base objects + LimitedUser: () => ({ + id: faker.string.uuid(), + name: faker.person.fullName(), + avatar: faker.image.avatar(), + bio: faker.lorem.sentence(), + company: faker.company.name(), + verified: faker.datatype.boolean(), + role: Roles.Server.User + }), + Project: () => ({ + id: faker.string.uuid(), + name: faker.commerce.productName(), + description: faker.lorem.sentence(), + visibility: faker.helpers.arrayElement(Object.values(ProjectVisibility)), + role: faker.helpers.arrayElement(Object.values(Roles.Stream)), + sourceApps: faker.helpers.arrayElements(SourceAppNames, { min: 0, max: 5 }) + }), + ProjectCollection: () => ({ + totalCount: faker.number.int({ min: 0, max: 10 }) + }) + } } } @@ -59,364 +84,54 @@ export async function buildMocksConfig(): Promise<{ mockEntireSchema: boolean resolvers?: Resolvers | ((store: IMockStore) => Resolvers) }> { - return { mocks: false, mockEntireSchema: false } + const mockableModuleList = mockedApiModules() + const enable = mockableModuleList.length && !isTestEnv() && !isProdEnv() + if (!enable) { + return { mocks: false, mockEntireSchema: false } + } - // TODO: Disable before merging! - if (isTestEnv()) return { mocks: false, mockEntireSchema: false } + const configs = moduleMockConfigs(mockableModuleList) + if (!Object.keys(configs).length) { + return { mocks: false, mockEntireSchema: false } + } - // const isDebugEnv = isDevEnv() - // if (!isDebugEnv) return { mocks: false, mockEntireSchema: false } // we def don't want this on in prod + const allConfigs = { base: await buildBaseConfig(), ...configs } - // feel free to define mocks for your dev env below - const { faker } = await import('@faker-js/faker') + // Merge configs into one + const mocks: IMocks = reduce( + allConfigs, + (acc, config) => { + return { ...acc, ...(config.mocks || {}) } + }, + {} as IMocks + ) + const resolvers: (store: IMockStore) => Resolvers = (store) => { + const allResolversBuilders = Object.values(allConfigs) + .map((c) => c.resolvers) + .filter(isNonNullable) + const allResolvers = allResolversBuilders.map((builder) => + builder({ store, helpers: mockStoreHelpers(store) }) + ) + + // Deep merge all resolvers + const resolvers = allResolvers.reduce((acc, resolvers) => { + for (const [typeName, typeResolvers] of Object.entries(resolvers)) { + if (!acc[typeName]) { + acc[typeName] = {} + } + + Object.assign(acc[typeName], typeResolvers) + } + return acc + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }, {} as Record) + + return resolvers as Resolvers + } return { - resolvers: (store) => ({ - AutomationRevisionTriggerDefinition: { - __resolveType: () => 'VersionCreatedTriggerDefinition' - }, - AutomationRunTrigger: { - __resolveType: () => 'VersionCreatedTrigger' - }, - VersionCreatedTriggerDefinition: { - model: store.get('Model') as any - }, - VersionCreatedTrigger: { - model: store.get('Model') as any, - version: store.get('Version') as any - }, - Query: { - automateFunctions: (_parent, args) => { - const forceZero = false - const count = forceZero ? 0 : faker.datatype.number({ min: 0, max: 20 }) - - const isFeatured = args.filter?.featuredFunctionsOnly - - return { - cursor: null, - totalCount: count, - items: times(count, () => store.get('AutomateFunction', { isFeatured })) - } as any - }, - automateFunction: (_parent, args) => { - const id = args.id - if (id === '404') { - throw new FunctionNotFoundError() - } - - return store.get('AutomateFunction', { id }) as any - } - }, - Project: { - automations: () => { - const forceAutomations = false - const forceNoAutomations = false - - const limit = faker.datatype.number({ min: 0, max: 20 }) - let count - if (forceNoAutomations) { - count = 0 - } else { - count = forceAutomations ? limit : faker.datatype.boolean() ? limit : 0 - } - - return { - cursor: null, - totalCount: count, - items: times(count, () => store.get('Automation')) - } as any - }, - automation: (_parent, args) => { - if (args.id === '404') { - throw new AutomationNotFoundError() - } - - return store.get('Automation', { id: args.id }) as any - }, - blob: () => { - return store.get('BlobMetadata') as any - } - }, - Model: { - automationsStatus: async () => { - const random = faker.datatype.boolean() - return (random ? store.get('TriggeredAutomationsStatus') : null) as any - } - }, - Version: { - automationsStatus: async () => { - const random = faker.datatype.boolean() - return (random ? store.get('TriggeredAutomationsStatus') : null) as any - } - }, - Automation: { - creationPublicKeys: () => { - // Random sized array of string keys - return [...new Array(faker.datatype.number({ min: 0, max: 5 }))].map(() => - faker.datatype.uuid() - ) - }, - runs: () => { - const forceZero = false - const count = forceZero ? 0 : faker.datatype.number({ min: 0, max: 20 }) - - return { - cursor: null, - totalCount: count, - items: times(count, () => store.get('AutomateRun')) - } as any - }, - currentRevision: () => store.get('AutomationRevision') as any - }, - AutomationRevision: { - triggerDefinitions: async (parent) => { - const rand = faker.datatype.number({ min: 0, max: 2 }) - const res = ( - await Promise.all([getRandomModelVersion(), getRandomModelVersion(1)]) - ).slice(0, rand) - - return res.map( - (i): AutomationRevisionTriggerDefinitionGraphQLReturn => ({ - triggerType: VersionCreationTriggerType, - triggeringId: i.model.id, - automationRevisionId: parent.id - }) - ) - }, - functions: () => [store.get('AutomateFunction') as any] - }, - AutomationRevisionFunction: { - parameters: () => ({}), - release: () => store.get('AutomateFunctionRelease') as any - }, - AutomateRun: { - trigger: async (parent) => { - const { version } = await getRandomModelVersion() - - return { - triggerType: VersionCreationTriggerType, - triggeringId: version.id, - automationRunId: parent.id - } - }, - automation: () => store.get('Automation') as any, - status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) - }, - AutomateFunctionRun: { - function: () => store.get('AutomateFunction') as any, - status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) - }, - ProjectAutomationMutations: { - create: (_parent, args) => { - const { - input: { name, enabled } - } = args - const automation = store.get('Automation') as any - return { - ...automation, - name, - enabled - } - }, - update: (_parent, args) => { - const { - input: { id, name, enabled } - } = args - const automation = store.get('Automation') as any - return { - ...automation, - id, - ...(name?.length ? { name } : {}), - ...(isNullOrUndefined(enabled) ? {} : { enabled }) - } - }, - trigger: () => faker.datatype.string(10), - createRevision: () => store.get('AutomationRevision') as any - }, - UserAutomateInfo: { - hasAutomateGithubApp: () => { - return faker.datatype.boolean() - }, - availableGithubOrgs: () => { - // Random string array - return [...new Array(faker.datatype.number({ min: 0, max: 5 }))].map(() => - faker.company.companyName() - ) - } - }, - AutomateFunction: { - // creator: async (_parent, args, ctx) => { - // const rand = faker.datatype.boolean() - // const activeUser = ctx.userId - // ? await ctx.loaders.users.getUser.load(ctx.userId) - // : null - - // return rand ? (store.get('LimitedUser') as any) : activeUser - // } - releases: () => store.get('AutomateFunctionReleaseCollection') as any, - automationCount: () => faker.datatype.number({ min: 0, max: 99 }) - }, - AutomateFunctionRelease: { - function: () => store.get('AutomateFunction') as any - }, - TriggeredAutomationsStatus: { - status: () => faker.helpers.arrayElement(Object.values(AutomateRunStatus)) - }, - AutomateMutations: { - createFunction: () => store.get('AutomateFunction') as any, - updateFunction: (_parent, args) => { - const { - input: { id, name, description, supportedSourceApps, tags } - } = args - const func = store.get('AutomateFunction', { id }) as any - return { - ...func, - id, - ...(name?.length ? { name } : {}), - ...(description?.length ? { description } : {}), - ...(supportedSourceApps?.length ? { supportedSourceApps } : {}), - ...(tags?.length ? { tags } : {}) - } - } - } - }), - mocks: { - BlobMetadata: () => ({ - fileName: () => faker.system.fileName(), - fileType: () => faker.system.mimeType(), - fileSize: () => faker.datatype.number({ min: 1, max: 1000 }) - }), - TriggeredAutomationsStatus: () => ({ - automationRuns: () => [...new Array(faker.datatype.number({ min: 1, max: 5 }))] - }), - AutomationRevision: () => ({ - functions: () => [undefined] // array of 1 always, - }), - Automation: () => ({ - name: () => faker.company.companyName(), - enabled: () => faker.datatype.boolean() - }), - AutomateFunction: () => ({ - name: () => faker.commerce.productName(), - isFeatured: () => faker.datatype.boolean(), - logo: () => { - const random = faker.datatype.boolean() - return random - ? faker.image.imageUrl(undefined, undefined, undefined, true) - : null - }, - repoUrl: () => - 'https://github.com/specklesystems/speckle-automate-code-compliance-window-safety', - automationCount: () => faker.datatype.number({ min: 0, max: 99 }), - description: () => { - // Example markdown description - return `# ${faker.commerce.productName()}\n${faker.lorem.paragraphs( - 1, - '\n\n' - )}\n## Features \n- ${faker.lorem.sentence()}\n - ${faker.lorem.sentence()}\n - ${faker.lorem.sentence()}` - }, - supportedSourceApps: () => { - const base = SourceAppNames - - // Random assortment from base - return base.filter(faker.datatype.boolean) - }, - tags: () => { - // Random string array - return [...new Array(faker.datatype.number({ min: 0, max: 5 }))].map(() => - faker.lorem.word() - ) - } - }), - AutomateFunctionRelease: () => ({ - versionTag: () => { - // Fake semantic version - return `${faker.datatype.number({ min: 0, max: 9 })}.${faker.datatype.number({ - min: 0, - max: 9 - })}.${faker.datatype.number({ min: 0, max: 9 })}` - }, - commitId: () => '0c259d384a4df3cce3f24667560e5124e68f202f', - inputSchema: () => { - // random fro 1 to 3 - const rand = faker.datatype.number({ min: 1, max: 3 }) - switch (rand) { - case 1: - return { - $schema: 'https://json-schema.org/draft/2020-12/schema', - $id: 'https://example.com/product.schema.json', - title: 'Product', - description: "A product from Acme's catalog", - type: 'object', - properties: { - name: { - desciption: 'Random name', - type: 'string' - }, - productId: { - description: 'The unique identifier for a product', - type: 'integer' - } - }, - required: ['productId'] - } - default: - return null - } - } - }), - AutomateRun: () => ({ - reason: () => faker.lorem.sentence(), - id: () => faker.random.alphaNumeric(20), - createdAt: () => - faker.date - .recent(undefined, dayjs().subtract(1, 'day').toDate()) - .toISOString(), - updatedAt: () => faker.date.recent().toISOString(), - functionRuns: () => [...new Array(faker.datatype.number({ min: 1, max: 5 }))], - statusMessage: () => faker.lorem.sentence() - }), - AutomateFunctionRun: () => ({ - contextView: () => `/`, - elapsed: () => faker.datatype.number({ min: 0, max: 600 }), - statusMessage: () => faker.lorem.sentence(), - results: (): Automate.AutomateTypes.ResultsSchema => { - return { - version: Automate.AutomateTypes.RESULTS_SCHEMA_VERSION, - values: { - objectResults: [], - blobIds: [...new Array(faker.datatype.number({ min: 0, max: 5 }))].map( - () => faker.datatype.uuid() - ) - } - } - } - }), - LimitedUser: () => - ({ - id: faker.datatype.uuid(), - name: faker.name.findName(), - avatar: faker.image.avatar(), - bio: faker.lorem.sentence(), - company: faker.company.companyName(), - verified: faker.datatype.boolean(), - role: Roles.Server.User - } as LimitedUser), - JSONObject: () => ({}), - ID: () => faker.datatype.uuid(), - DateTime: () => faker.date.recent().toISOString(), - Model: () => ({ - id: () => faker.datatype.uuid(), - name: () => faker.commerce.productName(), - previewUrl: () => faker.image.imageUrl() - }), - Version: () => ({ - id: () => faker.random.alphaNumeric(10) - }), - ServerAutomateInfo: () => ({ - availableFunctionTemplates: () => functionTemplateRepos - }) - }, + mocks, + resolvers, mockEntireSchema: false } } diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 7a3bd32bc..fed1401a5 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -231,6 +231,14 @@ export function ignoreMissingMigrations() { return getBooleanFromEnv('IGNORE_MISSING_MIRATIONS') } +/** + * Whether to enable GQL API mocks + */ +export const mockedApiModules = () => { + const base = process.env.MOCKED_API_MODULES + return (base || '').split(',').map((x) => x.trim()) +} + /** * URL of a project on any FE2 speckle server that will be pulled in and used as the onboarding stream */ diff --git a/packages/server/modules/shared/helpers/mocks.ts b/packages/server/modules/shared/helpers/mocks.ts new file mode 100644 index 000000000..aebf4f0ed --- /dev/null +++ b/packages/server/modules/shared/helpers/mocks.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { db } from '@/db/knex' +import { ResolverFn, Resolvers } from '@/modules/core/graph/generated/graphql' +import { IMockStore, IMocks, isRef, Ref } from '@graphql-tools/mock' +import { GraphQLResolveInfo } from 'graphql' +import { get, has, isArray, isObjectLike, random } from 'lodash' + +export type SpeckleModuleMocksConfig = { + resolvers?: (params: { + store: IMockStore + helpers: ReturnType + }) => Resolvers + mocks?: IMocks +} + +type SimpleRef = { type: string; id: string } +const isSimpleRef = (obj: any): obj is SimpleRef => + isObjectLike(obj) && Object.keys(obj).length === 2 && 'type' in obj && 'id' in obj + +export const mockStoreHelpers = (store: IMockStore) => { + /** + * We have to use an internal api, but there is no other way to check + * for the existence of a field in the mock store. + */ + const hasField = (type: string, key: string, field: string) => { + const internalStore = get(store, 'store') as { + [type: string]: { + [key: string]: { + [field: string]: unknown + } + } + } + + return has(internalStore, [type, key, field]) + } + + const addMockRefValues = (ref: Ref, values: Record) => { + store.set(ref, values) + return ref + } + + const getMockRef = ( + type: string, + options?: { + /** + * The id of the object to get. If object w/ this ID already exists in the mock store, + * it will be retrieved from there. Otherwise, a new object will be created. + */ + id?: string + /** + * Additional field values that should be set on the object + */ + values?: Record + } + ) => { + const { id, values } = options || {} + const ret = values + ? store.get(type, { + ...values, + ...(id ? { id } : {}) + }) + : store.get(type, id) + return ret as T + } + + const getFieldValue = ( + refOrObj: Record | Ref | SimpleRef, + field: string + ) => { + if (isRef(refOrObj)) return store.get(refOrObj, field) as T + if (isSimpleRef(refOrObj)) return store.get(refOrObj.type, refOrObj.id, field) as T + return refOrObj[field] as T + } + + type AnyResolverFn = ResolverFn + + const resolveFromMockParent = ( + options?: Partial<{ + /** + * Allows you to map any refs found (whether they're in arrays or not) to something else, + * e.g. the same mock, but with different arg values + */ + mapRefs: ( + mockRef: Ref, + resolverArgs: { parent: any; args: any; ctx: any; info: GraphQLResolveInfo } + ) => any + }> + ) => { + const { mapRefs } = options || {} + + const resolver: AnyResolverFn = (parent, args, ctx, info) => { + const resolverArgs = { parent, args, ctx, info } + const val = getFieldValue(parent, info.fieldName) + if (!mapRefs) return val + + if (isArray(val)) { + return val.map((v) => (isRef(v) ? mapRefs(v, resolverArgs) : v)) + } else { + return isRef(val) ? mapRefs(val, resolverArgs) : val + } + } + + return resolver + } + + const resolveAndCache = (resolver: AnyResolverFn) => { + const wrapperResolver: AnyResolverFn = (parent, args, ctx, info) => { + let cached: any + + if (!isRef(parent) && !has(parent, 'id')) { + throw new Error( + 'resolveAndCache depends on resolver parent being a mock ref or an object with an ID field' + ) + } + + if (isRef(parent)) { + if (hasField(parent.$ref.typeName, parent.$ref.key, info.fieldName)) { + cached = store.get(parent, info.fieldName) + } + } else { + if (hasField(info.parentType.name, parent.id, info.fieldName)) { + cached = store.get(info.parentType.name, parent.id, info.fieldName) + } + } + + if (cached) return cached + + const val = resolver(parent, args, ctx, info) + if (isRef(parent)) { + store.set(parent, info.fieldName, val) + } else { + store.set(info.parentType.name, parent.id, info.fieldName, val) + } + + return val + } + + return wrapperResolver + } + + return { + /** + * Get mock reference. It can be returned from resolvers and converted to the actual mock + * when outputted to response. + */ + getMockRef, + /** + * Get value from a mock reference or plain object. + * + * Useful when you need to access something from parent, where you don't know if it's + * gonna be a mock reference or the actual object. + * Also useful for just getting arbitrary field values from mock refs. + */ + getFieldValue, + /** + * Invoke this in place of a resolver definition to just tell Apollo to take the value from + * the mock in `parent`. + * + * This is useful when there's a real resolver that blocks access to the mock, so you + * need to create a mock resolver that just returns the value from the parent. + */ + resolveFromMockParent, + /** + * Update specific values in mock + */ + addMockRefValues, + /** + * Wraps your resolver with a caching mechanism that caches the value in the MockStore + * for this specific parent object + * + * Useful when parent object is not a MockRef, but you want to mock out and cache some of its values. + * Or it may be a MockRef, but for some reason you can't just define the field in the mock definition + * and need a resolver + */ + resolveAndCache + } +} + +export const getRandomDbRecords = async (params: { + tableName: string + min: number + max?: number +}) => { + const { tableName, min, max } = params + if (max && max < min) { + throw new Error('Max cannot be less than min') + } + + const finalCount = max ? random(min, max) : min + + // Query could be slow on large datasets, but for test/dev envs it should be fine + const res = await db(tableName) + .select('*') + .orderByRaw('RANDOM()') + .limit(finalCount) + return res as T[] +} + +/** + * For defining lists of size X in mock fields. If 2nd arg is specified, the size will be random + * between the two numbers. + */ +export const listMock = (min: number, max?: number) => + [...new Array(max ? random(min, max) : min)] as unknown[] diff --git a/packages/server/modules/workspaces/graph/mocks/workspaces.ts b/packages/server/modules/workspaces/graph/mocks/workspaces.ts new file mode 100644 index 000000000..4ce87fd6b --- /dev/null +++ b/packages/server/modules/workspaces/graph/mocks/workspaces.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { listMock, SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' +import { faker } from '@faker-js/faker' +import { Roles } from '@speckle/shared' +import { omit, times } from 'lodash' +import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace' + +const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() + +const workspaceName = () => + `${faker.person.firstName()} ${faker.commerce.productName()}` + +const config: SpeckleModuleMocksConfig = FF_WORKSPACES_MODULE_ENABLED + ? { + resolvers: ({ + helpers: { + getFieldValue, + getMockRef, + resolveFromMockParent, + addMockRefValues, + resolveAndCache + } + }) => { + return { + WorkspaceMutations: { + create: (_parent, args) => { + if (args.input.name === 'error') { + throw new Error('Fake workspace create error') + } + + return getMockRef('Workspace', { values: omit(args.input, ['logoUrl']) }) + }, + delete: () => { + const val = faker.datatype.boolean() + if (!val) { + throw new Error('Fake workspace delete error') + } + + return val + }, + update: (_parent, args) => { + if (args.input.name === 'error') { + throw new Error('Fake workspace update error') + } + + return getMockRef('Workspace', { values: omit(args.input, ['logoUrl']) }) + }, + updateRole: (_parent, args) => { + const val = faker.datatype.boolean() + + if (val) { + throw new Error('Fake update role error') + } + + return getMockRef('Workspace', { + id: args.input.workspaceId + }) + }, + deleteRole: (_parent, args) => { + const val = faker.datatype.boolean() + + if (val) { + throw new Error('Fake delete role error') + } + + return getMockRef('Workspace', { + id: args.input.workspaceId + }) + } + }, + WorkspaceInviteMutations: { + create: (_parent, args) => { + const val = faker.datatype.boolean() + + if (val) { + throw new Error('Fake invite create error') + } + + return getMockRef('Workspace', { + id: args.workspaceId + }) + }, + batchCreate: (_parent, args) => { + const val = faker.datatype.boolean() + + if (val) { + throw new Error('Fake batch create invite error') + } + + return getMockRef('Workspace', { + id: args.workspaceId + }) + }, + use: () => { + const val = faker.datatype.boolean() + if (!val) { + throw new Error('Fake use invite error') + } + + return val + }, + cancel: (_parent, args) => { + const val = faker.datatype.boolean() + + if (val) { + throw new Error('Fake cancel invite error') + } + + return getMockRef('Workspace', { + id: args.workspaceId + }) + } + }, + Query: { + workspace: (_parent, args) => { + if (args.id === '404') { + throw new WorkspaceNotFoundError('Workspace not found') + } + + return getMockRef('Workspace', { + id: args.id + }) + }, + workspaceInvite: (_parent, args) => { + const getResult = () => getMockRef('PendingWorkspaceCollaborator') + if (args.token) { + return getResult() + } + + return faker.datatype.boolean() ? getResult() : null + } + }, + User: { + workspaces: resolveAndCache((_parent, args) => + getMockRef('WorkspaceCollection', { + values: { + cursor: args.cursor ? null : undefined + } + }) + ), + workspaceInvites: resolveAndCache(() => + times(faker.number.int({ min: 0, max: 2 }), () => + getMockRef('PendingWorkspaceCollaborator') + ) + ) + }, + Workspace: { + role: resolveFromMockParent(), + team: resolveFromMockParent(), + invitedTeam: resolveFromMockParent({ + mapRefs: (mock, { parent }) => + addMockRefValues(mock, { + workspaceId: getFieldValue(parent, 'id'), + workspaceName: getFieldValue(parent, 'name') + }) + }), + projects: resolveAndCache((_parent, args) => + getMockRef('ProjectCollection', { + values: { + cursor: args.cursor ? null : undefined + } + }) + ) + }, + WorkspaceCollaborator: { + role: resolveFromMockParent() + }, + PendingWorkspaceCollaborator: { + user: resolveAndCache((parent) => { + const title = getFieldValue(parent, 'title') + const isEmail = title.includes('@') + if (isEmail) return null + + return getMockRef('LimitedUser', { values: { name: title } }) + }), + invitedBy: resolveAndCache(() => getMockRef('LimitedUser')) + }, + Project: { + workspace: resolveAndCache(() => { + return faker.datatype.boolean() ? getMockRef('Workspace') : null + }) + }, + AdminQueries: { + workspaceList: resolveAndCache((_parent, args) => + getMockRef('WorkspaceCollection', { + values: { + cursor: args.cursor ? null : undefined + } + }) + ) + }, + WorkspaceCollection: { + items: resolveAndCache((parent) => { + const count = getFieldValue(parent, 'totalCount') + + return times(count, () => getMockRef('Workspace')) + }) + } + } + }, + mocks: { + Workspace: () => ({ + name: workspaceName(), + description: faker.lorem.sentence(), + role: faker.helpers.arrayElement(Object.values(Roles.Workspace)), + team: listMock(1, 5), + invitedTeam: listMock(1, 5) + }), + WorkspaceCollaborator: () => ({ + role: () => faker.helpers.arrayElement(Object.values(Roles.Server)) + }), + PendingWorkspaceCollaborator: () => ({ + inviteId: faker.string.uuid(), + workspaceId: faker.string.uuid(), + workspaceName: workspaceName(), + title: faker.datatype.boolean() + ? faker.internet.email() + : faker.person.fullName(), + role: faker.helpers.arrayElement(Object.values(Roles.Workspace)), + token: faker.string.alphanumeric(32) + }), + WorkspaceCollection: () => ({ + totalCount: faker.number.int({ min: 0, max: 10 }) + }) + } + } + : {} +export default config diff --git a/packages/server/package.json b/packages/server/package.json index 5eaa5f37d..e11864bf1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -39,7 +39,6 @@ "@apollo/client": "^3.7.0", "@aws-sdk/client-s3": "^3.276.0", "@aws-sdk/lib-storage": "^3.100.0", - "@faker-js/faker": "^7.1.0", "@godaddy/terminus": "^4.9.0", "@graphql-tools/schema": "^10.0.4", "@mailchimp/mailchimp_marketing": "^3.0.80", @@ -117,6 +116,7 @@ "devDependencies": { "@apollo/rover": "^0.23.0", "@bull-board/express": "^4.2.2", + "@faker-js/faker": "^8.4.1", "@graphql-codegen/cli": "^5.0.2", "@graphql-codegen/typed-document-node": "^5.0.7", "@graphql-codegen/typescript": "^4.0.7", diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 4003abd28..3ba55af08 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -1704,6 +1704,7 @@ export type PendingWorkspaceCollaborator = { id: Scalars['ID']['output']; inviteId: Scalars['String']['output']; invitedBy: LimitedUser; + /** Target workspace role */ role: Scalars['String']['output']; /** E-mail address or name of the invited user */ title: Scalars['String']['output']; @@ -2414,6 +2415,13 @@ export type Query = { */ userSearch: UserSearchResultCollection; workspace: Workspace; + /** + * Look for an invitation to a workspace, for the current user (authed or not). If token + * isn't specified, the server will look for any valid invite. + * + * If token is specified, it will return the corresponding invite even if it belongs to a different user. + */ + workspaceInvite?: Maybe; }; @@ -2544,6 +2552,12 @@ export type QueryWorkspaceArgs = { id: Scalars['String']['input']; }; + +export type QueryWorkspaceInviteArgs = { + token?: InputMaybe; + workspaceId: Scalars['String']['input']; +}; + /** Deprecated: Used by old stream-based mutations */ export type ReplyCreateInput = { /** IDs of uploaded blobs that should be attached to this reply */ @@ -3344,6 +3358,8 @@ export type User = { * Note: Only count resolution is currently implemented */ versions: CountOnlyCollection; + /** Get all invitations to workspaces that the active user has */ + workspaceInvites: Array; /** Get the workspaces for the user */ workspaces: WorkspaceCollection; }; @@ -3715,6 +3731,7 @@ export type Workspace = { createdAt: Scalars['DateTime']['output']; description?: Maybe; id: Scalars['ID']['output']; + /** Only available to workspace owners */ invitedTeam?: Maybe>; name: Scalars['String']['output']; projects: ProjectCollection; @@ -3794,18 +3811,17 @@ export type WorkspaceInviteMutationsUseArgs = { export type WorkspaceInviteUseInput = { accept: Scalars['Boolean']['input']; token: Scalars['String']['input']; - workspaceId: Scalars['String']['input']; }; export type WorkspaceMutations = { __typename?: 'WorkspaceMutations'; create: Workspace; - delete: Workspace; - deleteRole: Scalars['Boolean']['output']; + delete: Scalars['Boolean']['output']; + deleteRole: Workspace; invites: WorkspaceInviteMutations; update: Workspace; /** TODO: `@hasWorkspaceRole(role: WORKSPACE_ADMIN)` for role changes */ - updateRole: Scalars['Boolean']['output']; + updateRole: Workspace; }; diff --git a/yarn.lock b/yarn.lock index 3ab905378..b56eb6917 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10047,10 +10047,10 @@ __metadata: languageName: node linkType: hard -"@faker-js/faker@npm:^7.1.0": - version: 7.1.0 - resolution: "@faker-js/faker@npm:7.1.0" - checksum: 10/62161e9ac25ab55db7d5fc2ea6177b94be07107275ef11137b88645cd2d41845f5d301077f84ee4663977bf9d42776bb244a23595b791deb9afa4c91366d5d2f +"@faker-js/faker@npm:^8.4.1": + version: 8.4.1 + resolution: "@faker-js/faker@npm:8.4.1" + checksum: 10/5983c2ea64f26055ad6648de748878e11ebe2fb751e3c7435ae141cdffabc2dccfe4c4f49da69a3d2add71e21b415c683ac5fba196fab0d5ed6779fbec436c80 languageName: node linkType: hard @@ -15550,7 +15550,7 @@ __metadata: "@aws-sdk/client-s3": "npm:^3.276.0" "@aws-sdk/lib-storage": "npm:^3.100.0" "@bull-board/express": "npm:^4.2.2" - "@faker-js/faker": "npm:^7.1.0" + "@faker-js/faker": "npm:^8.4.1" "@godaddy/terminus": "npm:^4.9.0" "@graphql-codegen/cli": "npm:^5.0.2" "@graphql-codegen/typed-document-node": "npm:^5.0.7"