Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions spec/unit/models/event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model
import { emitPromise } from "../../test-utils/test-utils";
import {
type IAnnotatedPushRule,
type IStickyEvent,
type MatrixClient,
PushRuleActionName,
Room,
Expand Down Expand Up @@ -598,6 +599,39 @@ describe("MatrixEvent", () => {
expect(stateEvent.isState()).toBeTruthy();
expect(stateEvent.threadRootId).toBeUndefined();
});

it("should calculate sticky duration correctly", async () => {
const evData: IStickyEvent = {
event_id: "$event_id",
type: "some_state_event",
content: {},
sender: "@alice:example.org",
origin_server_ts: 50,
msc4354_sticky: {
duration_ms: 1000,
},
unsigned: {
msc4354_sticky_duration_ttl_ms: 5000,
},
};
try {
jest.useFakeTimers();
jest.setSystemTime(50);
// Prefer unsigned
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
// Fall back to `duration_ms`
expect(
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
).toEqual(1050);
// Prefer current time if `origin_server_ts` is more recent.
expect(
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
.unstableStickyExpiresAt,
).toEqual(1050);
} finally {
jest.useRealTimers();
}
});
});

function mainTimelineLiveEventIds(room: Room): Array<string> {
Expand Down
262 changes: 262 additions & 0 deletions spec/unit/models/room-sticky-events.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { type IStickyEvent, MatrixEvent } from "../../../src";
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";

describe("RoomStickyEvents", () => {
let stickyEvents: RoomStickyEventsStore;
const emitSpy: jest.Mock = jest.fn();
const stickyEvent: IStickyEvent = {
event_id: "$foo:bar",
room_id: "!roomId",
type: "org.example.any_type",
msc4354_sticky: {
duration_ms: 15000,
},
content: {
msc4354_sticky_key: "foobar",
},
sender: "@alice:example.org",
origin_server_ts: Date.now(),
unsigned: {},
};

beforeEach(() => {
emitSpy.mockReset();
stickyEvents = new RoomStickyEventsStore();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
});

afterEach(() => {
stickyEvents?.clear();
});

describe("addStickyEvents", () => {
it("should allow adding an event without a msc4354_sticky_key", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
});
it("should not allow adding an event without a msc4354_sticky property", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event without a sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event with an invalid sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should ignore old events", () => {
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should be able to just add an event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace events on ID tie break", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
event_id: "$abc:bar",
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace a newer event with an older event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
origin_server_ts: 1,
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should replace an older event with a newer event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$new",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([newerEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
});
it("should allow multiple events with the same sticky key for different event types", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
const anotherEv = new MatrixEvent({
...stickyEvent,
type: "org.example.another_type",
});
stickyEvents.addStickyEvents([originalEv, anotherEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
});

it("should emit when a new sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
it("should emit when a new unkeyed sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
});

describe("getStickyEvents", () => {
it("should have zero sticky events", () => {
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should contain a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
});
it("should contain two sticky events", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
const ev2 = new MatrixEvent({
...stickyEvent,
sender: "@fibble:bobble",
content: {
msc4354_sticky_key: "bibble",
},
});
stickyEvents.addStickyEvents([ev, ev2]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
});
});

describe("getKeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toBeUndefined();
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toEqual(ev);
});
});

describe("getUnkeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
content: {
msc4354_sticky_key: undefined,
},
});
stickyEvents.addStickyEvents([ev]);
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
});
});

describe("cleanExpiredStickyEvents", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});

it("should emit when a sticky event expires", () => {
jest.setSystemTime(1000);
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: 0,
});
const evLater = new MatrixEvent({
...stickyEvent,
event_id: "$baz:bar",
sender: "@bob:example.org",
origin_server_ts: 1000,
});
stickyEvents.addStickyEvents([ev, evLater]);
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
// Then expire the next event
jest.advanceTimersByTime(1000);
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
});
it("should emit two events when both expire at the same time", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev1 = new MatrixEvent({
...stickyEvent,
event_id: "$eventA",
origin_server_ts: 0,
});
const ev2 = new MatrixEvent({
...stickyEvent,
event_id: "$eventB",
content: {
msc4354_sticky_key: "key_2",
},
origin_server_ts: 0,
});
stickyEvents.addStickyEvents([ev1, ev2]);
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
});
it("should emit when a unkeyed sticky event expires", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
});
});
62 changes: 62 additions & 0 deletions spec/unit/sync-accumulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ILeftRoom,
type IRoomEvent,
type IStateEvent,
type IStickyEvent,
type IStrippedState,
type ISyncResponse,
SyncAccumulator,
Expand Down Expand Up @@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
);
});
});

describe("MSC4354 sticky events", () => {
function stickyEvent(ts = 0): IStickyEvent {
const msgData = msg("test", "test text");
return {
...msgData,
msc4354_sticky: {
duration_ms: 1000,
},
origin_server_ts: ts,
};
}

beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it("should accumulate sticky events", () => {
jest.setSystemTime(0);
const ev = stickyEvent();
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
});
it("should clear stale sticky events", () => {
jest.setSystemTime(1000);
const ev = stickyEvent(1000);
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
jest.setSystemTime(2000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});

it("clears stale sticky events that pretend to be from the distant future", () => {
jest.setSystemTime(0);
const eventFarInTheFuture = stickyEvent(999999999999);
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
eventFarInTheFuture,
]);
jest.setSystemTime(1000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
});
});

function syncSkeleton(
Expand Down
Loading