test(server): utils (#10028)

This commit is contained in:
forehalo
2025-02-10 06:35:11 +00:00
parent 8f6b512cfd
commit c3f5b4abb4
40 changed files with 1577 additions and 2222 deletions
@@ -1,80 +1,51 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { gql } from './common';
import { TestingApp } from './testing-app';
export async function listBlobs(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
listBlobs(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.listBlobs;
const res = await app.gql(`
query {
listBlobs(workspaceId: "${workspaceId}")
}
`);
return res.listBlobs;
}
export async function getWorkspaceBlobsSize(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
blobsSize
}
}
`,
})
.expect(200);
return res.body.data.workspace.blobsSize;
const res = await app.gql(`
query {
workspace(id: "${workspaceId}") {
blobsSize
}
}
`);
return res.workspace.blobsSize;
}
export async function collectAllBlobSizes(
app: INestApplication,
token: string
): Promise<number> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
query {
currentUser {
quotaUsage {
storageQuota
}
}
}
`,
})
.expect(200);
return res.body.data.currentUser.quotaUsage.storageQuota;
export async function collectAllBlobSizes(app: TestingApp): Promise<number> {
const res = await app.gql(`
query {
currentUser {
quotaUsage {
storageQuota
}
}
}
`);
return res.currentUser.quotaUsage.storageQuota;
}
export async function setBlob(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
buffer: Buffer
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
const res = await app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
@@ -1,8 +1,5 @@
import { randomBytes } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import {
DEFAULT_DIMENSIONS,
OpenAIProvider,
@@ -26,8 +23,8 @@ import {
WorkflowNodeType,
WorkflowParams,
} from '../../plugins/copilot/workflow/types';
import { gql } from './common';
import { handleGraphQLError, sleep } from './utils';
import { TestingApp } from './testing-app';
import { sleep } from './utils';
// @ts-expect-error no error
export class MockCopilotTestProvider
@@ -159,167 +156,123 @@ export class MockCopilotTestProvider
}
export async function createCopilotSession(
app: INestApplication,
userToken: string,
app: TestingApp,
workspaceId: string,
docId: string,
promptName: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation createCopilotSession($options: CreateChatSessionInput!) {
createCopilotSession(options: $options)
}
`,
variables: { options: { workspaceId, docId, promptName } },
})
.expect(200);
const res = await app.gql(
`
mutation createCopilotSession($options: CreateChatSessionInput!) {
createCopilotSession(options: $options)
}
`,
{ options: { workspaceId, docId, promptName } }
);
handleGraphQLError(res);
return res.body.data.createCopilotSession;
return res.createCopilotSession;
}
export async function updateCopilotSession(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
promptName: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation updateCopilotSession($options: UpdateChatSessionInput!) {
updateCopilotSession(options: $options)
}
`,
variables: { options: { sessionId, promptName } },
})
.expect(200);
const res = await app.gql(
`
mutation updateCopilotSession($options: UpdateChatSessionInput!) {
updateCopilotSession(options: $options)
}
`,
{ options: { sessionId, promptName } }
);
handleGraphQLError(res);
return res.body.data.updateCopilotSession;
return res.updateCopilotSession;
}
export async function forkCopilotSession(
app: INestApplication,
userToken: string,
app: TestingApp,
workspaceId: string,
docId: string,
sessionId: string,
latestMessageId: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation forkCopilotSession($options: ForkChatSessionInput!) {
forkCopilotSession(options: $options)
}
`,
variables: {
options: { workspaceId, docId, sessionId, latestMessageId },
},
})
.expect(200);
const res = await app.gql(
`
mutation forkCopilotSession($options: ForkChatSessionInput!) {
forkCopilotSession(options: $options)
}
`,
{ options: { workspaceId, docId, sessionId, latestMessageId } }
);
handleGraphQLError(res);
return res.body.data.forkCopilotSession;
return res.forkCopilotSession;
}
export async function createCopilotMessage(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
content?: string,
attachments?: string[],
blobs?: ArrayBuffer[],
params?: Record<string, string>
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
variables: {
options: { sessionId, content, attachments, blobs, params },
},
})
.expect(200);
const res = await app.gql(
`
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
{ options: { sessionId, content, attachments, blobs, params } }
);
handleGraphQLError(res);
return res.body.data.createCopilotMessage;
return res.createCopilotMessage;
}
export async function chatWithText(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
messageId?: string,
prefix = ''
): Promise<string> {
const query = messageId ? `?messageId=${messageId}` : '';
const res = await request(app.getHttpServer())
.get(`/api/copilot/chat/${sessionId}${prefix}${query}`)
.auth(userToken, { type: 'bearer' })
const res = await app
.GET(`/api/copilot/chat/${sessionId}${prefix}${query}`)
.expect(200);
return res.text;
}
export async function chatWithTextStream(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
messageId?: string
) {
return chatWithText(app, userToken, sessionId, messageId, '/stream');
return chatWithText(app, sessionId, messageId, '/stream');
}
export async function chatWithWorkflow(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
messageId?: string
) {
return chatWithText(app, userToken, sessionId, messageId, '/workflow');
return chatWithText(app, sessionId, messageId, '/workflow');
}
export async function chatWithImages(
app: INestApplication,
userToken: string,
app: TestingApp,
sessionId: string,
messageId?: string
) {
return chatWithText(app, userToken, sessionId, messageId, '/images');
return chatWithText(app, sessionId, messageId, '/images');
}
export async function unsplashSearch(
app: INestApplication,
userToken: string,
app: TestingApp,
params: Record<string, string> = {}
) {
const query = new URLSearchParams(params);
const res = await request(app.getHttpServer())
.get(`/api/copilot/unsplash/photos?${query}`)
.auth(userToken, { type: 'bearer' });
const res = await app.GET(`/api/copilot/unsplash/photos?${query}`);
return res;
}
@@ -378,8 +331,7 @@ type History = {
};
export async function getHistories(
app: INestApplication,
userToken: string,
app: TestingApp,
variables: {
workspaceId: string;
docId?: string;
@@ -394,43 +346,36 @@ export async function getHistories(
};
}
): Promise<History[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query getCopilotHistories(
$workspaceId: String!
$docId: String
$options: QueryChatHistoriesInput
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: $options) {
sessionId
tokens
action
const res = await app.gql(
`
query getCopilotHistories(
$workspaceId: String!
$docId: String
$options: QueryChatHistoriesInput
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: $options) {
sessionId
tokens
action
createdAt
messages {
id
role
content
attachments
createdAt
messages {
id
role
content
attachments
createdAt
}
}
}
}
}
}
`,
variables,
})
.expect(200);
variables
);
handleGraphQLError(res);
return res.body.data.currentUser?.copilot?.histories || [];
return res.currentUser?.copilot?.histories || [];
}
type Prompt = {
@@ -1,6 +1,8 @@
export * from './blobs';
export * from './invite';
export * from './permission';
export * from './testing-app';
export * from './testing-module';
export * from './user';
export * from './utils';
export * from './workspace';
@@ -1,281 +1,171 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import type { InvitationType } from '../../core/workspaces';
import { gql } from './common';
import type { TestingApp } from './testing-app';
export async function inviteUser(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
email: string,
sendInviteMail = false
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.invite;
const res = await app.gql(`
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
}
`);
return res.invite;
}
export async function inviteUsers(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
emails: string[],
sendInviteMail = false
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
inviteBatch(
workspaceId: $workspaceId
emails: $emails
sendInviteMail: $sendInviteMail
) {
email
inviteId
sentSuccess
}
}
`,
variables: { workspaceId, emails, sendInviteMail },
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.inviteBatch;
const res = await app.gql(
`
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
inviteBatch(
workspaceId: $workspaceId
emails: $emails
sendInviteMail: $sendInviteMail
) {
email
inviteId
sentSuccess
}
}
`,
{ workspaceId, emails, sendInviteMail }
);
return res.inviteBatch;
}
export async function getInviteLink(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string
): Promise<{ link: string; expireTime: string }> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
inviteLink {
link
expireTime
}
}
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.workspace.inviteLink;
const res = await app.gql(`
query {
workspace(id: "${workspaceId}") {
inviteLink {
link
expireTime
}
}
}
`);
return res.workspace.inviteLink;
}
export async function createInviteLink(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth'
): Promise<{ link: string; expireTime: string }> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) {
link
expireTime
}
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.createInviteLink;
const res = await app.gql(`
mutation {
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) {
link
expireTime
}
}
`);
return res.createInviteLink;
}
export async function revokeInviteLink(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokeInviteLink(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.revokeInviteLink;
const res = await app.gql(`
mutation {
revokeInviteLink(workspaceId: "${workspaceId}")
}
`);
return res.revokeInviteLink;
}
export async function acceptInviteById(
app: INestApplication,
app: TestingApp,
workspaceId: string,
inviteId: string,
sendAcceptMail = false,
token: string = ''
sendAcceptMail = false
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail})
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.acceptInviteById;
const res = await app.gql(`
mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}", sendAcceptMail: ${sendAcceptMail})
}
`);
return res.acceptInviteById;
}
export async function approveMember(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
userId: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
approveMember(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.approveMember;
const res = await app.gql(`
mutation {
approveMember(workspaceId: "${workspaceId}", userId: "${userId}")
}
`);
return res.approveMember;
}
export async function leaveWorkspace(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
sendLeaveMail = false
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}", sendLeaveMail: ${sendLeaveMail})
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data.leaveWorkspace;
const res = await app.gql(`
mutation {
leaveWorkspace(workspaceId: "${workspaceId}", sendLeaveMail: ${sendLeaveMail})
}
`);
return res.leaveWorkspace;
}
export async function revokeUser(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.revoke;
const res = await app.gql(`
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`);
return res.revoke;
}
export async function getInviteInfo(
app: INestApplication,
token: string,
app: TestingApp,
inviteId: string
): Promise<InvitationType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
getInviteInfo(inviteId: "${inviteId}") {
workspace {
id
name
avatar
}
user {
id
name
avatarUrl
}
}
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message, {
cause: res.body.errors[0].cause,
});
}
return res.body.data.getInviteInfo;
const res = await app.gql(`
query {
getInviteInfo(inviteId: "${inviteId}") {
workspace {
id
name
avatar
}
user {
id
name
avatarUrl
}
}
}
`);
return res.getInviteInfo;
}
@@ -1,116 +1,84 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { DocRole } from '../../core/permission/types';
import { gql } from './common';
import { TestingApp } from './testing-app';
export function grantDocUserRoles(
app: INestApplication,
token: string,
export async function grantDocUserRoles(
app: TestingApp,
workspaceId: string,
docId: string,
userIds: string[],
role: DocRole
) {
return request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
grantDocUserRoles(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
userIds: ["${userIds.join('","')}"],
role: ${DocRole[role]}
})
}
`,
});
return await app.gql(`
mutation {
grantDocUserRoles(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
userIds: ["${userIds.join('","')}"],
role: ${DocRole[role]}
})
}
`);
}
export function revokeDocUserRoles(
app: INestApplication,
token: string,
export async function revokeDocUserRoles(
app: TestingApp,
workspaceId: string,
docId: string,
userId: string
) {
return request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokeDocUserRoles(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
userId: "${userId}"
})
}
`,
});
return await app.gql(`
mutation {
revokeDocUserRoles(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
userId: "${userId}"
})
}
`);
}
export function updateDocDefaultRole(
app: INestApplication,
token: string,
export async function updateDocDefaultRole(
app: TestingApp,
workspaceId: string,
docId: string,
role: DocRole
) {
return request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
updateDocDefaultRole(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
role: ${DocRole[role]}
})
}
`,
});
return await app.gql(`
mutation {
updateDocDefaultRole(input: {
workspaceId: "${workspaceId}",
docId: "${docId}",
role: ${DocRole[role]}
})
}
`);
}
export async function docGrantedUsersList(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
docId: string,
first = 10,
offset = 0
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
doc(docId: "${docId}") {
grantedUsersList(pagination: { first: ${first}, offset: ${offset} }) {
totalCount
edges {
cursor
node {
role
user {
id
}
}
return await app.gql(`
query {
workspace(id: "${workspaceId}") {
doc(docId: "${docId}") {
grantedUsersList(pagination: { first: ${first}, offset: ${offset} }) {
totalCount
edges {
cursor
node {
role
user {
id
}
}
}
}
}
`,
});
return res.body;
}
}
`);
}
@@ -0,0 +1,243 @@
import {
ConsoleLogger,
INestApplication,
ModuleMetadata,
} from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing';
import { User } from '@prisma/client';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import supertest from 'supertest';
import { ApplyType, GlobalExceptionFilter } from '../../base';
import { AuthService } from '../../core/auth';
import { UserModel } from '../../models';
import { createTestingModule } from './testing-module';
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
interface TestingAppMeatdata extends ModuleMetadata {
tapModule?(m: TestingModuleBuilder): void;
tapApp?(app: INestApplication): void;
}
export type TestUser = Omit<User, 'password'> & { password: string };
export async function createTestingApp(
moduleDef: TestingAppMeatdata = {}
): Promise<TestingApp> {
const module = await createTestingModule(moduleDef, false);
const app = module.createNestApplication({
cors: true,
bodyParser: true,
rawBody: true,
});
const logger = new ConsoleLogger();
logger.setLogLevels([TEST_LOG_LEVEL]);
app.useLogger(logger);
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
app.use(cookieParser());
if (moduleDef.tapApp) {
moduleDef.tapApp(app);
}
await module.initTestingDB();
await app.init();
return makeTestingApp(app);
}
export function parseCookies(res: supertest.Response) {
const cookies = res.get('Set-Cookie') ?? [];
const sessionCookie = cookies.reduce(
(cookies, cookie) => {
const [key, value] = cookie.split(';')[0].split('=');
cookies[key] = value;
return cookies;
},
{} as Record<string, string>
);
return sessionCookie;
}
export class TestingApp extends ApplyType<INestApplication>() {
private sessionCookie: string | null = null;
private currentUserCookie: string | null = null;
private readonly userCookies: Set<string> = new Set();
[Symbol.asyncDispose](): Promise<void> {
return this.close();
}
async initTestingDB() {
await initTestingDB(this);
this.sessionCookie = null;
this.currentUserCookie = null;
this.userCookies.clear();
}
url() {
const server = this.getHttpServer();
if (!server.address()) {
server.listen();
}
return `http://localhost:${server.address().port}`;
}
request(
method: 'get' | 'post' | 'put' | 'delete' | 'patch',
path: string
): supertest.Test {
return supertest(this.getHttpServer())
[method](path)
.set('Cookie', [
`${AuthService.sessionCookieName}=${this.sessionCookie ?? ''}`,
`${AuthService.userCookieName}=${this.currentUserCookie ?? ''}`,
]);
}
GET(path: string): supertest.Test {
return this.request('get', path);
}
POST(path: string): supertest.Test {
return this.request('post', path).on(
'response',
(res: supertest.Response) => {
const cookies = parseCookies(res);
if (AuthService.sessionCookieName in cookies) {
if (this.sessionCookie !== cookies[AuthService.sessionCookieName]) {
this.userCookies.clear();
}
this.sessionCookie = cookies[AuthService.sessionCookieName];
this.currentUserCookie = cookies[AuthService.userCookieName];
if (this.currentUserCookie) {
this.userCookies.add(this.currentUserCookie);
}
}
return res;
}
);
}
PUT(path: string): supertest.Test {
return this.request('put', path);
}
DELETE(path: string): supertest.Test {
return this.request('delete', path);
}
PATCH(path: string): supertest.Test {
return this.request('patch', path);
}
// TODO(@forehalo): directly make proxy for graphql queries defined in `@affine/graphql`
// by calling with `app.apis.createWorkspace({ ...variables })`
async gql<Data = any>(query: string, variables?: any): Promise<Data> {
const res = await this.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query,
variables,
})
.expect(200);
if (res.body.errors?.length) {
throw new Error(res.body.errors[0].message);
}
return res.body.data;
}
async createUser(email: string, override?: Partial<User>): Promise<TestUser> {
const model = this.get(UserModel);
// TODO(@forehalo): model factories
// TestingData.user.create()
const user = await model.create({
email,
password: '1',
name: email,
emailVerifiedAt: new Date(),
...override,
});
// returned password is not encrypted
user.password = '1';
return user as Omit<User, 'password'> & { password: string };
}
async signup(email: string, override?: Partial<User>) {
const user = await this.createUser(email, override);
await this.login(user);
return user;
}
async login(user: TestUser) {
await this.POST('/api/auth/sign-in')
.send({
email: user.email,
password: user.password,
})
.expect(200);
}
async switchUser(userOrId: string | { id: string }) {
if (!this.sessionCookie) {
throw new Error('No user is logged in.');
}
const userId = typeof userOrId === 'string' ? userOrId : userOrId.id;
if (userId === this.currentUserCookie) {
return;
}
if (this.userCookies.has(userId)) {
this.currentUserCookie = userId;
} else {
throw new Error(`User [${userId}] is not logged in.`);
}
}
async logout(userId?: string) {
const res = await this.GET(
'/api/auth/sign-out' + (userId ? `?user_id=${userId}` : '')
).expect(200);
const cookies = parseCookies(res);
this.sessionCookie = cookies[AuthService.sessionCookieName];
if (!this.sessionCookie) {
this.currentUserCookie = null;
this.userCookies.clear();
} else {
this.currentUserCookie = cookies[AuthService.userCookieName];
if (userId) {
this.userCookies.delete(userId);
}
}
}
}
function makeTestingApp(app: INestApplication): TestingApp {
const testingApp = new TestingApp();
return new Proxy(testingApp, {
get(target, prop) {
// @ts-expect-error override
return target[prop] ?? app[prop];
},
});
}
@@ -0,0 +1,110 @@
import { ModuleMetadata } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { Query, Resolver } from '@nestjs/graphql';
import {
Test,
TestingModule as BaseTestingModule,
TestingModuleBuilder,
} from '@nestjs/testing';
import { AppModule, FunctionalityModules } from '../../app.module';
import { Runtime } from '../../base';
import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth';
import { ModelsModule } from '../../models';
import { initTestingDB, TEST_LOG_LEVEL } from './utils';
interface TestingModuleMeatdata extends ModuleMetadata {
tapModule?(m: TestingModuleBuilder): void;
}
export interface TestingModule extends BaseTestingModule {
initTestingDB(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}
function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
const map = new Map();
modules.forEach(m => {
if ('module' in m) {
map.set(m.module, m);
} else {
map.set(m, m);
}
});
return Array.from(map.values());
}
@Resolver(() => String)
class MockResolver {
@Query(() => String)
hello() {
return 'hello world';
}
}
export async function createTestingModule(
moduleDef: TestingModuleMeatdata = {},
autoInitialize = true
): Promise<TestingModule> {
// setting up
let imports = moduleDef.imports ?? [AppModule];
imports =
imports[0] === AppModule
? [AppModule]
: dedupeModules([
...FunctionalityModules,
ModelsModule,
AuthModule,
GqlModule,
...imports,
]);
const builder = Test.createTestingModule({
imports,
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
MockResolver,
...(moduleDef.providers ?? []),
],
controllers: moduleDef.controllers,
});
if (moduleDef.tapModule) {
moduleDef.tapModule(builder);
}
const module = await builder.compile();
const testingModule = module as TestingModule;
testingModule.initTestingDB = async () => {
await initTestingDB(module);
const runtime = module.get(Runtime);
// by pass password min length validation
await runtime.set('auth/password.min', 1);
};
testingModule[Symbol.asyncDispose] = async () => {
await module.close();
};
if (autoInitialize) {
// we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs
// @ts-expect-error private
module.applyLogger({
logger: [TEST_LOG_LEVEL],
});
await testingModule.initTestingDB();
await testingModule.init();
}
return testingModule;
}
@@ -1,201 +1,124 @@
import type { INestApplication } from '@nestjs/common';
import request, { type Response } from 'supertest';
import { TestingApp } from './testing-app';
import {
AuthService,
type ClientTokenType,
type CurrentUser,
} from '../../core/auth';
import { sessionUser } from '../../core/auth/service';
import { UserType } from '../../core/user';
import { Models } from '../../models';
import { gql } from './common';
export type UserAuthedType = UserType & { token: ClientTokenType };
export async function internalSignIn(app: INestApplication, userId: string) {
const auth = app.get(AuthService);
const session = await auth.createUserSession(userId);
return `${AuthService.sessionCookieName}=${session.sessionId}`;
}
export function sessionCookie(headers: any): string {
const cookie = headers['set-cookie']?.find((c: string) =>
c.startsWith(`${AuthService.sessionCookieName}=`)
);
if (!cookie) {
return '';
}
return cookie.split(';')[0];
}
export async function getSession(
app: INestApplication,
signInRes: Response
): Promise<{ user?: CurrentUser }> {
const cookie = sessionCookie(signInRes.headers);
const res = await request(app.getHttpServer())
.get('/api/auth/session')
.set('cookie', cookie!)
.expect(200);
return res.body;
}
export async function signUp(
app: INestApplication,
name: string,
email: string,
password: string,
autoVerifyEmail = true
): Promise<UserAuthedType> {
const user = await app.get(Models).user.create({
name,
email,
password,
emailVerifiedAt: autoVerifyEmail ? new Date() : null,
});
const { sessionId } = await app.get(AuthService).createUserSession(user.id);
return {
...sessionUser(user),
token: { token: sessionId, refresh: '' },
};
}
export async function currentUser(app: INestApplication, token: string) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
currentUser {
id, name, email, emailVerified, avatarUrl, hasPassword,
token { token }
}
}
`,
})
.expect(200);
return res.body.data.currentUser;
export async function currentUser(app: TestingApp) {
const res = await app.gql(`
query {
currentUser {
id, name, email, emailVerified, avatarUrl, hasPassword,
token { token }
}
}
`);
return res.currentUser;
}
export async function sendChangeEmail(
app: INestApplication,
userToken: string,
app: TestingApp,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
const res = await app.gql(`
mutation {
sendChangeEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`);
return res.body.data.sendChangeEmail;
return res.sendChangeEmail;
}
export async function sendSetPasswordEmail(
app: INestApplication,
userToken: string,
app: TestingApp,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendSetPasswordEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
const res = await app.gql(`
mutation {
sendSetPasswordEmail(email: "${email}", callbackUrl: "${callbackUrl}")
}
`);
return res.body.data.sendChangeEmail;
return res.sendSetPasswordEmail;
}
export async function changePassword(
app: INestApplication,
app: TestingApp,
userId: string,
token: string,
password: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation changePassword($token: String!, $userId: String!, $password: String!) {
changePassword(token: $token, userId: $userId, newPassword: $password)
}
`,
variables: { token, password, userId },
})
.expect(200);
return res.body.data.changePassword;
const res = await app.gql(`
mutation {
changePassword(token: "${token}", userId: "${userId}", newPassword: "${password}")
}
`);
return res.changePassword;
}
export async function sendVerifyChangeEmail(
app: INestApplication,
userToken: string,
app: TestingApp,
token: string,
email: string,
callbackUrl: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sendVerifyChangeEmail(token:"${token}", email: "${email}", callbackUrl: "${callbackUrl}")
}
`,
})
.expect(200);
const res = await app.gql(`
mutation {
sendVerifyChangeEmail(token: "${token}", email: "${email}", callbackUrl: "${callbackUrl}")
}
`);
return res.body.data.sendVerifyChangeEmail;
return res.sendVerifyChangeEmail;
}
export async function changeEmail(
app: INestApplication,
userToken: string,
app: TestingApp,
token: string,
email: string
): Promise<UserAuthedType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
changeEmail(token: "${token}", email: "${email}") {
id
name
avatarUrl
email
}
}
`,
})
.expect(200);
return res.body.data.changeEmail;
) {
const res = await app.gql(`
mutation {
changeEmail(token: "${token}", email: "${email}") {
id
name
avatarUrl
email
}
}
`);
return res.changeEmail;
}
export async function deleteAccount(app: TestingApp) {
const res = await app.gql(`
mutation {
deleteAccount {
success
}
}
`);
return res.deleteAccount.success;
}
export async function updateAvatar(app: TestingApp, avatar: Buffer) {
return app
.POST('/graphql')
.field(
'operations',
JSON.stringify({
name: 'uploadAvatar',
query: `mutation uploadAvatar($avatar: Upload!) {
uploadAvatar(avatar: $avatar) {
avatarUrl
}
}`,
variables: { avatar: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
.attach('0', avatar, {
filename: 'test.png',
contentType: 'image/png',
});
}
@@ -1,25 +1,10 @@
import { INestApplication, LogLevel, ModuleMetadata } from '@nestjs/common';
import { APP_GUARD, ModuleRef } from '@nestjs/core';
import { Query, Resolver } from '@nestjs/graphql';
import {
Test,
TestingModule as BaseTestingModule,
TestingModuleBuilder,
} from '@nestjs/testing';
import { INestApplicationContext, LogLevel } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import type { Response } from 'supertest';
import supertest from 'supertest';
import { AppModule, FunctionalityModules } from '../../app.module';
import { AFFiNELogger, GlobalExceptionFilter, Runtime } from '../../base';
import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth';
import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features';
import { ModelsModule } from '../../models';
const TEST_LOG_LEVEL: LogLevel =
export const TEST_LOG_LEVEL: LogLevel =
(process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal';
async function flushDB(client: PrismaClient) {
@@ -38,190 +23,10 @@ async function flushDB(client: PrismaClient) {
);
}
interface TestingModuleMetadata extends ModuleMetadata {
tapModule?(m: TestingModuleBuilder): void;
tapApp?(app: INestApplication): void;
}
const initTestingDB = async (ref: ModuleRef) => {
const db = ref.get(PrismaClient, { strict: false });
export async function initTestingDB(context: INestApplicationContext) {
const db = context.get(PrismaClient, { strict: false });
await flushDB(db);
await RefreshFeatures0001.up(db, ref);
};
export type TestingModule = BaseTestingModule & {
initTestingDB(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
};
export type TestingApp = INestApplication & {
initTestingDB(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
// get the url of the http server, e.g. http://localhost:random-port
getHttpServerUrl(): string;
};
function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
const map = new Map();
modules.forEach(m => {
if ('module' in m) {
map.set(m.module, m);
} else {
map.set(m, m);
}
});
return Array.from(map.values());
}
@Resolver(() => String)
class MockResolver {
@Query(() => String)
hello() {
return 'hello world';
}
}
export async function createTestingModule(
moduleDef: TestingModuleMetadata = {},
autoInitialize = true
): Promise<TestingModule> {
// setting up
let imports = moduleDef.imports ?? [];
imports =
imports[0] === AppModule
? [AppModule]
: dedupeModules([
...FunctionalityModules,
ModelsModule,
AuthModule,
GqlModule,
...imports,
]);
const builder = Test.createTestingModule({
imports,
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
MockResolver,
...(moduleDef.providers ?? []),
],
controllers: moduleDef.controllers,
});
if (moduleDef.tapModule) {
moduleDef.tapModule(builder);
}
const m = await builder.compile();
const testingModule = m as TestingModule;
testingModule.initTestingDB = async () => {
await initTestingDB(m.get(ModuleRef));
// we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs
// @ts-expect-error private
m.applyLogger({
logger: [TEST_LOG_LEVEL],
});
const runtime = m.get(Runtime);
// by pass password min length validation
await runtime.set('auth/password.min', 1);
};
testingModule[Symbol.asyncDispose] = async () => {
await m.close();
};
if (autoInitialize) {
await testingModule.initTestingDB();
await testingModule.init();
}
return testingModule;
}
export async function createTestingApp(
moduleDef: TestingModuleMetadata = {}
): Promise<{ module: TestingModule; app: TestingApp }> {
const m = await createTestingModule(moduleDef, false);
const app = m.createNestApplication({
cors: true,
bodyParser: true,
rawBody: true,
}) as TestingApp;
const logger = new AFFiNELogger();
logger.setLogLevels([TEST_LOG_LEVEL]);
app.useLogger(logger);
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
app.use(cookieParser());
if (moduleDef.tapApp) {
moduleDef.tapApp(app);
}
await m.initTestingDB();
await app.init();
app.initTestingDB = m.initTestingDB.bind(m);
app[Symbol.asyncDispose] = async () => {
await m[Symbol.asyncDispose]();
await app.close();
};
app.getHttpServerUrl = () => {
const server = app.getHttpServer();
if (!server.address()) {
server.listen();
}
return `http://localhost:${server.address().port}`;
};
return {
module: m,
app: app,
};
}
export function handleGraphQLError(resp: Response) {
const { errors } = resp.body;
if (errors) {
const cause = errors[0];
const stacktrace = cause.extensions?.stacktrace;
throw new Error(
stacktrace
? Array.isArray(stacktrace)
? stacktrace.join('\n')
: String(stacktrace)
: cause.message,
cause
);
}
}
export function gql(app: INestApplication, query?: string) {
const req = supertest(app.getHttpServer())
.post('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' });
if (query) {
return req.send({ query });
}
return req;
await RefreshFeatures0001.up(db, context.get(ModuleRef));
}
export async function sleep(ms: number) {
@@ -1,18 +1,14 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { WorkspaceRole } from '../../core/permission/types';
import type { WorkspaceType } from '../../core/workspaces';
import { gql } from './common';
import { TestingApp } from './testing-app';
export async function createWorkspace(
app: INestApplication,
token: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
export async function createWorkspace(app: TestingApp): Promise<WorkspaceType> {
const res = await app
.POST('/graphql')
.set({
'x-request-id': 'test',
'x-operation-name': 'test',
})
.field(
'operations',
JSON.stringify({
@@ -26,181 +22,141 @@ export async function createWorkspace(
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
.attach('0', Buffer.from([0, 0]), 'init.data');
return res.body.data.createWorkspace;
}
export async function getWorkspacePublicPages(
app: INestApplication,
token: string,
export async function getWorkspacePublicDocs(
app: TestingApp,
workspaceId: string
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
publicPages {
id
mode
}
}
}
`,
})
.expect(200);
return res.body.data.workspace.publicPages;
const res = await app.gql(
`
query {
workspace(id: "${workspaceId}") {
publicDocs {
id
mode
}
}
}
`
);
return res.workspace.publicDocs;
}
export async function getWorkspace(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
skip = 0,
take = 8
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId, status }
}
}
`,
})
.expect(200);
return res.body.data.workspace;
const res = await app.gql(
`
query {
workspace(id: "${workspaceId}") {
id,
members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId, status }
}
}
`
);
return res.workspace;
}
export async function updateWorkspace(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
isPublic: boolean
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
public
}
}
`,
})
.expect(200);
return res.body.data.updateWorkspace.public;
const res = await app.gql(
`
mutation {
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
public
}
}
`
);
return res.updateWorkspace.public;
}
export async function publishDoc(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
docId: string
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
publishDoc(workspaceId: "${workspaceId}", docId: "${docId}") {
id
mode
}
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.publishDoc;
const res = await app.gql(
`
mutation {
publishDoc(workspaceId: "${workspaceId}", docId: "${docId}") {
id
mode
}
}
`
);
return res.publishDoc;
}
export async function revokePublicDoc(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
docId: string
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokePublicDoc(workspaceId: "${workspaceId}", docId: "${docId}") {
id
mode
public
}
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.revokePublicDoc;
const res = await app.gql(
`
mutation {
revokePublicDoc(workspaceId: "${workspaceId}", docId: "${docId}") {
id
mode
public
}
}
`
);
return res.revokePublicDoc;
}
export async function grantMember(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
userId: string,
permission: WorkspaceRole
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
grantMember(
workspaceId: "${workspaceId}"
userId: "${userId}"
permission: ${WorkspaceRole[permission]}
)
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data?.grantMember;
const res = await app.gql(
`
mutation {
grantMember(
workspaceId: "${workspaceId}"
userId: "${userId}"
permission: ${WorkspaceRole[permission]}
)
}
`
);
return res.grantMember;
}
export async function revokeMember(
app: INestApplication,
token: string,
app: TestingApp,
workspaceId: string,
userId: string
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data?.revokeMember;
const res = await app.gql(
`
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`
);
return res.revoke;
}