test: fix most e2e tests

This commit is contained in:
2026-06-11 10:21:28 +02:00
parent 1671a04f03
commit b630bacdc6
9 changed files with 230 additions and 36 deletions

View File

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

View File

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