feat(core): integrate realtime features (#15003)

This commit is contained in:
DarkSky
2026-05-20 05:48:03 +08:00
committed by GitHub
parent 3e42bbf4fa
commit 9f33d37add
108 changed files with 3069 additions and 2729 deletions
@@ -5,7 +5,6 @@ import {
listNotificationsQuery,
MentionNotificationBodyType,
mentionUserMutation,
notificationCountQuery,
NotificationObjectType,
NotificationType,
readAllNotificationsMutation,
@@ -13,6 +12,7 @@ import {
} from '@affine/graphql';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
async function init() {
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
},
});
}
const count = await app.gql({
query: notificationCountQuery,
});
t.is(count.currentUser!.notificationCount, 0);
const socket = await createRealtimeClient(app, member);
t.teardown(() => socket.disconnect());
const count = await realtimeRequest(socket, 'notification.count.get', {});
t.is(count.count, 0);
// read again should work
for (const notification of notifications) {
@@ -0,0 +1,92 @@
import type {
RealtimeAck,
RealtimeRequestInputOf,
RealtimeRequestName,
RealtimeRequestOutputOf,
} from '@affine/realtime';
import { io, type Socket as SocketIOClient } from 'socket.io-client';
import type { Response } from 'supertest';
import type { MockedUser } from '../mocks';
import type { TestingApp } from './create-app';
const REALTIME_CLIENT_VERSION = '0.26.0';
const WS_TIMEOUT_MS = 5_000;
function cookieHeader(res: Response) {
return (res.get('Set-Cookie') ?? [])
.map(cookie => cookie.split(';')[0])
.join('; ');
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
) {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
}, timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForConnect(socket: SocketIOClient) {
if (socket.connected) {
return;
}
await withTimeout(
new Promise<void>((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
}),
WS_TIMEOUT_MS,
'realtime socket connect'
);
}
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
const login = await app.login(user);
const socket = io(app.url, {
transports: ['websocket'],
reconnection: false,
forceNew: true,
extraHeaders: {
cookie: cookieHeader(login),
},
});
await waitForConnect(socket);
return socket;
}
export async function realtimeRequest<Op extends RealtimeRequestName>(
socket: SocketIOClient,
op: Op,
input: RealtimeRequestInputOf<Op>
): Promise<RealtimeRequestOutputOf<Op>> {
const ack = await withTimeout(
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
socket.emit(
'realtime:request',
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
);
}),
WS_TIMEOUT_MS,
`realtime request ${op}`
);
if ('error' in ack) {
throw new Error(`${ack.error.name}: ${ack.error.message}`);
}
return ack.data;
}
@@ -5,7 +5,6 @@ import {
approveWorkspaceTeamMemberMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
revokeMemberPermissionMutation,
@@ -13,16 +12,21 @@ import {
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
} from '@prisma/client';
import { EntitlementService } from '../../../core/entitlement';
import { WorkspacePolicyService } from '../../../core/permission';
import { Models } from '../../../models';
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { createRealtimeClient, realtimeRequest } from '../realtime';
import { app, e2e } from '../test';
const TWO_BILLION_BYTES = 2_000_000_000;
@@ -380,39 +384,31 @@ e2e('should support pagination for member', async t => {
userId: u2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
t.is(result.memberCount, 3);
t.is(result.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 2,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 3,
take: 2,
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
t.is(result.memberCount, 3);
t.is(result.members.length, 0);
});
e2e('should limit member count correctly', async t => {
@@ -428,17 +424,15 @@ e2e('should limit member count correctly', async t => {
})
);
await app.login(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
const result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
skip: 0,
take: 10,
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
t.is(result.memberCount, 11);
t.is(result.members.length, 10);
});
e2e('should get invite link info with status', async t => {
@@ -634,38 +628,54 @@ e2e(
userId: user2.id,
});
await app.login(owner);
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'lucy',
},
const socket = await createRealtimeClient(app, owner);
t.teardown(() => socket.disconnect());
let result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'lucy',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'LUCY',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'LUCY',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].name, user1.name);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].name, user1.name);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
query: 'jeanne_doe',
},
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'jeanne_doe',
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
t.is(result.workspace.members[0].email, user2.email);
t.is(result.memberCount, 3);
t.is(result.members.length, 1);
t.is(result.members[0].email, user2.email);
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
const pendingUser = await app.create(Mockers.User, {
email: pendingEmail,
});
await app
.get(Models)
.workspaceUser.set(
workspace.id,
pendingUser.id,
ModelWorkspaceRole.Collaborator,
{
status: PrismaWorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
}
);
result = await realtimeRequest(socket, 'workspace.members.get', {
workspaceId: workspace.id,
query: 'pending_search',
});
t.is(result.memberCount, 4);
t.is(result.members.length, 1);
t.is(result.members[0].email, pendingEmail);
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
}
);