Skip to content

Commit

Permalink
Poll model - page /relations results (#3073)
Browse files Browse the repository at this point in the history
* first cut poll model

* process incoming poll relations

* allow alt event types in relations model

* allow alt event types in relations model

* remove unneccesary checks on remove relation

* comment

* Revert "allow alt event types in relations model"

This reverts commit e578d84.

* Revert "Revert "allow alt event types in relations model""

This reverts commit 515db7a.

* basic handling for new poll relations

* tests

* test room.processPollEvents

* join processBeaconEvents and poll events in client

* tidy and set 23 copyrights

* use rooms instance of matrixClient

* tidy

* more copyright

* simplify processPollEvent code

* throw when poll start event has no roomId

* updates for events-sdk move

* more type changes for events-sdk changes

* page poll relation results

* validate poll end event senders

* reformatted copyright

* undo more comment reformatting

* test paging

* use correct pollstartevent type

* emit after updating _isFetchingResponses state

* make rootEvent public readonly

* fix poll end validation logic to allow poll creator to end poll regardless of redaction
  • Loading branch information
Kerry authored Feb 1, 2023
1 parent 2800681 commit 4e8affa
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 32 deletions.
151 changes: 137 additions & 14 deletions spec/unit/models/poll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("Poll", () => {
jest.clearAllMocks();
jest.setSystemTime(now);

mockClient.relations.mockResolvedValue({ events: [] });
mockClient.relations.mockReset().mockResolvedValue({ events: [] });

maySendRedactionForEventSpy.mockClear().mockReturnValue(true);
});
Expand Down Expand Up @@ -95,8 +95,17 @@ describe("Poll", () => {
it("calls relations api and emits", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
const emitSpy = jest.spyOn(poll, "emit");
const responses = await poll.getResponses();
expect(mockClient.relations).toHaveBeenCalledWith(roomId, basePollStartEvent.getId(), "m.reference");
const fetchResponsePromise = poll.getResponses();
expect(poll.isFetchingResponses).toBe(true);
const responses = await fetchResponsePromise;
expect(poll.isFetchingResponses).toBe(false);
expect(mockClient.relations).toHaveBeenCalledWith(
roomId,
basePollStartEvent.getId(),
"m.reference",
undefined,
{ from: undefined },
);
expect(emitSpy).toHaveBeenCalledWith(PollEvent.Responses, responses);
});

Expand Down Expand Up @@ -133,6 +142,48 @@ describe("Poll", () => {
expect(responses.getRelations()).toEqual([stableResponseEvent, unstableResponseEvent]);
});

describe("with multiple pages of relations", () => {
const makeResponses = (count = 1, timestamp = now): MatrixEvent[] =>
new Array(count)
.fill("x")
.map((_x, index) =>
makeRelatedEvent(
{ type: M_POLL_RESPONSE.stable!, sender: "@bob@server.org" },
timestamp + index,
),
);

it("page relations responses", async () => {
const responseEvents = makeResponses(6);
mockClient.relations
.mockResolvedValueOnce({
events: responseEvents.slice(0, 2),
nextBatch: "test-next-1",
})
.mockResolvedValueOnce({
events: responseEvents.slice(2, 4),
nextBatch: "test-next-2",
})
.mockResolvedValueOnce({
events: responseEvents.slice(4),
});

const poll = new Poll(basePollStartEvent, mockClient, room);
jest.spyOn(poll, "emit");
const responses = await poll.getResponses();

expect(mockClient.relations.mock.calls).toEqual([
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: undefined }],
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-1" }],
[roomId, basePollStartEvent.getId(), "m.reference", undefined, { from: "test-next-2" }],
]);

expect(poll.emit).toHaveBeenCalledTimes(3);
expect(poll.isFetchingResponses).toBeFalsy();
expect(responses.getRelations().length).toEqual(6);
});
});

describe("with poll end event", () => {
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@bob@server.org" });
const unstablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.unstable!, sender: "@bob@server.org" });
Expand All @@ -156,17 +207,6 @@ describe("Poll", () => {
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
});

it("does not set poll end event when sent by a user without redaction rights", async () => {
const poll = new Poll(basePollStartEvent, mockClient, room);
maySendRedactionForEventSpy.mockReturnValue(false);
jest.spyOn(poll, "emit");
await poll.getResponses();

expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@bob@server.org");
expect(poll.isEnded).toBe(false);
expect(poll.emit).not.toHaveBeenCalledWith(PollEvent.End);
});

it("sets poll end event when endevent sender also created the poll, but does not have redaction rights", async () => {
const pollStartEvent = new MatrixEvent({
...PollStartEvent.from("What?", ["a", "b"], M_POLL_KIND_DISCLOSED.name).serialize(),
Expand Down Expand Up @@ -316,6 +356,89 @@ describe("Poll", () => {
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
});

it("does not set poll end event when sent by invalid user", async () => {
maySendRedactionForEventSpy.mockReturnValue(false);
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: "@charlie:server.org" });
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd],
});
const poll = new Poll(basePollStartEvent, mockClient, room);
await poll.getResponses();
jest.spyOn(poll, "emit");

poll.onNewRelation(stablePollEndEvent);

// didn't end, didn't refilter responses
expect(poll.emit).not.toHaveBeenCalled();
expect(poll.isEnded).toBeFalsy();
expect(maySendRedactionForEventSpy).toHaveBeenCalledWith(basePollStartEvent, "@charlie:server.org");
});

it("does not set poll end event when an earlier end event already exists", async () => {
const earlierPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now,
);
const laterPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now + 2000,
);

const poll = new Poll(basePollStartEvent, mockClient, room);
await poll.getResponses();

poll.onNewRelation(earlierPollEndEvent);

// first end event set correctly
expect(poll.isEnded).toBeTruthy();

// reset spy count
jest.spyOn(poll, "emit").mockClear();

poll.onNewRelation(laterPollEndEvent);
// didn't set new end event, didn't refilter responses
expect(poll.emit).not.toHaveBeenCalled();
expect(poll.isEnded).toBeTruthy();
});

it("replaces poll end event and refilters when an older end event already exists", async () => {
const earlierPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now,
);
const laterPollEndEvent = makeRelatedEvent(
{ type: M_POLL_END.stable!, sender: "@valid:server.org" },
now + 2000,
);
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
const responseEventAtEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now);
const responseEventAfterEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now + 1000);
mockClient.relations.mockResolvedValue({
events: [responseEventAfterEnd, responseEventAtEnd, responseEventBeforeEnd, laterPollEndEvent],
});

const poll = new Poll(basePollStartEvent, mockClient, room);
const responses = await poll.getResponses();

// all responses have a timestamp < laterPollEndEvent
expect(responses.getRelations().length).toEqual(3);
// first end event set correctly
expect(poll.isEnded).toBeTruthy();

// reset spy count
jest.spyOn(poll, "emit").mockClear();

// add a valid end event with earlier timestamp
poll.onNewRelation(earlierPollEndEvent);

// emitted new end event
expect(poll.emit).toHaveBeenCalledWith(PollEvent.End);
// filtered responses and emitted
expect(poll.emit).toHaveBeenCalledWith(PollEvent.Responses, responses);
expect(responses.getRelations()).toEqual([responseEventAtEnd, responseEventBeforeEnd]);
});

it("sets poll end event and refilters responses based on timestamp", async () => {
const stablePollEndEvent = makeRelatedEvent({ type: M_POLL_END.stable!, sender: userId });
const responseEventBeforeEnd = makeRelatedEvent({ type: M_POLL_RESPONSE.name }, now - 1000);
Expand Down
68 changes: 50 additions & 18 deletions src/models/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { M_POLL_END, M_POLL_RESPONSE, PollStartEvent } from "../@types/polls";
import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
import { MatrixClient } from "../client";
import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
import { MatrixEvent } from "./event";
import { Relations } from "./relations";
import { Room } from "./room";
Expand Down Expand Up @@ -61,11 +62,12 @@ const filterResponseRelations = (
export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> {
public readonly roomId: string;
public readonly pollEvent: PollStartEvent;
private fetchingResponsesPromise: null | Promise<void> = null;
private _isFetchingResponses = false;
private relationsNextBatch: string | undefined;
private responses: null | Relations = null;
private endEvent: MatrixEvent | undefined;

public constructor(private rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
super();
if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
throw new Error("Invalid poll start event.");
Expand All @@ -82,16 +84,23 @@ export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, P
return !!this.endEvent;
}

public get isFetchingResponses(): boolean {
return this._isFetchingResponses;
}

public async getResponses(): Promise<Relations> {
// if we have already fetched the responses
// if we have already fetched some responses
// just return them
if (this.responses) {
return this.responses;
}
if (!this.fetchingResponsesPromise) {
this.fetchingResponsesPromise = this.fetchResponses();

// if there is no fetching in progress
// start fetching
if (!this.isFetchingResponses) {
await this.fetchResponses();
}
await this.fetchingResponsesPromise;
// return whatever responses we got from the first page
return this.responses!;
}

Expand Down Expand Up @@ -124,33 +133,56 @@ export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, P
}

private async fetchResponses(): Promise<void> {
this._isFetchingResponses = true;

// we want:
// - stable and unstable M_POLL_RESPONSE
// - stable and unstable M_POLL_END
// so make one api call and filter by event type client side
const allRelations = await this.matrixClient.relations(this.roomId, this.rootEvent.getId()!, "m.reference");
const allRelations = await this.matrixClient.relations(
this.roomId,
this.rootEvent.getId()!,
"m.reference",
undefined,
{
from: this.relationsNextBatch || undefined,
},
);

// @TODO(kerrya) paging results
const responses =
this.responses ||
new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]);

const responses = new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [
M_POLL_RESPONSE.altName!,
]);
const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType()));
if (this.validateEndEvent(pollEndEvent)) {
this.endEvent = pollEndEvent;
this.refilterResponsesOnEnd();
this.emit(PollEvent.End);
}

const potentialEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType()));
const pollEndEvent = this.validateEndEvent(potentialEndEvent) ? potentialEndEvent : undefined;
const pollCloseTimestamp = pollEndEvent?.getTs() || Number.MAX_SAFE_INTEGER;
const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;

const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp);

responseEvents.forEach((event) => {
responses.addEvent(event);
});

this.relationsNextBatch = allRelations.nextBatch ?? undefined;
this.responses = responses;
this.endEvent = pollEndEvent;
if (this.endEvent) {
this.emit(PollEvent.End);

// while there are more pages of relations
// fetch them
if (this.relationsNextBatch) {
// don't await
// we want to return the first page as soon as possible
this.fetchResponses();
} else {
// no more pages
this._isFetchingResponses = false;
}

// emit after updating _isFetchingResponses state
this.emit(PollEvent.Responses, this.responses);
}

Expand Down

0 comments on commit 4e8affa

Please sign in to comment.