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
This commit is contained in:
Kristaps Fabians Geikins 2024-07-29 11:21:59 +03:00 коммит произвёл GitHub
Родитель eefeef1ee4
Коммит 1e5dadacd3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
15 изменённых файлов: 1327 добавлений и 425 удалений

Просмотреть файл

@ -68,6 +68,7 @@ export type AdminQueries = {
projectList: ProjectCollection;
serverStatistics: ServerStatistics;
userList: AdminUserList;
workspaceList: WorkspaceCollection;
};
@ -94,6 +95,13 @@ export type AdminQueriesUserListArgs = {
role?: InputMaybe<ServerRole>;
};
export type AdminQueriesWorkspaceListArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: Scalars['Int']['input'];
query?: InputMaybe<Scalars['String']['input']>;
};
export type AdminUserList = {
__typename?: 'AdminUserList';
cursor?: Maybe<Scalars['String']['output']>;
@ -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<LimitedUser>;
};
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<Scalars['String']['output']>;
/** Set only if user is registered */
user?: Maybe<LimitedUser>;
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<ViewerResourceGroup>;
visibility: ProjectVisibility;
webhooks: WebhookCollection;
workspace?: Maybe<Workspace>;
};
@ -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<PendingWorkspaceCollaborator>;
};
@ -2508,6 +2543,17 @@ export type QueryUserSearchArgs = {
query: Scalars['String']['input'];
};
export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
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<ServerRoleItem>;
termsOfService?: Maybe<Scalars['String']['output']>;
version?: Maybe<Scalars['String']['output']>;
workspaces: ServerWorkspacesInfo;
};
export type ServerInfoUpdateInput = {
@ -2678,6 +2725,15 @@ export type ServerStats = {
userHistory?: Maybe<Array<Maybe<Scalars['JSONObject']['output']>>>;
};
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<PendingWorkspaceCollaborator>;
/** 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<Scalars['String']['input']>;
filter?: InputMaybe<UserWorkspacesFilter>;
limit?: Scalars['Int']['input'];
};
export type UserAutomateInfo = {
__typename?: 'UserAutomateInfo';
availableGithubOrgs: Array<Scalars['String']['output']>;
@ -3435,6 +3506,10 @@ export type UserUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type UserWorkspacesFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
@ -3647,6 +3722,153 @@ export type WebhookUpdateInput = {
url?: InputMaybe<Scalars['String']['input']>;
};
export type Workspace = {
__typename?: 'Workspace';
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
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<Scalars['String']['output']>;
team: Array<WorkspaceCollaborator>;
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceProjectsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<UserProjectsFilter>;
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<Scalars['String']['output']>;
items: Array<Workspace>;
totalCount: Scalars['Int']['output'];
};
export type WorkspaceCreateInput = {
description?: InputMaybe<Scalars['String']['input']>;
logoUrl?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type WorkspaceInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
/** Defaults to the member role, if not specified */
role?: InputMaybe<WorkspaceRole>;
/** Either this or email must be filled */
userId?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceInviteMutations = {
__typename?: 'WorkspaceInviteMutations';
batchCreate: Workspace;
cancel: Workspace;
create: Workspace;
use: Scalars['Boolean']['output'];
};
export type WorkspaceInviteMutationsBatchCreateArgs = {
input: Array<WorkspaceInviteCreateInput>;
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<Scalars['String']['input']>;
id: Scalars['String']['input'];
logoUrl?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
};
export type AuthRegisterPanelServerInfoFragment = { __typename?: 'ServerInfo', inviteOnly?: boolean | null };
export type RegisterPanelServerInviteQueryVariables = Exact<{

Просмотреть файл

@ -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

Просмотреть файл

@ -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 {

Просмотреть файл

@ -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 <AutomationRunTriggerGraphQLReturn>{
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

Просмотреть файл

@ -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<PendingWorkspaceCollaborator>;
};
@ -2554,6 +2562,12 @@ export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
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<PendingWorkspaceCollaborator>;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@ -3725,6 +3741,7 @@ export type Workspace = {
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
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<Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaces'> & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe<ResolversTypes['CommitCollection']>, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversTypes['PendingStreamCollaborator']>, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaces: ResolversTypes['WorkspaceCollection'] }>;
User: ResolverTypeWrapper<Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaceInvites' | 'workspaces'> & { automateInfo: ResolversTypes['UserAutomateInfo'], commits?: Maybe<ResolversTypes['CommitCollection']>, favoriteStreams: ResolversTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversTypes['PendingStreamCollaborator']>, projects: ResolversTypes['ProjectCollection'], streams: ResolversTypes['StreamCollection'], workspaceInvites: Array<ResolversTypes['PendingWorkspaceCollaborator']>, workspaces: ResolversTypes['WorkspaceCollection'] }>;
UserAutomateInfo: ResolverTypeWrapper<UserAutomateInfoGraphQLReturn>;
UserDeleteInput: UserDeleteInput;
UserProjectsFilter: UserProjectsFilter;
@ -4347,7 +4363,7 @@ export type ResolversParentTypes = {
UpdateAutomateFunctionInput: UpdateAutomateFunctionInput;
UpdateModelInput: UpdateModelInput;
UpdateVersionInput: UpdateVersionInput;
User: Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaces'> & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe<ResolversParentTypes['CommitCollection']>, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversParentTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversParentTypes['PendingStreamCollaborator']>, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaces: ResolversParentTypes['WorkspaceCollection'] };
User: Omit<User, 'automateInfo' | 'commits' | 'favoriteStreams' | 'projectAccessRequest' | 'projectInvites' | 'projects' | 'streams' | 'workspaceInvites' | 'workspaces'> & { automateInfo: ResolversParentTypes['UserAutomateInfo'], commits?: Maybe<ResolversParentTypes['CommitCollection']>, favoriteStreams: ResolversParentTypes['StreamCollection'], projectAccessRequest?: Maybe<ResolversParentTypes['ProjectAccessRequest']>, projectInvites: Array<ResolversParentTypes['PendingStreamCollaborator']>, projects: ResolversParentTypes['ProjectCollection'], streams: ResolversParentTypes['StreamCollection'], workspaceInvites: Array<ResolversParentTypes['PendingWorkspaceCollaborator']>, workspaces: ResolversParentTypes['WorkspaceCollection'] };
UserAutomateInfo: UserAutomateInfoGraphQLReturn;
UserDeleteInput: UserDeleteInput;
UserProjectsFilter: UserProjectsFilter;
@ -5263,6 +5279,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
userPwdStrength?: Resolver<ResolversTypes['PasswordStrengthCheckResults'], ParentType, ContextType, RequireFields<QueryUserPwdStrengthArgs, 'pwd'>>;
userSearch?: Resolver<ResolversTypes['UserSearchResultCollection'], ParentType, ContextType, RequireFields<QueryUserSearchArgs, 'archived' | 'emailOnly' | 'limit' | 'query'>>;
workspace?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<QueryWorkspaceArgs, 'id'>>;
workspaceInvite?: Resolver<Maybe<ResolversTypes['PendingWorkspaceCollaborator']>, ParentType, ContextType, RequireFields<QueryWorkspaceInviteArgs, 'workspaceId'>>;
};
export type ResourceIdentifierResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['ResourceIdentifier'] = ResolversParentTypes['ResourceIdentifier']> = {
@ -5541,6 +5558,7 @@ export type UserResolvers<ContextType = GraphQLContext, ParentType extends Resol
totalOwnedStreamsFavorites?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
verified?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
versions?: Resolver<ResolversTypes['CountOnlyCollection'], ParentType, ContextType, RequireFields<UserVersionsArgs, 'authoredOnly' | 'limit'>>;
workspaceInvites?: Resolver<Array<ResolversTypes['PendingWorkspaceCollaborator']>, ParentType, ContextType>;
workspaces?: Resolver<ResolversTypes['WorkspaceCollection'], ParentType, ContextType, RequireFields<UserWorkspacesArgs, 'cursor' | 'limit'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -5708,11 +5726,11 @@ export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, Pare
export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceMutations'] = ResolversParentTypes['WorkspaceMutations']> = {
create?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsCreateArgs, 'input'>>;
delete?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteArgs, 'workspaceId'>>;
deleteRole?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteRoleArgs, 'input'>>;
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteArgs, 'workspaceId'>>;
deleteRole?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteRoleArgs, 'input'>>;
invites?: Resolver<ResolversTypes['WorkspaceInviteMutations'], ParentType, ContextType>;
update?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateArgs, 'input'>>;
updateRole?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateRoleArgs, 'input'>>;
updateRole?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsUpdateRoleArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};

Просмотреть файл

@ -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

Просмотреть файл

@ -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<PendingWorkspaceCollaborator>;
};
@ -2543,6 +2551,12 @@ export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
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<PendingWorkspaceCollaborator>;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@ -3714,6 +3730,7 @@ export type Workspace = {
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
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;
};

Просмотреть файл

@ -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<string, import('@/modules/shared/helpers/mocks').SpeckleModuleMocksConfig>}
*/
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
}

Просмотреть файл

@ -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<SpeckleModuleMocksConfig> => {
// 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<string, any>)
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 <AutomationRunTriggerGraphQLReturn>{
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
}
}

Просмотреть файл

@ -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
*/

Просмотреть файл

@ -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<typeof mockStoreHelpers>
}) => 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<string, any>) => {
store.set(ref, values)
return ref
}
const getMockRef = <T = any>(
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<string, any>
}
) => {
const { id, values } = options || {}
const ret = values
? store.get(type, {
...values,
...(id ? { id } : {})
})
: store.get(type, id)
return ret as T
}
const getFieldValue = <T = any>(
refOrObj: Record<string, unknown> | 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<any, any, any, any>
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 <T extends {} = any>(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<T>('*')
.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[]

Просмотреть файл

@ -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<string>(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

Просмотреть файл

@ -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",

Просмотреть файл

@ -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<PendingWorkspaceCollaborator>;
};
@ -2544,6 +2552,12 @@ export type QueryWorkspaceArgs = {
id: Scalars['String']['input'];
};
export type QueryWorkspaceInviteArgs = {
token?: InputMaybe<Scalars['String']['input']>;
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<PendingWorkspaceCollaborator>;
/** Get the workspaces for the user */
workspaces: WorkspaceCollection;
};
@ -3715,6 +3731,7 @@ export type Workspace = {
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** Only available to workspace owners */
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
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;
};

Просмотреть файл

@ -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"