Files
openclaw/extensions/matrix/src/matrix/monitor/direct.test.ts
peter eaebed0e38 fix(matrix): only use 2-member DM fallback when dm refresh fails
Issue #54772 reports that two separate 2-person Matrix rooms with the same participants can both get routed as DMs. The regression comes from treating strict 2-member membership as a general fallback even when the DM cache is available and says the room is not a DM.

Move the catch from refreshDmCache() into isDirectMessage(). When the refresh succeeds, keep the existing client.dms.isDm(roomId) && isStrictDirectMembership(...) gate so non-DM 2-person rooms stay grouped. Only when the refresh fails do we fall back to the exact 2-member heuristic and keep the warning log.
2026-03-27 19:07:42 -04:00

196 lines
6.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { createDirectRoomTracker } from "./direct.js";
function createMockClient(params: { isDm?: boolean; members?: string[] }) {
let members = params.members ?? ["@alice:example.org", "@bot:example.org"];
return {
dms: {
update: vi.fn().mockResolvedValue(undefined),
isDm: vi.fn().mockReturnValue(params.isDm === true),
},
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
getJoinedRoomMembers: vi.fn().mockImplementation(async () => members),
__setMembers(next: string[]) {
members = next;
},
} as unknown as MatrixClient & {
dms: {
update: ReturnType<typeof vi.fn>;
isDm: ReturnType<typeof vi.fn>;
};
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
__setMembers: (members: string[]) => void;
};
}
describe("createDirectRoomTracker", () => {
afterEach(() => {
vi.useRealTimers();
});
it("treats m.direct rooms as DMs", async () => {
const client = createMockClient({ isDm: true });
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("does not trust stale m.direct classifications for shared rooms", async () => {
const client = createMockClient({
isDm: true,
members: ["@alice:example.org", "@bot:example.org", "@extra:example.org"],
});
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("does not classify 2-member rooms as DMs when the dm cache refresh succeeds", async () => {
const client = createMockClient({ isDm: false });
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("falls back to strict 2-member membership when dm cache refresh fails", async () => {
const client = createMockClient({ isDm: false });
client.dms.update.mockRejectedValue(new Error("dm cache unavailable"));
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("does not classify rooms with extra members as DMs when falling back", async () => {
const client = createMockClient({
isDm: false,
members: ["@alice:example.org", "@bot:example.org", "@observer:example.org"],
});
client.dms.update.mockRejectedValue(new Error("dm cache unavailable"));
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
});
it("does not classify 2-member rooms whose sender is not a joined member when falling back", async () => {
const client = createMockClient({
isDm: false,
members: ["@mallory:example.org", "@bot:example.org"],
});
client.dms.update.mockRejectedValue(new Error("dm cache unavailable"));
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
});
it("re-checks room membership after invalidation when fallback membership changes", async () => {
const client = createMockClient({ isDm: false });
client.dms.update.mockRejectedValue(new Error("dm cache unavailable"));
const tracker = createDirectRoomTracker(client);
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
client.__setMembers(["@alice:example.org", "@bot:example.org", "@mallory:example.org"]);
tracker.invalidateRoom("!room:example.org");
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
});
it("bounds joined-room membership cache size", async () => {
const client = createMockClient({ isDm: false });
client.dms.update.mockRejectedValue(new Error("dm cache unavailable"));
const tracker = createDirectRoomTracker(client);
for (let i = 0; i <= 1024; i += 1) {
await tracker.isDirectMessage({
roomId: `!room-${i}:example.org`,
senderId: "@alice:example.org",
});
}
await tracker.isDirectMessage({
roomId: "!room-0:example.org",
senderId: "@alice:example.org",
});
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(1026);
});
it("refreshes dm and membership caches after the ttl expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-12T10:00:00Z"));
const client = createMockClient({ isDm: true });
const tracker = createDirectRoomTracker(client);
await tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
});
await tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
});
expect(client.dms.update).toHaveBeenCalledTimes(1);
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date("2026-03-12T10:00:31Z"));
await tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
});
expect(client.dms.update).toHaveBeenCalledTimes(2);
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(2);
});
});