test: fix most e2e tests
This commit is contained in:
@@ -137,6 +137,39 @@ describe('PluginClientApiService', () => {
|
||||
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an edit issued immediately after sending a message', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const sent = api.messages.send('Plugin API original message');
|
||||
|
||||
api.messages.edit(sent.id, 'Plugin API edited message');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const editDispatched = context.store.dispatch.mock.calls.some(
|
||||
([action]) => action.type === MessagesActions.editMessageSuccess.type
|
||||
&& action.messageId === sent.id
|
||||
&& action.content === 'Plugin API edited message'
|
||||
);
|
||||
|
||||
expect(editDispatched).toBe(true);
|
||||
});
|
||||
|
||||
it('applies a delete issued immediately after sending a message', async () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
const sent = api.messages.send('Plugin API deleted message');
|
||||
|
||||
api.messages.delete(sent.id);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const deleteDispatched = context.store.dispatch.mock.calls.some(
|
||||
([action]) => action.type === MessagesActions.deleteMessageSuccess.type
|
||||
&& action.messageId === sent.id
|
||||
);
|
||||
|
||||
expect(deleteDispatched).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes typing state through the realtime facade', () => {
|
||||
const api = context.service.createApi(TEST_MANIFEST);
|
||||
|
||||
@@ -287,6 +320,12 @@ interface ServiceTestContext {
|
||||
createSignedRevision: ReturnType<typeof vi.fn>;
|
||||
persistRevision: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
db: {
|
||||
getMessageById: ReturnType<typeof vi.fn>;
|
||||
saveMessage: ReturnType<typeof vi.fn>;
|
||||
updateMessage: ReturnType<typeof vi.fn>;
|
||||
updateRoom: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
function createServiceTestContext(): ServiceTestContext {
|
||||
@@ -354,6 +393,15 @@ function createServiceTestContext(): ServiceTestContext {
|
||||
onSignalingMessage: new Subject<unknown>(),
|
||||
sendRawMessage: vi.fn()
|
||||
};
|
||||
const messageDb = new Map<string, Message>();
|
||||
const db = {
|
||||
getMessageById: vi.fn(async (id: string) => messageDb.get(id) ?? null),
|
||||
saveMessage: vi.fn(async (message: Message) => {
|
||||
messageDb.set(message.id, message);
|
||||
}),
|
||||
updateMessage: vi.fn(async () => undefined),
|
||||
updateRoom: vi.fn(async () => undefined)
|
||||
};
|
||||
const store = {
|
||||
dispatch: vi.fn(),
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
@@ -401,12 +449,7 @@ function createServiceTestContext(): ServiceTestContext {
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: {
|
||||
getMessageById: vi.fn(async () => null),
|
||||
saveMessage: vi.fn(async () => undefined),
|
||||
updateMessage: vi.fn(async () => undefined),
|
||||
updateRoom: vi.fn(async () => undefined)
|
||||
}
|
||||
useValue: db
|
||||
},
|
||||
{
|
||||
provide: MessageRevisionService,
|
||||
@@ -485,7 +528,8 @@ function createServiceTestContext(): ServiceTestContext {
|
||||
store,
|
||||
uiRegistry,
|
||||
voice,
|
||||
messageRevisions
|
||||
messageRevisions,
|
||||
db
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,15 @@ export class PluginClientApiService {
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly messageRevisions = inject(MessageRevisionService);
|
||||
|
||||
/**
|
||||
* Serializes plugin-initiated message create/edit/delete operations. `send`
|
||||
* returns its draft synchronously while the create persists asynchronously, so
|
||||
* an edit/delete issued on the returned id must wait for that create to land in
|
||||
* the database - otherwise its `getMessageById` lookup races ahead of the
|
||||
* pending `saveMessage` and the mutation is silently dropped.
|
||||
*/
|
||||
private messageMutationChain: Promise<void> = Promise.resolve();
|
||||
|
||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
|
||||
@@ -630,7 +639,7 @@ export class PluginClientApiService {
|
||||
}
|
||||
|
||||
private deletePluginMessage(pluginId: string, messageId: string): void {
|
||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
this.enqueueMessageMutation(() => this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
type: 'plugin-delete',
|
||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||
message: existing,
|
||||
@@ -647,11 +656,11 @@ export class PluginClientApiService {
|
||||
type: 'message-deleted'
|
||||
}),
|
||||
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
this.enqueueMessageMutation(() => this.emitPluginMessageMutation(pluginId, messageId, {
|
||||
type: 'plugin-edit',
|
||||
content,
|
||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||
@@ -674,7 +683,7 @@ export class PluginClientApiService {
|
||||
editedAt: message.editedAt ?? Date.now(),
|
||||
messageId
|
||||
})
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
||||
@@ -694,7 +703,7 @@ export class PluginClientApiService {
|
||||
revision: 0
|
||||
};
|
||||
|
||||
void this.emitPluginMessageRevision(pluginId, {
|
||||
this.enqueueMessageMutation(() => this.emitPluginMessageRevision(pluginId, {
|
||||
draftMessage,
|
||||
type: 'create',
|
||||
actorId: currentUser?.id ?? pluginId,
|
||||
@@ -702,11 +711,22 @@ export class PluginClientApiService {
|
||||
pluginId,
|
||||
sign: !!currentUser,
|
||||
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
|
||||
});
|
||||
}));
|
||||
|
||||
return draftMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chains a plugin message operation onto the serial mutation queue so create,
|
||||
* edit, and delete calls apply in the order the plugin issued them, regardless of
|
||||
* how their individual async persistence steps would otherwise interleave.
|
||||
*/
|
||||
private enqueueMessageMutation(task: () => Promise<void>): void {
|
||||
this.messageMutationChain = this.messageMutationChain
|
||||
.then(() => task())
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private async emitPluginMessageRevision(
|
||||
pluginId: string,
|
||||
input: {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { SignalingTransportHandler, ResolvedSignalCredential } from './signaling-transport-handler';
|
||||
import { SIGNALING_TYPE_IDENTIFY } from '../realtime.constants';
|
||||
|
||||
describe('SignalingTransportHandler identify', () => {
|
||||
const SIGNAL_URL = 'ws://signal.example.com:3001';
|
||||
|
||||
function createHandler(resolved: ResolvedSignalCredential | null) {
|
||||
const sentMessages: Record<string, unknown>[] = [];
|
||||
const manager = {
|
||||
isSocketOpen: () => true,
|
||||
sendRawMessage: (message: Record<string, unknown>) => {
|
||||
sentMessages.push(message);
|
||||
}
|
||||
};
|
||||
const coordinator = {
|
||||
getConnectedSignalingManagers: () => [{ signalUrl: SIGNAL_URL, manager }],
|
||||
getSignalingManager: (signalUrl: string) => (signalUrl === SIGNAL_URL ? manager : null)
|
||||
} as unknown as ConstructorParameters<typeof SignalingTransportHandler>[0]['signalingCoordinator'];
|
||||
const handler = new SignalingTransportHandler<unknown>({
|
||||
signalingCoordinator: coordinator,
|
||||
logger: { warn: () => undefined, error: () => undefined, info: () => undefined } as never,
|
||||
getLocalPeerId: () => 'local-peer',
|
||||
resolveCredential: () => resolved,
|
||||
getHomeCredential: () => resolved,
|
||||
getClientInstanceId: () => 'device-a'
|
||||
});
|
||||
|
||||
return { handler, sentMessages };
|
||||
}
|
||||
|
||||
it('uses the freshly supplied display name over the stale stored credential', () => {
|
||||
const { handler, sentMessages } = createHandler({
|
||||
userId: 'user-1',
|
||||
token: 'token-1',
|
||||
displayName: 'Alice'
|
||||
});
|
||||
|
||||
handler.identify('user-1', 'Alice One', undefined, {
|
||||
description: 'New bio',
|
||||
profileUpdatedAt: 123456
|
||||
});
|
||||
|
||||
const identifyMessages = sentMessages.filter((entry) => entry['type'] === SIGNALING_TYPE_IDENTIFY);
|
||||
|
||||
expect(identifyMessages.length).toBeGreaterThan(0);
|
||||
|
||||
for (const message of identifyMessages) {
|
||||
expect(message['displayName']).toBe('Alice One');
|
||||
expect(message['profileUpdatedAt']).toBe(123456);
|
||||
expect(message['description']).toBe('New bio');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the stored credential display name when none is supplied', () => {
|
||||
const { handler, sentMessages } = createHandler({
|
||||
userId: 'user-1',
|
||||
token: 'token-1',
|
||||
displayName: 'Alice'
|
||||
});
|
||||
|
||||
handler.identify('user-1', ' ', undefined);
|
||||
|
||||
const identifyMessages = sentMessages.filter((entry) => entry['type'] === SIGNALING_TYPE_IDENTIFY);
|
||||
|
||||
expect(identifyMessages.length).toBeGreaterThan(0);
|
||||
|
||||
for (const message of identifyMessages) {
|
||||
expect(message['displayName']).toBe('Alice');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
signalUrl?: string,
|
||||
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt' | 'homeSignalServerUrl'>
|
||||
): void {
|
||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const normalizedDescription = typeof profile?.description === 'string'
|
||||
? (profile.description.trim() || undefined)
|
||||
: undefined;
|
||||
@@ -243,7 +243,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
if (signalUrl) {
|
||||
this.identifyOnSignalUrl(signalUrl, {
|
||||
fallbackOderId: oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
displayName: trimmedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
@@ -262,7 +262,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
|
||||
const credentials = this.identifyOnSignalUrl(managerSignalUrl, {
|
||||
fallbackOderId: oderId,
|
||||
displayName: normalizedDisplayName,
|
||||
displayName: trimmedDisplayName,
|
||||
description: normalizedDescription,
|
||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||
@@ -312,7 +312,7 @@ export class SignalingTransportHandler<TMessage> {
|
||||
const credentials: IdentifyCredentials = {
|
||||
oderId: resolved.userId,
|
||||
token: resolved.token,
|
||||
displayName: resolved.displayName || params.displayName,
|
||||
displayName: params.displayName || resolved.displayName || DEFAULT_DISPLAY_NAME,
|
||||
description: params.description,
|
||||
profileUpdatedAt: params.profileUpdatedAt,
|
||||
homeSignalServerUrl: params.homeSignalServerUrl ?? resolved.homeSignalServerUrl,
|
||||
|
||||
@@ -79,4 +79,38 @@ describe('user avatar sync helpers', () => {
|
||||
updatedAt: 100
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('requests data when only the remote profile text is newer and no avatar exists', () => {
|
||||
const existingUser = createUser({
|
||||
displayName: 'Alice',
|
||||
profileUpdatedAt: 100
|
||||
});
|
||||
|
||||
expect(shouldRequestAvatarData(existingUser, {
|
||||
avatarUpdatedAt: 0,
|
||||
profileUpdatedAt: 200
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('does not request profile data when the local profile is the same or newer', () => {
|
||||
const existingUser = createUser({ profileUpdatedAt: 200 });
|
||||
|
||||
expect(shouldRequestAvatarData(existingUser, {
|
||||
avatarUpdatedAt: 0,
|
||||
profileUpdatedAt: 200
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('applies profile-only transfers when the remote profile is newer', () => {
|
||||
const existingUser = createUser({
|
||||
displayName: 'Alice',
|
||||
profileUpdatedAt: 100
|
||||
});
|
||||
|
||||
expect(shouldApplyAvatarTransfer(existingUser, {
|
||||
hash: undefined,
|
||||
updatedAt: 0,
|
||||
profileUpdatedAt: 200
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ interface PendingAvatarTransfer {
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'> | undefined;
|
||||
type RoomProfileState = Pick<User,
|
||||
| 'id'
|
||||
| 'oderId'
|
||||
@@ -80,22 +80,31 @@ function shouldAcceptAvatarPayload(
|
||||
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
||||
}
|
||||
|
||||
function shouldAcceptProfilePayload(
|
||||
existingUser: Pick<User, 'profileUpdatedAt'> | undefined,
|
||||
incomingProfileUpdatedAt: number | undefined
|
||||
): boolean {
|
||||
return (incomingProfileUpdatedAt ?? 0) > (existingUser?.profileUpdatedAt ?? 0);
|
||||
}
|
||||
|
||||
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
||||
return (user?.avatarUpdatedAt ?? 0) > 0;
|
||||
return (user?.avatarUpdatedAt ?? 0) > 0 || (user?.profileUpdatedAt ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function shouldRequestAvatarData(
|
||||
existingUser: AvatarVersionState,
|
||||
incomingAvatar: Pick<ChatEvent, 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>
|
||||
): boolean {
|
||||
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash);
|
||||
return shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash)
|
||||
|| shouldAcceptProfilePayload(existingUser, incomingAvatar.profileUpdatedAt);
|
||||
}
|
||||
|
||||
export function shouldApplyAvatarTransfer(
|
||||
existingUser: AvatarVersionState,
|
||||
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
|
||||
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt' | 'profileUpdatedAt'>
|
||||
): boolean {
|
||||
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
|
||||
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash)
|
||||
|| shouldAcceptProfilePayload(existingUser, transfer.profileUpdatedAt);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -258,17 +267,18 @@ export class UserAvatarEffects {
|
||||
)
|
||||
);
|
||||
|
||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
|
||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>): ChatEvent {
|
||||
return {
|
||||
type: 'user-avatar-summary',
|
||||
oderId: user.oderId || user.id,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||
profileUpdatedAt: user.profileUpdatedAt || 0
|
||||
};
|
||||
}
|
||||
|
||||
private handleAvatarSummary(event: ChatEvent, allUsers: User[], currentUser: User | null) {
|
||||
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
|
||||
if (!event.fromPeerId || !event.oderId || (!event.avatarUpdatedAt && !event.profileUpdatedAt)) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -467,6 +477,8 @@ export class UserAvatarEffects {
|
||||
oderId: userKey,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
description: user.description,
|
||||
profileUpdatedAt: user.profileUpdatedAt,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||
|
||||
@@ -178,6 +178,7 @@ function buildInactiveCameraState(user: User): User['cameraState'] {
|
||||
}
|
||||
|
||||
function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User {
|
||||
|
||||
const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds);
|
||||
const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true;
|
||||
const status = isOnline
|
||||
|
||||
Reference in New Issue
Block a user