From 83a2e3bcba2a19034a9ea77965f62192a6c828b4 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Mon, 24 Mar 2025 04:36:49 +0000 Subject: [PATCH] test(server): use new e2e (#11056) --- packages/backend/server/package.json | 4 +- .../server/src/__tests__/e2e/app.spec.ts | 8 + .../e2e/notification/resolver.spec.ts | 696 ++++++++++++++++++ .../__tests__/e2e/workspace/invite.spec.ts | 314 ++++++++ .../server/src/__tests__/mocks/index.ts | 3 + .../__tests__/mocks/workspace-user.mock.ts | 26 + 6 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts create mode 100644 packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts create mode 100644 packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 0eb7c78805..232ac8cf73 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -15,8 +15,8 @@ "test:copilot": "ava \"src/__tests__/copilot-*.spec.ts\"", "test:coverage": "c8 ava --concurrency 1 --serial", "test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/copilot-*.spec.ts\"", - "e2e": "cross-env TEST_MODE=e2e ava", - "e2e:coverage": "cross-env TEST_MODE=e2e c8 ava", + "e2e": "cross-env TEST_MODE=e2e ava --serial", + "e2e:coverage": "cross-env TEST_MODE=e2e c8 ava --serial", "data-migration": "cross-env NODE_ENV=development r ./src/data/index.ts", "init": "yarn prisma migrate dev && yarn data-migration run", "seed": "r ./src/seed/index.ts", diff --git a/packages/backend/server/src/__tests__/e2e/app.spec.ts b/packages/backend/server/src/__tests__/e2e/app.spec.ts index 64bb4fdb1a..b66696a97d 100644 --- a/packages/backend/server/src/__tests__/e2e/app.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/app.spec.ts @@ -33,3 +33,11 @@ e2e('should create workspace with owner', async t => { }); t.truthy(workspace); }); + +e2e('should get current user', async t => { + const user = await app.signup(); + await app.switchUser(user); + const res = await app.gql({ query: getCurrentUserQuery }); + t.truthy(res.currentUser); + t.is(res.currentUser!.id, user.id); +}); diff --git a/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts new file mode 100644 index 0000000000..5831a1ead1 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/notification/resolver.spec.ts @@ -0,0 +1,696 @@ +import { randomUUID } from 'node:crypto'; + +import { + DocMode, + listNotificationsQuery, + MentionNotificationBodyType, + mentionUserMutation, + notificationCountQuery, + NotificationObjectType, + NotificationType, + readNotificationMutation, +} from '@affine/graphql'; + +import { Mockers } from '../../mocks'; +import { app, e2e } from '../test'; + +async function init() { + const member = await app.create(Mockers.User); + const owner = await app.create(Mockers.User); + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + name: 'test-workspace-name', + avatarKey: 'test-avatar-key', + }); + + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: member.id, + }); + + return { + member, + owner, + workspace, + }; +} + +e2e('should mention user in a doc', async t => { + const { member, owner, workspace } = await init(); + + await app.login(owner); + const { mentionUser: mentionId } = await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }); + t.truthy(mentionId); + // mention user at another doc + const { mentionUser: mentionId2 } = await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-2', + title: 'doc-title-2', + elementId: 'element-id-2', + mode: DocMode.edgeless, + }, + }, + }, + }); + t.truthy(mentionId2); + + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 0, + }, + }, + }); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ); + t.is(notifications.length, 2); + + const notification = notifications[1] as NotificationObjectType; + t.is(notification.read, false); + t.truthy(notification.createdAt); + t.truthy(notification.updatedAt); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace.id); + t.is(body.doc.id, 'doc-id-1'); + t.is(body.doc.title, 'doc-title-1'); + t.is(body.doc.blockId, 'block-id-1'); + t.is(body.doc.mode, DocMode.page); + t.is(body.createdByUser!.id, owner.id); + t.is(body.createdByUser!.name, owner.name); + t.is(body.workspace!.id, workspace.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.falsy(body.workspace!.avatarUrl); + + const notification2 = notifications[0] as NotificationObjectType; + t.is(notification2.read, false); + t.truthy(notification2.createdAt); + t.truthy(notification2.updatedAt); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.doc.id, 'doc-id-2'); + t.is(body2.doc.title, 'doc-title-2'); + t.is(body2.doc.elementId, 'element-id-2'); + t.is(body2.doc.mode, DocMode.edgeless); + t.is(body2.createdByUser!.id, owner.id); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.workspace!.name, 'test-workspace-name'); + t.falsy(body2.workspace!.avatarUrl); +}); + +e2e('should mention doc mode support string value', async t => { + const { member, owner, workspace } = await init(); + + await app.login(owner); + const { mentionUser: mentionId } = await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: 'page' as DocMode, + }, + }, + }, + }); + t.truthy(mentionId); +}); + +e2e('should throw error when mention user has no Doc.Read role', async t => { + const { owner, workspace } = await init(); + const otherUser = await app.create(Mockers.User); + + await app.login(owner); + const docId = randomUUID(); + await t.throwsAsync( + app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: otherUser.id, + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }), + { + message: `Mentioned user can not access doc ${docId}.`, + } + ); +}); + +e2e('should throw error when mention a not exists user', async t => { + const { owner, workspace } = await init(); + + await app.login(owner); + const docId = randomUUID(); + await t.throwsAsync( + app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: 'user-id-not-exists', + workspaceId: workspace.id, + doc: { + id: docId, + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }), + { + message: `Mentioned user can not access doc ${docId}.`, + } + ); +}); + +e2e('should not mention user oneself', async t => { + const { owner, workspace } = await init(); + + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: owner.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }), + { + message: 'You can not mention yourself.', + } + ); +}); + +e2e('should mark notification as read', async t => { + const { member, owner, workspace } = await init(); + + await app.login(owner); + const { mentionUser: mentionId } = await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }); + t.truthy(mentionId); + + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 0, + }, + }, + }); + t.truthy(mentionId); + + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ); + + for (const notification of notifications) { + t.is(notification.read, false); + await app.gql({ + query: readNotificationMutation, + variables: { + id: notification.id, + }, + }); + } + const count = await app.gql({ + query: notificationCountQuery, + }); + t.is(count.currentUser!.notificationCount, 0); + + // read again should work + for (const notification of notifications) { + t.is(notification.read, false); + await app.gql({ + query: readNotificationMutation, + variables: { + id: notification.id, + }, + }); + } +}); + +e2e('should throw error when read the other user notification', async t => { + const { member, owner, workspace } = await init(); + + await app.login(owner); + const { mentionUser: mentionId } = await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }); + t.truthy(mentionId); + + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 0, + }, + }, + }); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ); + t.is(notifications[0].read, false); + + await app.login(owner); + await t.throwsAsync( + app.gql({ + query: readNotificationMutation, + variables: { + id: notifications[0].id, + }, + }), + { + message: 'Notification not found.', + } + ); + // notification not exists + await t.throwsAsync( + app.gql({ + query: readNotificationMutation, + variables: { + id: 'notification-id-not-exists', + }, + }), + { + message: 'Notification not found.', + } + ); +}); + +e2e('should throw error when mention call with invalid params', async t => { + await app.signup(); + await t.throwsAsync( + app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: '1', + workspaceId: '1', + doc: { + id: '1', + title: 'doc-title-1'.repeat(100), + blockId: '1', + mode: DocMode.page, + }, + }, + }, + }), + { + message: /Validation error/, + } + ); +}); + +e2e('should throw error when mention mode value is invalid', async t => { + await app.signup(); + await t.throwsAsync( + app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: randomUUID(), + workspaceId: randomUUID(), + doc: { + id: randomUUID(), + title: 'doc-title-1', + blockId: 'block-id-1', + mode: 'invalid-mode' as DocMode, + }, + }, + }, + }), + { + message: + 'Variable "$input" got invalid value "invalid-mode" at "input.doc.mode"; Value "invalid-mode" does not exist in "DocMode" enum.', + } + ); +}); + +e2e('should get empty notifications', async t => { + await app.signup(); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 0, + }, + }, + }); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ); + t.is(notifications.length, 0); + t.is(result.currentUser!.notifications.totalCount, 0); +}); + +e2e('should list and count notifications', async t => { + const { member, owner, workspace } = await init(); + await app.login(owner); + await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-1', + title: 'doc-title-1', + blockId: 'block-id-1', + mode: DocMode.page, + }, + }, + }, + }); + await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-2', + title: 'doc-title-2', + blockId: 'block-id-2', + mode: DocMode.page, + }, + }, + }, + }); + await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-3', + title: 'doc-title-3', + blockId: 'block-id-3', + mode: DocMode.page, + }, + }, + }, + }); + // mention user in another workspace + const workspace2 = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + name: 'test-workspace-name2', + avatarKey: 'test-avatar-key2', + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace2.id, + userId: member.id, + }); + await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace2.id, + doc: { + id: 'doc-id-4', + title: 'doc-title-4', + blockId: 'block-id-4', + mode: DocMode.page, + }, + }, + }, + }); + + { + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 0, + }, + }, + }); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 4); + t.is(result.currentUser!.notifications.totalCount, 4); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.type, NotificationType.Mention); + t.is(body.workspace!.name, 'test-workspace-name2'); + t.is(body.workspace!.id, workspace2.id); + t.falsy(body.workspace!.avatarUrl); + t.is(body.doc.id, 'doc-id-4'); + t.is(body.doc.title, 'doc-title-4'); + t.is(body.doc.blockId, 'block-id-4'); + t.is(body.createdByUser!.id, owner.id); + + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.type, NotificationType.Mention); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.doc.id, 'doc-id-3'); + t.is(body2.doc.title, 'doc-title-3'); + t.is(body2.doc.blockId, 'block-id-3'); + t.is(body2.createdByUser!.id, owner.id); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.workspace!.name, 'test-workspace-name'); + t.falsy(body2.workspace!.avatarUrl); + } + + { + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 10, + offset: 2, + }, + }, + }); + t.is(result.currentUser!.notifications.totalCount, 4); + t.is(result.currentUser!.notifications.pageInfo.hasNextPage, false); + t.is(result.currentUser!.notifications.pageInfo.hasPreviousPage, true); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 2); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace.id); + t.is(body.doc.id, 'doc-id-2'); + t.is(body.doc.title, 'doc-title-2'); + t.is(body.doc.blockId, 'block-id-2'); + t.is(body.createdByUser!.id, owner.id); + t.is(body.workspace!.id, workspace.id); + t.is(body.workspace!.name, 'test-workspace-name'); + t.falsy(body.workspace!.avatarUrl); + + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.doc.id, 'doc-id-1'); + t.is(body2.doc.title, 'doc-title-1'); + t.is(body2.doc.blockId, 'block-id-1'); + t.is(body2.createdByUser!.id, owner.id); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.workspace!.name, 'test-workspace-name'); + t.falsy(body2.workspace!.avatarUrl); + } + + { + await app.login(member); + const result = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 2, + offset: 0, + }, + }, + }); + t.is(result.currentUser!.notifications.totalCount, 4); + t.is(result.currentUser!.notifications.pageInfo.hasNextPage, true); + t.is(result.currentUser!.notifications.pageInfo.hasPreviousPage, false); + const notifications = result.currentUser!.notifications.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications.length, 2); + + const notification = notifications[0]; + t.is(notification.read, false); + const body = notification.body as MentionNotificationBodyType; + t.is(body.workspace!.id, workspace2.id); + t.is(body.doc.id, 'doc-id-4'); + t.is(body.doc.title, 'doc-title-4'); + t.is(body.doc.blockId, 'block-id-4'); + t.is(body.createdByUser!.id, owner.id); + t.is(body.workspace!.id, workspace2.id); + t.is(body.workspace!.name, 'test-workspace-name2'); + t.falsy(body.workspace!.avatarUrl); + t.is( + notification.createdAt.toString(), + Buffer.from( + result.currentUser!.notifications.pageInfo.startCursor!, + 'base64' + ).toString('utf-8') + ); + const notification2 = notifications[1]; + t.is(notification2.read, false); + const body2 = notification2.body as MentionNotificationBodyType; + t.is(body2.workspace!.id, workspace.id); + t.is(body2.doc.id, 'doc-id-3'); + t.is(body2.doc.title, 'doc-title-3'); + t.is(body2.doc.blockId, 'block-id-3'); + t.is(body2.createdByUser!.id, owner.id); + t.is(body2.workspace!.id, workspace.id); + t.is(body2.workspace!.name, 'test-workspace-name'); + t.falsy(body2.workspace!.avatarUrl); + + await app.login(owner); + await app.gql({ + query: mentionUserMutation, + variables: { + input: { + userId: member.id, + workspaceId: workspace.id, + doc: { + id: 'doc-id-5', + title: 'doc-title-5', + blockId: 'block-id-5', + mode: DocMode.page, + }, + }, + }, + }); + + // get new notifications + await app.login(member); + const result2 = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 2, + offset: 0, + after: result.currentUser!.notifications.pageInfo.startCursor, + }, + }, + }); + t.is(result2.currentUser!.notifications.totalCount, 5); + t.is(result2.currentUser!.notifications.pageInfo.hasNextPage, false); + t.is(result2.currentUser!.notifications.pageInfo.hasPreviousPage, true); + const notifications2 = result2.currentUser!.notifications.edges.map( + edge => edge.node + ) as NotificationObjectType[]; + t.is(notifications2.length, 1); + + const notification3 = notifications2[0]; + t.is(notification3.read, false); + const body3 = notification3.body as MentionNotificationBodyType; + t.is(body3.workspace!.id, workspace.id); + t.is(body3.doc.id, 'doc-id-5'); + t.is(body3.doc.title, 'doc-title-5'); + t.is(body3.doc.blockId, 'block-id-5'); + t.is(body3.createdByUser!.id, owner.id); + t.is(body3.createdByUser!.name, owner.name); + t.is(body3.workspace!.id, workspace.id); + t.is(body3.workspace!.name, 'test-workspace-name'); + t.falsy(body3.workspace!.avatarUrl); + + // no new notifications + const result3 = await app.gql({ + query: listNotificationsQuery, + variables: { + pagination: { + first: 2, + offset: 0, + after: result2.currentUser!.notifications.pageInfo.startCursor, + }, + }, + }); + t.is(result3.currentUser!.notifications.totalCount, 5); + t.is(result3.currentUser!.notifications.pageInfo.hasNextPage, false); + t.is(result3.currentUser!.notifications.pageInfo.hasPreviousPage, true); + t.is(result3.currentUser!.notifications.pageInfo.startCursor, null); + t.is(result3.currentUser!.notifications.pageInfo.endCursor, null); + t.is(result3.currentUser!.notifications.edges.length, 0); + } +}); diff --git a/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts new file mode 100644 index 0000000000..45f84fa84c --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/workspace/invite.spec.ts @@ -0,0 +1,314 @@ +import { + acceptInviteByInviteIdMutation, + getMembersByWorkspaceIdQuery, + inviteByEmailMutation, + leaveWorkspaceMutation, + revokeMemberPermissionMutation, + WorkspaceMemberStatus, +} from '@affine/graphql'; +import { faker } from '@faker-js/faker'; + +import { Models } from '../../../models'; +import { Mockers } from '../../mocks'; +import { app, e2e } from '../test'; + +e2e('should invite a user', async t => { + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + + const result = await app.gql({ + query: inviteByEmailMutation, + variables: { + email: u2.email, + workspaceId: workspace.id, + }, + }); + t.truthy(result, 'failed to invite user'); + // add invitation notification job + const invitationNotification = app.queue.last('notification.sendInvitation'); + t.is(invitationNotification.payload.inviterId, owner.id); + t.is(invitationNotification.payload.inviteId, result.invite); +}); + +e2e('should leave a workspace', async t => { + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: u2.id, + }); + + app.switchUser(u2.id); + const { leaveWorkspace } = await app.gql({ + query: leaveWorkspaceMutation, + variables: { + workspaceId: workspace.id, + }, + }); + + t.true(leaveWorkspace, 'failed to leave workspace'); +}); + +e2e('should revoke a user', async t => { + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: u2.id, + }); + + const { revoke } = await app.gql({ + query: revokeMemberPermissionMutation, + variables: { + workspaceId: workspace.id, + userId: u2.id, + }, + }); + t.true(revoke, 'failed to revoke user'); +}); + +e2e('should revoke a user on under review', async t => { + const user = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: user.id, + status: WorkspaceMemberStatus.UnderReview, + }); + + const { revoke } = await app.gql({ + query: revokeMemberPermissionMutation, + variables: { + workspaceId: workspace.id, + userId: user.id, + }, + }); + t.true(revoke, 'failed to revoke user'); + const requestDeclinedNotification = app.queue.last( + 'notification.sendInvitationReviewDeclined' + ); + t.truthy(requestDeclinedNotification); + t.deepEqual( + requestDeclinedNotification.payload, + { + userId: user.id, + workspaceId: workspace.id, + reviewerId: owner.id, + }, + 'should send review declined notification' + ); +}); + +e2e('should create user if not exist', async t => { + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + + const email = faker.internet.email(); + await app.gql({ + query: inviteByEmailMutation, + variables: { + email, + workspaceId: workspace.id, + }, + }); + + const u2 = await app.get(Models).user.getUserByEmail(email); + t.truthy(u2, 'failed to create user'); +}); + +e2e('should invite a user by link', async t => { + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + + const invite1 = await app.gql({ + query: inviteByEmailMutation, + variables: { + email: u2.email, + workspaceId: workspace.id, + }, + }); + + app.switchUser(u2); + const accept = await app.gql({ + query: acceptInviteByInviteIdMutation, + variables: { + inviteId: invite1.invite, + workspaceId: workspace.id, + }, + }); + t.true(accept.acceptInviteById, 'failed to accept invite'); + + app.switchUser(owner); + const invite2 = await app.gql({ + query: inviteByEmailMutation, + variables: { + email: u2.email, + workspaceId: workspace.id, + }, + }); + + t.is( + invite2.invite, + invite1.invite, + 'repeat the invitation must return same id' + ); + + const member = await app + .get(Models) + .workspaceUser.getActive(workspace.id, u2.id); + t.truthy(member, 'failed to invite user'); + t.is(member!.id, invite1.invite, 'failed to check invite id'); +}); + +e2e('should send invitation notification and leave email', async t => { + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + const invite = await app.gql({ + query: inviteByEmailMutation, + variables: { + email: u2.email, + workspaceId: workspace.id, + }, + }); + + const invitationNotification = app.queue.last('notification.sendInvitation'); + t.is(invitationNotification.payload.inviterId, owner.id); + t.is(invitationNotification.payload.inviteId, invite.invite); + + app.switchUser(u2); + const accept = await app.gql({ + query: acceptInviteByInviteIdMutation, + variables: { + inviteId: invite.invite, + workspaceId: workspace.id, + }, + }); + t.true(accept.acceptInviteById, 'failed to accept invite'); + + const acceptedNotification = app.queue.last( + 'notification.sendInvitationAccepted' + ); + t.is(acceptedNotification.payload.inviterId, owner.id); + t.is(acceptedNotification.payload.inviteId, invite.invite); + + const leave = await app.gql({ + query: leaveWorkspaceMutation, + variables: { + workspaceId: workspace.id, + sendLeaveMail: true, + }, + }); + t.true(leave.leaveWorkspace, 'failed to leave workspace'); + + const leaveMail = app.mails.last('MemberLeave'); + + t.is(leaveMail.to, owner.email); + t.is(leaveMail.props.user.$$userId, u2.id); +}); + +e2e('should support pagination for member', async t => { + const u1 = await app.signup(); + const u2 = await app.signup(); + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: u1.id, + }); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: u2.id, + }); + + let result = await app.gql({ + query: getMembersByWorkspaceIdQuery, + variables: { + workspaceId: workspace.id, + skip: 0, + take: 2, + }, + }); + t.is(result.workspace.memberCount, 3); + t.is(result.workspace.members.length, 2); + + result = await app.gql({ + query: getMembersByWorkspaceIdQuery, + variables: { + workspaceId: workspace.id, + skip: 2, + take: 2, + }, + }); + t.is(result.workspace.memberCount, 3); + t.is(result.workspace.members.length, 1); + + result = await app.gql({ + query: getMembersByWorkspaceIdQuery, + variables: { + workspaceId: workspace.id, + skip: 3, + take: 2, + }, + }); + t.is(result.workspace.memberCount, 3); + t.is(result.workspace.members.length, 0); +}); + +e2e('should limit member count correctly', async t => { + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + await Promise.allSettled( + Array.from({ length: 10 }).map(async () => { + const user = await app.signup(); + await app.create(Mockers.WorkspaceUser, { + workspaceId: workspace.id, + userId: user.id, + }); + }) + ); + + await app.switchUser(owner); + const result = await app.gql({ + query: getMembersByWorkspaceIdQuery, + variables: { + workspaceId: workspace.id, + skip: 0, + take: 10, + }, + }); + t.is(result.workspace.memberCount, 11); + t.is(result.workspace.members.length, 10); +}); diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts index 6e116cfa7e..162f4d92d8 100644 --- a/packages/backend/server/src/__tests__/mocks/index.ts +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -2,17 +2,20 @@ export { createFactory } from './factory'; export * from './team-workspace.mock'; export * from './user.mock'; export * from './workspace.mock'; +export * from './workspace-user.mock'; import { MockMailer } from './mailer.mock'; import { MockJobQueue } from './queue.mock'; import { MockTeamWorkspace } from './team-workspace.mock'; import { MockUser } from './user.mock'; import { MockWorkspace } from './workspace.mock'; +import { MockWorkspaceUser } from './workspace-user.mock'; export const Mockers = { User: MockUser, Workspace: MockWorkspace, TeamWorkspace: MockTeamWorkspace, + WorkspaceUser: MockWorkspaceUser, }; export { MockJobQueue, MockMailer }; diff --git a/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts b/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts new file mode 100644 index 0000000000..70f43d726b --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/workspace-user.mock.ts @@ -0,0 +1,26 @@ +import type { Prisma, WorkspaceUserRole } from '@prisma/client'; + +import { WorkspaceMemberStatus, WorkspaceRole } from '../../models'; +import { Mocker } from './factory'; + +export type MockWorkspaceUserInput = Omit< + Prisma.WorkspaceUserRoleUncheckedCreateInput, + 'type' +> & { + type?: WorkspaceRole; +}; + +export class MockWorkspaceUser extends Mocker< + MockWorkspaceUserInput, + WorkspaceUserRole +> { + override async create(input: MockWorkspaceUserInput) { + return await this.db.workspaceUserRole.create({ + data: { + type: WorkspaceRole.Collaborator, + status: WorkspaceMemberStatus.Accepted, + ...input, + }, + }); + } +}