sliding-sync/tests-e2e/lists_test.go

1819 lines
56 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package syncv3_test
import (
"fmt"
"sync"
"testing"
"time"
"github.com/matrix-org/complement/b"
"github.com/matrix-org/sliding-sync/sync3/extensions"
"github.com/tidwall/gjson"
"github.com/matrix-org/sliding-sync/sync3"
"github.com/matrix-org/sliding-sync/testutils/m"
)
// Test that multiple lists can be independently scrolled through
func TestMultipleLists(t *testing.T) {
alice := registerNewUser(t)
// make 10 encrypted rooms and make 10 unencrypted rooms. [0] is most recent
var encryptedRoomIDs []string
var unencryptedRoomIDs []string
for i := 0; i < 10; i++ {
unencryptedRoomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
})
unencryptedRoomIDs = append([]string{unencryptedRoomID}, unencryptedRoomIDs...) // push in array
encryptedRoomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"initial_state": []b.Event{
NewEncryptionEvent(),
},
})
encryptedRoomIDs = append([]string{encryptedRoomID}, encryptedRoomIDs...) // push in array
time.Sleep(time.Millisecond) // ensure timestamp changes
}
// request 2 lists, one set encrypted, one set unencrypted
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"enc": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1,
},
Filters: &sync3.RequestFilters{
IsEncrypted: &boolTrue,
},
},
"unenc": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1,
},
Filters: &sync3.RequestFilters{
IsEncrypted: &boolFalse,
},
},
},
})
m.MatchResponse(t, res,
m.MatchLists(map[string][]m.ListMatcher{
"enc": {
m.MatchV3Count(len(encryptedRoomIDs)),
m.MatchV3Ops(m.MatchV3SyncOp(0, 2, encryptedRoomIDs[:3])),
},
"unenc": {
m.MatchV3Count(len(unencryptedRoomIDs)),
m.MatchV3Ops(m.MatchV3SyncOp(0, 2, unencryptedRoomIDs[:3])),
},
}),
m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
encryptedRoomIDs[0]: {},
encryptedRoomIDs[1]: {},
encryptedRoomIDs[2]: {},
unencryptedRoomIDs[0]: {},
unencryptedRoomIDs[1]: {},
unencryptedRoomIDs[2]: {},
}),
)
// now scroll one of the lists
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"enc": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms still
},
},
"unenc": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
[2]int64{3, 5}, // next 3 rooms
},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
"enc": {
m.MatchV3Count(len(encryptedRoomIDs)),
},
"unenc": {
m.MatchV3Count(len(unencryptedRoomIDs)),
m.MatchV3Ops(
m.MatchV3SyncOp(3, 5, unencryptedRoomIDs[3:6]),
),
},
}), m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
unencryptedRoomIDs[3]: {},
unencryptedRoomIDs[4]: {},
unencryptedRoomIDs[5]: {},
}))
// now shift the last/oldest unencrypted room to an encrypted room and make sure both lists update
alice.SendEventSynced(t, unencryptedRoomIDs[len(unencryptedRoomIDs)-1], NewEncryptionEvent())
// update our source of truth: the last unencrypted room is now the first encrypted room
encryptedRoomIDs = append([]string{unencryptedRoomIDs[len(unencryptedRoomIDs)-1]}, encryptedRoomIDs...)
unencryptedRoomIDs = unencryptedRoomIDs[:len(unencryptedRoomIDs)-1]
// We are tracking the first few encrypted rooms so we expect list 0 to update
// However we do not track old unencrypted rooms so we expect no change in list 1
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"enc": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms still
},
},
"unenc": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
[2]int64{3, 5}, // next 3 rooms
},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
"enc": {
m.MatchV3Count(len(encryptedRoomIDs)),
m.MatchV3Ops(
m.MatchV3DeleteOp(2),
m.MatchV3InsertOp(0, encryptedRoomIDs[0]),
),
},
"unenc": {
m.MatchV3Count(len(unencryptedRoomIDs)),
},
}))
}
// Test that bumps only update a single list and not both. Regression test for when
// DM rooms get bumped they appeared in the is_dm:false list.
func TestMultipleListsDMUpdate(t *testing.T) {
alice := registerNewUser(t)
var dmRoomIDs []string
var groupRoomIDs []string
dmContent := map[string]interface{}{} // user_id -> [room_id]
// make 5 group rooms and make 5 DMs rooms. Room 0 is most recent to ease checks
for i := 0; i < 5; i++ {
dmUserID := fmt.Sprintf("@dm_%d:synapse", i) // TODO: domain brittle
groupRoomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
})
groupRoomIDs = append([]string{groupRoomID}, groupRoomIDs...) // push in array
dmRoomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": true,
"invite": []string{dmUserID},
})
dmRoomIDs = append([]string{dmRoomID}, dmRoomIDs...) // push in array
dmContent[dmUserID] = []string{dmRoomID}
time.Sleep(time.Millisecond) // ensure timestamp changes
}
// set the account data
alice.MustSetGlobalAccountData(t, "m.direct", dmContent)
// request 2 lists, one set DM, one set no DM
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"dm": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1,
},
Filters: &sync3.RequestFilters{
IsDM: &boolTrue,
},
},
"nodm": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1,
},
Filters: &sync3.RequestFilters{
IsDM: &boolFalse,
},
},
},
})
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
"dm": {
m.MatchV3Count(len(dmRoomIDs)),
m.MatchV3Ops(m.MatchV3SyncOp(0, 2, dmRoomIDs[:3])),
},
"nodm": {
m.MatchV3Count(len(groupRoomIDs)),
m.MatchV3Ops(m.MatchV3SyncOp(0, 2, groupRoomIDs[:3])),
},
}))
// now bring the last DM room to the top with a notif
pingEventID := alice.SendEventSynced(t, dmRoomIDs[len(dmRoomIDs)-1], b.Event{
Type: "m.room.message",
Content: map[string]interface{}{"body": "ping", "msgtype": "m.text"},
})
// update our source of truth: swap the last and first elements
dmRoomIDs[0], dmRoomIDs[len(dmRoomIDs)-1] = dmRoomIDs[len(dmRoomIDs)-1], dmRoomIDs[0]
// now get the delta: only the DM room should change
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"dm": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms still
},
},
"nodm": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms still
},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
"dm": {
m.MatchV3Count(len(dmRoomIDs)),
m.MatchV3Ops(
m.MatchV3DeleteOp(2),
m.MatchV3InsertOp(0, dmRoomIDs[0]),
),
},
"nodm": {
m.MatchV3Count(len(groupRoomIDs)),
},
}), m.MatchRoomSubscription(dmRoomIDs[0], MatchRoomTimelineMostRecent(1, []Event{
{
Type: "m.room.message",
ID: pingEventID,
},
})))
}
// Test that a new list can be added mid-connection
func TestNewListMidConnection(t *testing.T) {
alice := registerNewUser(t)
var roomIDs []string
// make rooms
for i := 0; i < 4; i++ {
roomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
})
roomIDs = append([]string{roomID}, roomIDs...) // push in array
time.Sleep(time.Millisecond) // ensure timestamp changes
}
// first request no list
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{},
})
m.MatchResponse(t, res, m.MatchLists(nil))
// now add a list
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{
[2]int64{0, 2}, // first 3 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1,
},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(len(roomIDs)), m.MatchV3Ops(
m.MatchV3SyncOp(0, 2, roomIDs[:3]),
)))
}
// Tests that if a room appears in >1 list that we union room subscriptions correctly.
func TestMultipleOverlappingLists(t *testing.T) {
alice := registerNewUser(t)
var allRoomIDs []string
var encryptedRoomIDs []string
var dmRoomIDs []string
dmContent := map[string]interface{}{} // user_id -> [room_id]
dmUserID := "@bob:synapse"
// make 3 encrypted rooms, 3 encrypted/dm rooms, 3 dm rooms.
// [0] is the newest room.
for i := 9; i >= 0; i-- {
isEncrypted := i < 6
isDM := i >= 3
createContent := map[string]interface{}{
"preset": "private_chat",
}
if isEncrypted {
createContent["initial_state"] = []b.Event{
NewEncryptionEvent(),
}
}
if isDM {
createContent["is_direct"] = true
createContent["invite"] = []string{dmUserID}
}
roomID := alice.MustCreateRoom(t, createContent)
time.Sleep(time.Millisecond)
if isDM {
var roomIDs []string
roomIDsInt, ok := dmContent[dmUserID]
if ok {
roomIDs = roomIDsInt.([]string)
}
dmContent[dmUserID] = append(roomIDs, roomID)
dmRoomIDs = append([]string{roomID}, dmRoomIDs...)
}
if isEncrypted {
encryptedRoomIDs = append([]string{roomID}, encryptedRoomIDs...)
}
allRoomIDs = append([]string{roomID}, allRoomIDs...) // push entries so [0] is newest
}
// set the account data
t.Logf("DM rooms: %v", dmRoomIDs)
t.Logf("Encrypted rooms: %v", encryptedRoomIDs)
alice.MustSetGlobalAccountData(t, "m.direct", dmContent)
// seed the proxy: so we can get timeline correctly as it uses limit:1 initially.
alice.SlidingSync(t, sync3.Request{})
// send messages to track timeline. The most recent messages are:
// - ENCRYPTION EVENT (if set)
// - DM INVITE EVENT (if set)
// - This ping message (always)
roomToEventID := make(map[string]string, len(allRoomIDs))
for i := len(allRoomIDs) - 1; i >= 0; i-- {
roomToEventID[allRoomIDs[i]] = alice.SendEventSynced(t, allRoomIDs[i], b.Event{
Type: "m.room.message",
Content: map[string]interface{}{"body": "ping", "msgtype": "m.text"},
})
}
lastEventID := roomToEventID[allRoomIDs[0]]
alice.SlidingSyncUntilEventID(t, "", allRoomIDs[0], lastEventID)
// request 2 lists: one DMs, one encrypted. The room subscriptions are different so they should be UNION'd correctly.
// We request 5 rooms to ensure there is some overlap but not total overlap:
// newest top 5 DM
// v .-----------.
// E E E ED* ED* D D D D
// `-----------`
// top 5 Encrypted
//
// Rooms with * are union'd
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"enc": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 4}, // first 5 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 2, // pull in the ping msg + some state event depending on the room type
RequiredState: [][2]string{
{"m.room.join_rules", ""},
},
},
Filters: &sync3.RequestFilters{
IsEncrypted: &boolTrue,
},
},
"dm": {
Sort: []string{sync3.SortByRecency},
Ranges: sync3.SliceRanges{
[2]int64{0, 4}, // first 5 rooms
},
RoomSubscription: sync3.RoomSubscription{
TimelineLimit: 1, // pull in ping message only
RequiredState: [][2]string{
{"m.room.power_levels", ""},
},
},
Filters: &sync3.RequestFilters{
IsDM: &boolTrue,
},
},
},
})
m.MatchResponse(t, res,
m.MatchList("enc", m.MatchV3Ops(m.MatchV3SyncOp(0, 4, encryptedRoomIDs[:5]))),
m.MatchList("dm", m.MatchV3Ops(m.MatchV3SyncOp(0, 4, dmRoomIDs[:5]))),
m.MatchRoomSubscriptions(map[string][]m.RoomMatcher{
// encrypted rooms just come from the encrypted only list
encryptedRoomIDs[0]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.join_rules",
StateKey: ptr(""),
},
}),
MatchRoomTimeline([]Event{
{Type: "m.room.encryption", StateKey: ptr("")},
{ID: roomToEventID[encryptedRoomIDs[0]]},
}),
},
encryptedRoomIDs[1]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.join_rules",
StateKey: ptr(""),
},
}),
MatchRoomTimeline([]Event{
{Type: "m.room.encryption", StateKey: ptr("")},
{ID: roomToEventID[encryptedRoomIDs[1]]},
}),
},
encryptedRoomIDs[2]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.join_rules",
StateKey: ptr(""),
},
}),
MatchRoomTimeline([]Event{
{Type: "m.room.encryption", StateKey: ptr("")},
{ID: roomToEventID[encryptedRoomIDs[2]]},
}),
},
// overlapping with DM rooms
encryptedRoomIDs[3]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.join_rules",
StateKey: ptr(""),
},
{
Type: "m.room.power_levels",
StateKey: ptr(""),
},
}),
MatchRoomTimeline([]Event{
{Type: "m.room.member", StateKey: ptr(dmUserID)},
{ID: roomToEventID[encryptedRoomIDs[3]]},
}),
},
encryptedRoomIDs[4]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.join_rules",
StateKey: ptr(""),
},
{
Type: "m.room.power_levels",
StateKey: ptr(""),
},
}),
MatchRoomTimeline([]Event{
{Type: "m.room.member", StateKey: ptr(dmUserID)},
{ID: roomToEventID[encryptedRoomIDs[4]]},
}),
},
// DM only rooms
dmRoomIDs[2]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.power_levels",
StateKey: ptr(""),
},
}),
MatchRoomTimelineMostRecent(1, []Event{{ID: roomToEventID[dmRoomIDs[2]]}}),
},
dmRoomIDs[3]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.power_levels",
StateKey: ptr(""),
},
}),
MatchRoomTimelineMostRecent(1, []Event{{ID: roomToEventID[dmRoomIDs[3]]}}),
},
dmRoomIDs[4]: {
m.MatchRoomInitial(true),
MatchRoomRequiredStateStrict([]Event{
{
Type: "m.room.power_levels",
StateKey: ptr(""),
},
}),
MatchRoomTimelineMostRecent(1, []Event{{ID: roomToEventID[dmRoomIDs[4]]}}),
},
}),
)
}
// Regression test for a panic when new rooms were live-streamed to the client in Element-Web
func TestNot500OnNewRooms(t *testing.T) {
boolTrue := true
boolFalse := false
mSpace := "m.space"
alice := registerNewUser(t)
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
SlowGetAllRooms: &boolTrue,
Filters: &sync3.RequestFilters{
RoomTypes: []*string{&mSpace},
},
},
},
})
alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
SlowGetAllRooms: &boolTrue,
Filters: &sync3.RequestFilters{
RoomTypes: []*string{&mSpace},
},
},
"b": {
Filters: &sync3.RequestFilters{
IsDM: &boolFalse,
},
Ranges: sync3.SliceRanges{{0, 20}},
},
},
}, WithPos(res.Pos))
alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
// should not 500
alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
SlowGetAllRooms: &boolTrue,
},
"b": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
}, WithPos(res.Pos))
}
// Regression test for room name calculations, which could be incorrect for new rooms due to caches
// not being populated yet e.g "and 1 others" or "Empty room" when a room name has been set.
func TestNewRoomNameCalculations(t *testing.T) {
alice := registerNewUser(t)
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
SlowGetAllRooms: &boolTrue,
},
},
})
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(0)))
// create 10 room in parallel and at the same time spam sliding sync to ensure we get bits of
// rooms before they are fully loaded.
numRooms := 10
ch := make(chan int, numRooms)
var roomIDToName sync.Map
// start the goroutines
for i := 0; i < numRooms; i++ {
go func() {
for i := range ch {
roomName := fmt.Sprintf("room %d", i)
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": roomName})
roomIDToName.Store(roomID, roomName)
}
}()
}
// inject the work
for i := 0; i < numRooms; i++ {
ch <- i
}
close(ch)
seenRoomNames := make(map[string]string)
start := time.Now()
var err error
for {
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
SlowGetAllRooms: &boolTrue,
},
},
}, WithPos(res.Pos))
for roomID, sub := range res.Rooms {
if sub.Name != "" {
seenRoomNames[roomID] = sub.Name
}
}
if time.Since(start) > 15*time.Second {
t.Errorf("timed out, did not see all rooms, seen %d/%d", len(seenRoomNames), numRooms)
break
}
// do assertions and bail if they all pass
err = nil
seenRooms := 0
roomIDToName.Range(func(key, value interface{}) bool {
seenRooms++
createRoomID := key.(string)
name := value.(string)
gotName := seenRoomNames[createRoomID]
if name != gotName {
err = fmt.Errorf("[%s: got %s want %s] %w", createRoomID, gotName, name, err)
}
return true
})
if seenRooms != numRooms {
continue // wait for all /createRoom calls to return
}
if err == nil {
t.Logf("%+v\n", seenRoomNames)
break // we saw all the rooms with the right names
}
}
if err != nil {
// we didn't see all the right room names after timeout secs
t.Errorf(err.Error())
}
}
// Regression test for when you swap from sorting by recency to sorting by name and it didn't take effect.
func TestChangeSortOrder(t *testing.T) {
alice := registerNewUser(t)
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
Sort: []string{sync3.SortByRecency},
RoomSubscription: sync3.RoomSubscription{
RequiredState: [][2]string{{"m.room.name", ""}},
},
},
},
})
m.MatchResponse(t, res, m.MatchRoomSubscriptionsStrict(nil), m.MatchNoV3Ops())
roomNames := []string{
"Kiwi", "Lemon", "Apple", "Orange",
}
roomIDs := make([]string, len(roomNames))
gotNameToIDs := make(map[string]string)
waitFor := func(roomID, roomName string) {
start := time.Now()
for {
if time.Since(start) > time.Second {
t.Fatalf("didn't see room name '%s' for room %s", roomName, roomID)
break
}
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
}, WithPos(res.Pos))
for roomID, sub := range res.Rooms {
gotNameToIDs[sub.Name] = roomID
}
if gotNameToIDs[roomName] == roomID {
break
}
time.Sleep(10 * time.Millisecond)
}
}
for i, name := range roomNames {
roomIDs[i] = alice.MustCreateRoom(t, map[string]interface{}{
"name": name,
})
// we cannot guarantee we will see the right state yet, so just keep track of the room names
waitFor(roomIDs[i], name)
}
// now change the sort order: first send the request up then keep hitting sliding sync until
// we see the txn ID confirming it has been applied
txnID := "a"
res = alice.SlidingSync(t, sync3.Request{
TxnID: "a",
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
Sort: []string{sync3.SortByName},
},
},
}, WithPos(res.Pos))
for res.TxnID != txnID {
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
}, WithPos(res.Pos))
}
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(4), m.MatchV3Ops(
m.MatchV3InvalidateOp(0, 3),
m.MatchV3SyncOp(0, 3, []string{gotNameToIDs["Apple"], gotNameToIDs["Kiwi"], gotNameToIDs["Lemon"], gotNameToIDs["Orange"]}),
)))
}
// Regression test that a window can be shrunk and the INVALIDATE appears correctly for the low/high ends of the range
func TestShrinkRange(t *testing.T) {
alice := registerNewUser(t)
var roomIDs []string // most recent first
for i := 0; i < 10; i++ {
time.Sleep(time.Millisecond) // ensure creation timestamp changes
roomIDs = append([]string{alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"name": fmt.Sprintf("Room %d", i),
})}, roomIDs...)
}
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
})
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(10), m.MatchV3Ops(
m.MatchV3SyncOp(0, 9, roomIDs),
)))
// now shrink the window on both ends
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{2, 6}},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(10), m.MatchV3Ops(
m.MatchV3InvalidateOp(0, 1),
m.MatchV3InvalidateOp(7, 9),
)))
}
// Regression test that you can expand a window without it causing problems. Previously, it would cause
// a spurious {"op":"SYNC","range":[11,20],"room_ids":["!jHVHDxEWqTIcbDYoah:synapse"]} op for a bad index
func TestExpandRange(t *testing.T) {
alice := registerNewUser(t)
var roomIDs []string // most recent first
for i := 0; i < 10; i++ {
time.Sleep(time.Millisecond) // ensure creation timestamp changes
roomIDs = append([]string{alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"name": fmt.Sprintf("Room %d", i),
})}, roomIDs...)
}
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 10}},
},
},
})
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(10), m.MatchV3Ops(
m.MatchV3SyncOp(0, 9, roomIDs),
)))
// now expand the window
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
}, WithPos(res.Pos))
m.MatchResponse(t, res, m.MatchList("a", m.MatchV3Count(10), m.MatchV3Ops()))
}
// Regression test for Element X which has 2 identical lists and then changes the ranges in weird ways,
// causing the proxy to send back bad data. The request data here matches what EX sent.
func TestMultipleSameList(t *testing.T) {
alice := registerNewUser(t)
// the account had 16 rooms, so do this now
var roomIDs []string // most recent first
for i := 0; i < 16; i++ {
time.Sleep(time.Millisecond) // ensure creation timestamp changes
roomIDs = append([]string{alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
"name": fmt.Sprintf("Room %d", i),
})}, roomIDs...)
}
firstList := sync3.RequestList{
Sort: []string{sync3.SortByRecency, sync3.SortByName},
Ranges: sync3.SliceRanges{{0, 20}},
RoomSubscription: sync3.RoomSubscription{
RequiredState: [][2]string{{"m.room.avatar", ""}, {"m.room.encryption", ""}},
TimelineLimit: 10,
},
}
secondList := sync3.RequestList{
Sort: []string{sync3.SortByRecency, sync3.SortByName},
Ranges: sync3.SliceRanges{{0, 16}},
RoomSubscription: sync3.RoomSubscription{
RequiredState: [][2]string{{"m.room.avatar", ""}, {"m.room.encryption", ""}},
},
}
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"1": firstList, "2": secondList,
},
})
m.MatchResponse(t, res,
m.MatchList("1", m.MatchV3Count(16), m.MatchV3Ops(
m.MatchV3SyncOp(0, 15, roomIDs, false),
)),
m.MatchList("2", m.MatchV3Count(16), m.MatchV3Ops(
m.MatchV3SyncOp(0, 15, roomIDs, false),
)),
)
// now change both list ranges in a valid but strange way, and get back bad responses
firstList.Ranges = sync3.SliceRanges{{2, 15}} // from [0,20]
secondList.Ranges = sync3.SliceRanges{{0, 20}} // from [0,16]
res = alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"1": firstList, "2": secondList,
},
}, WithPos(res.Pos))
m.MatchResponse(t, res,
m.MatchList("1", m.MatchV3Count(16), m.MatchV3Ops(
m.MatchV3InvalidateOp(0, 1),
)),
m.MatchList("2", m.MatchV3Count(16), m.MatchV3Ops()),
)
}
// NB: assumes bump_event_types is sticky
func TestBumpEventTypesHandling(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
charlie := registerNamedUser(t, "charlie")
t.Log("Alice creates two rooms")
room1 := alice.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room1",
},
)
room2 := alice.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room2",
},
)
t.Logf("room1=%s room2=%s", room1, room2)
t.Log("Bob joins both rooms.")
bob.JoinRoom(t, room1, nil)
bob.JoinRoom(t, room2, nil)
t.Log("Bob sends a message in room 2 then room 1.")
bob.SendEventSynced(t, room2, b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"body": "Hi room 2",
"msgtype": "m.text",
},
})
bob.SendEventSynced(t, room1, b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"body": "Hello world",
"msgtype": "m.text",
},
})
t.Log("Alice requests a sliding sync that bumps rooms on messages only.")
aliceReqList := sync3.RequestList{
Sort: []string{sync3.SortByRecency, sync3.SortByName},
Ranges: sync3.SliceRanges{{0, 20}},
RoomSubscription: sync3.RoomSubscription{
RequiredState: [][2]string{{"m.room.avatar", ""}, {"m.room.encryption", ""}},
TimelineLimit: 10,
},
BumpEventTypes: []string{"m.room.message", "m.room.encrypted"},
}
aliceSyncRequest := sync3.Request{
Lists: map[string]sync3.RequestList{
"alice_list": aliceReqList,
},
}
// This sync should include both of Bob's messages. The proxy will make an initial
// V2 sync to the HS, which should include the latest event in both rooms.
// TODO: we could capture the event IDs above and assert this explicitly.
aliceRes := alice.SlidingSync(t, aliceSyncRequest)
t.Log("Alice's sync response should include room1 ahead of room 2.")
matchRoom1ThenRoom2 := []m.ListMatcher{
m.MatchV3Count(2),
m.MatchV3Ops(
m.MatchV3SyncOp(0, 1, []string{room1, room2}, false),
),
}
m.MatchResponse(t, aliceRes, m.MatchList("alice_list", matchRoom1ThenRoom2...))
t.Log("Bob requests a sliding sync that bumps rooms on messages and memberships.")
bobReqList := sync3.RequestList{
Sort: []string{sync3.SortByRecency, sync3.SortByName},
Ranges: sync3.SliceRanges{{0, 20}},
RoomSubscription: sync3.RoomSubscription{
RequiredState: [][2]string{{"m.room.avatar", ""}, {"m.room.encryption", ""}},
TimelineLimit: 10,
},
BumpEventTypes: []string{"m.room.message", "m.room.encrypted", "m.room.member"},
}
bobSyncRequest := sync3.Request{
Lists: map[string]sync3.RequestList{
"bob_list": bobReqList,
},
}
bobRes := bob.SlidingSync(t, bobSyncRequest)
t.Log("Bob should also see room 1 ahead of room 2 in his sliding sync response.")
m.MatchResponse(t, bobRes, m.MatchList("bob_list", matchRoom1ThenRoom2...))
t.Log("Charlie joins room 2.")
charlie.JoinRoom(t, room2, nil)
t.Log("Alice syncs until she sees Charlie's membership.")
aliceRes = alice.SlidingSyncUntilMembership(t, aliceRes.Pos, room2, charlie, "join")
t.Log("Alice shouldn't see any rooms' positions change.")
m.MatchResponse(
t,
aliceRes,
m.MatchList("alice_list", m.MatchV3Count(2)),
m.MatchNoV3Ops(),
)
t.Log("Bob syncs until he sees Charlie's membership.")
bobRes = bob.SlidingSyncUntilMembership(t, bobRes.Pos, room2, charlie, "join")
t.Log("Bob should see room 2 at the top of his list.")
matchBobSeesRoom2Bumped := m.MatchList("bob_list",
m.MatchV3Count(2),
m.MatchV3Ops(
m.MatchV3DeleteOp(1),
m.MatchV3InsertOp(0, room2),
),
)
m.MatchResponse(t, bobRes, matchBobSeesRoom2Bumped)
// The read receipt stuff here specifically checks for the bug in
// https://github.com/matrix-org/sliding-sync/issues/83
aliceRoom2Timeline := aliceRes.Rooms[room2].Timeline
aliceLastSeenEvent := aliceRoom2Timeline[len(aliceRoom2Timeline)-1]
aliceLastSeenEventID := gjson.ParseBytes(aliceLastSeenEvent).Get("event_id").Str
if aliceLastSeenEventID == "" {
t.Error("Could not find event ID for the last event in Alice's timeline.")
}
t.Log("Alice marks herself as having seen Charlie's join.")
alice.SendReceipt(t, room2, aliceLastSeenEventID, "m.read")
t.Log("Alice syncs until she sees her receipt. At no point should see see any room list operations.")
alice.SlidingSyncUntil(
t,
aliceRes.Pos,
sync3.Request{Extensions: extensions.Request{
Receipts: &extensions.ReceiptsRequest{
Core: extensions.Core{Enabled: &boolTrue},
},
}},
func(response *sync3.Response) error {
if err := m.MatchNoV3Ops()(response); err != nil {
t.Fatalf("expected no ops while waiting for receipt: %s", err)
}
matchReceipt := m.MatchReceipts(room2, []m.Receipt{{
EventID: aliceLastSeenEventID,
UserID: alice.UserID,
Type: "m.read",
}})
return matchReceipt(response)
},
)
}
func TestBumpEventTypesInOverlappingLists(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
t.Log("Alice creates four rooms")
room1 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "room1"})
room2 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "room2"})
room3 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "room3"})
room4 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "room4"})
t.Log("Alice writes a message in all four rooms.")
// Note: all lists bump on messages, so this will ensure the recency order is sensible.
helloWorld := map[string]interface{}{"body": "Hello world", "msgtype": "m.text"}
alice.Unsafe_SendEventUnsynced(t, room1, b.Event{Type: "m.room.message", Content: helloWorld})
alice.Unsafe_SendEventUnsynced(t, room2, b.Event{Type: "m.room.message", Content: helloWorld})
alice.Unsafe_SendEventUnsynced(t, room3, b.Event{Type: "m.room.message", Content: helloWorld})
alice.SendEventSynced(t, room4, b.Event{Type: "m.room.message", Content: helloWorld})
t.Log("Alice requests a sync with three lists: one bumping on messages, a second bumping on messages and memberships, and a third bumping on all events.")
const listMsg = "message"
const listMsgMember = "message_membership"
const listAll = "all"
req := sync3.Request{
Lists: map[string]sync3.RequestList{
listMsg: {
Sort: []string{sync3.SortByRecency},
RoomSubscription: sync3.RoomSubscription{TimelineLimit: 10},
Ranges: sync3.SliceRanges{{0, 10}},
BumpEventTypes: []string{"m.room.message"},
},
listMsgMember: {
Sort: []string{sync3.SortByRecency},
RoomSubscription: sync3.RoomSubscription{TimelineLimit: 10},
Ranges: sync3.SliceRanges{{0, 10}},
BumpEventTypes: []string{"m.room.message", "m.room.member"},
},
listAll: {
Sort: []string{sync3.SortByRecency},
RoomSubscription: sync3.RoomSubscription{TimelineLimit: 10},
Ranges: sync3.SliceRanges{{0, 10}},
BumpEventTypes: nil,
},
},
}
res := alice.SlidingSync(t, req)
t.Log("Alice sees the rooms in order 4, 3, 2, 1.")
// Note: first sync, so first poll, so should see all four message events.
see4321 := []m.ListMatcher{
m.MatchV3Count(4),
m.MatchV3Ops(m.MatchV3SyncOp(0, 3, []string{room4, room3, room2, room1}, false)),
}
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
listMsg: see4321,
listMsgMember: see4321,
listAll: see4321,
}))
t.Log("Bob joins room 1. Alice syncs until she sees Bob's join.")
bob.JoinRoom(t, room1, nil)
res = alice.SlidingSyncUntilMembership(t, res.Pos, room1, bob, "join")
t.Logf("Alice should see room1 bumped in the lists %s and %s, but not %s", listMsgMember, listAll, listMsg)
noMovement := []m.ListMatcher{
m.MatchV3Count(4),
m.MatchV3Ops(),
}
bumpToTop := func(roomID string, fromIdx int) []m.ListMatcher {
return []m.ListMatcher{
m.MatchV3Count(4),
m.MatchV3Ops(m.MatchV3DeleteOp(fromIdx), m.MatchV3InsertOp(0, roomID)),
}
}
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
listMsg: noMovement, // 4321
listMsgMember: bumpToTop(room1, 3), // 4321 -> 1432
listAll: bumpToTop(room1, 3), // 4321 -> 1432
}))
t.Log("Alice sets a room topic in room 3, and syncs until she sees the topic.")
topicEventID := alice.Unsafe_SendEventUnsynced(t, room3, b.Event{
Type: "m.room.topic",
StateKey: ptr(""),
Content: map[string]interface{}{"topic": "spicy meatballs"},
})
res = alice.SlidingSyncUntilEventID(t, res.Pos, room3, topicEventID)
t.Logf("Alice sees room3 bump in the %s list only", listAll)
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
listMsg: noMovement, // 4321
listMsgMember: noMovement, // 1432
listAll: bumpToTop(room3, 2), // 1432 -> 3142
}))
t.Logf("Alice sends a message in room 2, and syncs until she sees it.")
msgEventID := alice.Unsafe_SendEventUnsynced(t, room2, b.Event{Type: "m.room.message", Content: helloWorld})
res = alice.SlidingSyncUntilEventID(t, res.Pos, room2, msgEventID)
t.Logf("Alice sees room2 bump in all lists")
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
listMsg: bumpToTop(room2, 2), // 4321 -> 2431
listMsgMember: bumpToTop(room2, 3), // 1432 -> 2143
listAll: bumpToTop(room2, 3), // 3142 -> 2314
}))
}
func TestBumpEventTypesDoesntLeakOnNewConnAfterJoin(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
t.Log("Alice creates a room and sends a secret state event.")
room1 := alice.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room1",
},
)
alice.Unsafe_SendEventUnsynced(t, room1, b.Event{
Type: "secret",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Bob creates a room and sends a secret state event.")
time.Sleep(1 * time.Millisecond)
room2 := bob.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room1",
},
)
bob.Unsafe_SendEventUnsynced(t, room2, b.Event{
Type: "secret",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Alice invites Bob, who accepts.")
alice.InviteRoom(t, room1, bob.UserID)
bob.JoinRoom(t, room1, nil)
t.Log("Bob sliding syncs, requesting that rooms are bumped on the secret event type.")
res := bob.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
RoomSubscription: sync3.RoomSubscription{},
Ranges: sync3.SliceRanges{{0, 1}},
Sort: []string{sync3.SortByRecency},
BumpEventTypes: []string{"secret"},
},
},
})
t.Log("Bob should see room1 ahead of room2.")
// The order in which things happen:
// 1. alice: room1 secret event
// 2. bob: room2 secret event
// 3. alice: invite bob to room 1
// 4. bob: join room 1
// Bob can only see (2), (3) and (4), which means that room 1 has had most recent activity.
// (If we use the secret events' timestamps alone, without considering what Bob has
// permission to see, we will only consider (1) and (2), which would mean room 2
// has had the most recent activity.)
m.MatchResponse(
t,
res,
m.MatchList(
"a",
m.MatchV3Count(2),
m.MatchV3Ops(m.MatchV3SyncOp(0, 1, []string{room1, room2})),
),
)
}
// Like TestBumpEventTypesDoesntLeakOnNewConnAfterJoin, but Bob never accepts the invite.
func TestBumpEventTypesDoesntLeakOnNewConnAfterInvite(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
t.Log("Alice creates a room and sends a secret state event.")
room1 := alice.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room1",
},
)
alice.Unsafe_SendEventUnsynced(t, room1, b.Event{
Type: "secret",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Bob creates a room and sends a secret state event.")
time.Sleep(1 * time.Millisecond)
room2 := bob.MustCreateRoom(
t,
map[string]interface{}{
"preset": "public_chat",
"name": "room1",
},
)
bob.Unsafe_SendEventUnsynced(t, room2, b.Event{
Type: "secret",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Alice invites Bob, who does not respond.")
alice.InviteRoom(t, room1, bob.UserID)
t.Log("Bob sliding syncs, requesting that rooms are bumped on the secret event type.")
res := bob.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
RoomSubscription: sync3.RoomSubscription{},
Ranges: sync3.SliceRanges{{0, 1}},
Sort: []string{sync3.SortByRecency},
BumpEventTypes: []string{"secret"},
},
},
})
t.Log("Bob should see room1 ahead of room2.")
// The order in which things happen:
// 1. alice: room1 secret event
// 2. bob: room2 secret event
// 3. alice: invite bob to room 1
// Bob can only see (2) and (3), which means that room 1 has had most recent activity.
// (If we use the secret events' timestamps alone, without considering what Bob has
// permission to see, we will only consider (1) and (2), which would mean room 2
// has had the most recent activity.)
m.MatchResponse(
t,
res,
m.MatchList(
"a",
m.MatchV3Count(2),
m.MatchV3Ops(m.MatchV3SyncOp(0, 1, []string{room1, room2})),
),
)
}
// Tests the scenario described at
// https://github.com/matrix-org/sliding-sync/pull/58#discussion_r1159850458
func TestRangeOutsideTotalRooms(t *testing.T) {
alice := registerNewUser(t)
t.Log("Alice makes three public rooms.")
room0 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "A"})
room1 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "B"})
room2 := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat", "name": "C"})
t.Log("Alice initial syncs, requesting room ranges [0, 1] and [8, 9]")
syncRes := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Sort: []string{sync3.SortByName},
Ranges: sync3.SliceRanges{{0, 1}, {8, 9}},
},
},
})
t.Log("Alice should only see rooms 01 in the sync response.")
m.MatchResponse(
t,
syncRes,
m.MatchList(
"a",
m.MatchV3Count(3),
m.MatchV3Ops(
m.MatchV3SyncOp(0, 1, []string{room0, room1}),
),
),
)
t.Log("Alice changes the sort order")
syncRes = alice.SlidingSync(
t,
sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Sort: []string{sync3.SortByRecency},
},
},
},
WithPos(syncRes.Pos),
)
m.MatchResponse(
t,
syncRes,
m.MatchList(
"a",
m.MatchV3Count(3),
m.MatchV3Ops(
m.MatchV3InvalidateOp(0, 1),
m.MatchV3SyncOp(0, 1, []string{room2, room1}),
),
),
)
}
// Nicked from Synapse's tests, see
// https://github.com/matrix-org/synapse/blob/2cacd0849a02d43f88b6c15ee862398159ab827c/tests/test_utils/__init__.py#L154-L161
// Resolution: 1×1, MIME type: image/png, Extension: png, Size: 67 B
var smallPNG = []byte(
"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82",
)
func TestAvatarFieldInRoomResponse(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
chris := registerNamedUser(t, "chris")
avatarURLs := map[string]struct{}{}
uploadAvatar := func(client *CSAPI, filename string) string {
avatar := alice.UploadContent(t, smallPNG, filename, "image/png")
if _, exists := avatarURLs[avatar]; exists {
t.Fatalf("New avatar %s has already been uploaded", avatar)
}
t.Logf("%s is uploaded as %s", filename, avatar)
avatarURLs[avatar] = struct{}{}
return avatar
}
t.Log("Alice, Bob and Chris upload and set an avatar.")
aliceAvatar := uploadAvatar(alice, "alice.png")
bobAvatar := uploadAvatar(bob, "bob.png")
chrisAvatar := uploadAvatar(chris, "chris.png")
alice.SetAvatar(t, aliceAvatar)
bob.SetAvatar(t, bobAvatar)
chris.SetAvatar(t, chrisAvatar)
t.Log("Alice makes a public room, a DM with herself, a DM with Bob, a DM with Chris, and a group-DM with Bob and Chris.")
public := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
// TODO: you can create a DM with yourself e.g. as below. It probably ought to have
// your own face as an avatar.
// dmAlice := alice.MustCreateRoom(t, map[string]interface{}{
// "preset": "trusted_private_chat",
// "is_direct": true,
// })
dmBob := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": true,
"invite": []string{bob.UserID},
})
dmChris := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": true,
"invite": []string{chris.UserID},
})
dmBobChris := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": true,
"invite": []string{bob.UserID, chris.UserID},
})
alice.MustSetGlobalAccountData(t, "m.direct", map[string]any{
bob.UserID: []string{dmBob, dmBobChris},
chris.UserID: []string{dmChris, dmBobChris},
})
t.Logf("Rooms:\npublic=%s\ndmBob=%s\ndmChris=%s\ndmBobChris=%s", public, dmBob, dmChris, dmBobChris)
t.Log("Bob accepts his invites. Chris accepts none.")
bob.JoinRoom(t, dmBob, nil)
bob.JoinRoom(t, dmBobChris, nil)
t.Log("Alice makes an initial sliding sync.")
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"rooms": {
Ranges: sync3.SliceRanges{{0, 4}},
},
},
})
t.Log("Alice should see each room in the sync response with an appropriate avatar and DM flag")
m.MatchResponse(
t,
res,
m.MatchRoomSubscription(public, m.MatchRoomUnsetAvatar(), m.MatchRoomIsDM(false)),
m.MatchRoomSubscription(dmBob, m.MatchRoomAvatar(bob.AvatarURL), m.MatchRoomIsDM(true)),
m.MatchRoomSubscription(dmChris, m.MatchRoomAvatar(chris.AvatarURL), m.MatchRoomIsDM(true)),
m.MatchRoomSubscription(dmBobChris, m.MatchRoomUnsetAvatar(), m.MatchRoomIsDM(true)),
)
t.Run("Avatar not resent on message", func(t *testing.T) {
t.Log("Bob sends a sentinel message.")
sentinel := bob.SendEventSynced(t, dmBob, b.Event{
Type: "m.room.message",
Content: map[string]interface{}{
"body": "Hello world",
"msgtype": "m.text",
},
})
t.Log("Alice syncs until she sees the sentinel. She should not see the DM avatar change.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, func(response *sync3.Response) error {
matchNoAvatarChange := m.MatchRoomSubscription(dmBob, m.MatchRoomUnchangedAvatar())
if err := matchNoAvatarChange(response); err != nil {
t.Fatalf("Saw DM avatar change: %s", err)
}
matchSentinel := m.MatchRoomSubscription(dmBob, MatchRoomTimelineMostRecent(1, []Event{{ID: sentinel}}))
return matchSentinel(response)
})
})
t.Run("DM declined", func(t *testing.T) {
t.Log("Chris leaves his DM with Alice.")
chris.LeaveRoom(t, dmChris)
t.Log("Alice syncs until she sees Chris's leave.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmChris, chris, "leave")
t.Log("Alice sees Chris's avatar vanish.")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmChris, m.MatchRoomUnsetAvatar()))
})
t.Run("Group DM declined", func(t *testing.T) {
t.Log("Chris leaves his group DM with Alice and Bob.")
chris.LeaveRoom(t, dmBobChris)
t.Log("Alice syncs until she sees Chris's leave.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmBobChris, chris, "leave")
t.Log("Alice sees the room's avatar change to Bob's avatar.")
// Because this is now a DM room with exactly one other (joined|invited) member.
m.MatchResponse(t, res, m.MatchRoomSubscription(dmBobChris, m.MatchRoomAvatar(bob.AvatarURL)))
})
t.Run("Bob's avatar change propagates", func(t *testing.T) {
t.Log("Bob changes his avatar.")
bobAvatar2 := uploadAvatar(bob, "bob2.png")
bob.SetAvatar(t, bobAvatar2)
avatarChangeInDM := false
avatarChangeInGroupDM := false
t.Log("Alice syncs until she sees Bob's new avatar.")
res = alice.SlidingSyncUntil(
t,
res.Pos,
sync3.Request{},
func(response *sync3.Response) error {
if !avatarChangeInDM {
err := m.MatchRoomSubscription(dmBob, m.MatchRoomAvatar(bob.AvatarURL))(response)
if err == nil {
avatarChangeInDM = true
}
}
if !avatarChangeInGroupDM {
err := m.MatchRoomSubscription(dmBobChris, m.MatchRoomAvatar(bob.AvatarURL))(response)
if err == nil {
avatarChangeInGroupDM = true
}
}
if avatarChangeInDM && avatarChangeInGroupDM {
return nil
}
return fmt.Errorf("still waiting: avatarChangeInDM=%t avatarChangeInGroupDM=%t", avatarChangeInDM, avatarChangeInGroupDM)
},
)
t.Log("Bob removes his avatar.")
bob.SetAvatar(t, "")
avatarChangeInDM = false
avatarChangeInGroupDM = false
t.Log("Alice syncs until she sees Bob's avatars vanish.")
res = alice.SlidingSyncUntil(
t,
res.Pos,
sync3.Request{},
func(response *sync3.Response) error {
if !avatarChangeInDM {
err := m.MatchRoomSubscription(dmBob, m.MatchRoomUnsetAvatar())(response)
if err == nil {
avatarChangeInDM = true
} else {
t.Log(err)
}
}
if !avatarChangeInGroupDM {
err := m.MatchRoomSubscription(dmBobChris, m.MatchRoomUnsetAvatar())(response)
if err == nil {
avatarChangeInGroupDM = true
} else {
t.Log(err)
}
}
if avatarChangeInDM && avatarChangeInGroupDM {
return nil
}
return fmt.Errorf("still waiting: avatarChangeInDM=%t avatarChangeInGroupDM=%t", avatarChangeInDM, avatarChangeInGroupDM)
},
)
})
t.Run("Explicit avatar propagates in non-DM room", func(t *testing.T) {
t.Log("Alice sets an avatar for the public room.")
publicAvatar := uploadAvatar(alice, "public.png")
alice.Unsafe_SendEventUnsynced(t, public, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{
"url": publicAvatar,
},
})
t.Log("Alice syncs until she sees that avatar.")
res = alice.SlidingSyncUntil(
t,
res.Pos,
sync3.Request{},
m.MatchRoomSubscriptions(map[string][]m.RoomMatcher{
public: {m.MatchRoomAvatar(publicAvatar)},
}),
)
t.Log("Alice changes the avatar for the public room.")
publicAvatar2 := uploadAvatar(alice, "public2.png")
alice.Unsafe_SendEventUnsynced(t, public, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{
"url": publicAvatar2,
},
})
t.Log("Alice syncs until she sees that avatar.")
res = alice.SlidingSyncUntil(
t,
res.Pos,
sync3.Request{},
m.MatchRoomSubscriptions(map[string][]m.RoomMatcher{
public: {m.MatchRoomAvatar(publicAvatar2)},
}),
)
t.Log("Alice removes the avatar for the public room.")
alice.Unsafe_SendEventUnsynced(t, public, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Alice syncs until she sees that avatar vanish.")
res = alice.SlidingSyncUntil(
t,
res.Pos,
sync3.Request{},
m.MatchRoomSubscriptions(map[string][]m.RoomMatcher{
public: {m.MatchRoomUnsetAvatar()},
}),
)
})
t.Run("Explicit avatar propagates in DM room", func(t *testing.T) {
t.Log("Alice re-invites Chris to their DM.")
alice.InviteRoom(t, dmChris, chris.UserID)
t.Log("Alice syncs until she sees her invitation to Chris.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmChris, chris, "invite")
t.Log("Alice should see the DM with Chris's avatar.")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmChris, m.MatchRoomAvatar(chris.AvatarURL)))
t.Log("Chris joins the room.")
chris.JoinRoom(t, dmChris, nil)
t.Log("Alice syncs until she sees Chris's join.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmChris, chris, "join")
t.Log("Alice shouldn't see the DM's avatar change..")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmChris, m.MatchRoomUnchangedAvatar()))
t.Log("Chris gives their DM a bespoke avatar.")
dmAvatar := uploadAvatar(chris, "dm.png")
chris.Unsafe_SendEventUnsynced(t, dmChris, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{
"url": dmAvatar,
},
})
t.Log("Alice syncs until she sees that avatar.")
alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, m.MatchRoomSubscription(dmChris, m.MatchRoomAvatar(dmAvatar)))
t.Log("Chris changes his global avatar, which adds a join event to the room.")
chrisAvatar2 := uploadAvatar(chris, "chris2.png")
chris.SetAvatar(t, chrisAvatar2)
t.Log("Alice syncs until she sees that join event.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmChris, chris, "join")
t.Log("Her response should have either no avatar change, or the same bespoke avatar.")
// No change, ideally, but repeating the same avatar isn't _wrong_
m.MatchResponse(t, res, m.MatchRoomSubscription(dmChris, func(r sync3.Room) error {
noChangeErr := m.MatchRoomUnchangedAvatar()(r)
sameBespokeAvatarErr := m.MatchRoomAvatar(dmAvatar)(r)
if noChangeErr == nil || sameBespokeAvatarErr == nil {
return nil
}
return fmt.Errorf("expected no change or the same bespoke avatar (%s), got '%s'", dmAvatar, r.AvatarChange)
}))
t.Log("Chris updates the DM's avatar.")
dmAvatar2 := uploadAvatar(chris, "dm2.png")
chris.Unsafe_SendEventUnsynced(t, dmChris, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{
"url": dmAvatar2,
},
})
t.Log("Alice syncs until she sees that avatar.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, m.MatchRoomSubscription(dmChris, m.MatchRoomAvatar(dmAvatar2)))
t.Log("Chris removes the DM's avatar.")
chris.Unsafe_SendEventUnsynced(t, dmChris, b.Event{
Type: "m.room.avatar",
StateKey: ptr(""),
Content: map[string]interface{}{},
})
t.Log("Alice syncs until the DM avatar returns to Chris's most recent avatar.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, m.MatchRoomSubscription(dmChris, m.MatchRoomAvatar(chris.AvatarURL)))
})
t.Run("Changing DM flag", func(t *testing.T) {
t.Skip("TODO: unimplemented")
t.Log("Alice clears the DM flag on Bob's room.")
alice.MustSetGlobalAccountData(t, "m.direct", map[string]interface{}{
"content": map[string][]string{
bob.UserID: {}, // no dmBob here
chris.UserID: {dmChris, dmBobChris},
},
})
t.Log("Alice syncs until she sees a new set of account data.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{
Extensions: extensions.Request{
AccountData: &extensions.AccountDataRequest{
extensions.Core{Enabled: &boolTrue},
},
},
}, func(response *sync3.Response) error {
if response.Extensions.AccountData == nil {
return fmt.Errorf("no account data yet")
}
if len(response.Extensions.AccountData.Global) == 0 {
return fmt.Errorf("no global account data yet")
}
return nil
})
t.Log("The DM with Bob should no longer be a DM and should no longer have an avatar.")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmBob, func(r sync3.Room) error {
if r.IsDM {
return fmt.Errorf("dmBob is still a DM")
}
return m.MatchRoomUnsetAvatar()(r)
}))
t.Log("Alice sets the DM flag on Bob's room.")
alice.MustSetGlobalAccountData(t, "m.direct", map[string]interface{}{
"content": map[string][]string{
bob.UserID: {dmBob}, // dmBob reinstated
chris.UserID: {dmChris, dmBobChris},
},
})
t.Log("Alice syncs until she sees a new set of account data.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{
Extensions: extensions.Request{
AccountData: &extensions.AccountDataRequest{
extensions.Core{Enabled: &boolTrue},
},
},
}, func(response *sync3.Response) error {
if response.Extensions.AccountData == nil {
return fmt.Errorf("no account data yet")
}
if len(response.Extensions.AccountData.Global) == 0 {
return fmt.Errorf("no global account data yet")
}
return nil
})
t.Log("The room should have Bob's avatar again.")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmBob, func(r sync3.Room) error {
if !r.IsDM {
return fmt.Errorf("dmBob is still not a DM")
}
return m.MatchRoomAvatar(bob.AvatarURL)(r)
}))
})
t.Run("See avatar when invited", func(t *testing.T) {
t.Log("Chris invites Alice to a DM.")
dmInvited := chris.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": true,
"invite": []string{alice.UserID},
})
t.Log("Alice syncs until she sees the invite.")
res = alice.SlidingSyncUntilMembership(t, res.Pos, dmInvited, alice, "invite")
t.Log("The new room should appear as a DM and use Chris's avatar.")
m.MatchResponse(t, res, m.MatchRoomSubscription(dmInvited, m.MatchRoomIsDM(true), m.MatchRoomAvatar(chris.AvatarURL)))
t.Run("Creator of a non-DM never sees an avatar", func(t *testing.T) {
t.Log("Alice makes a new room which is not a DM.")
privateGroup := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"is_direct": false,
})
t.Log("Alice sees the group. It has no avatar.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, m.MatchRoomSubscription(privateGroup, m.MatchRoomUnsetAvatar()))
m.MatchResponse(t, res, m.MatchRoomSubscription(privateGroup, m.MatchRoomIsDM(false)))
t.Log("Alice invites Bob to the group, who accepts.")
alice.MustInviteRoom(t, privateGroup, bob.UserID)
bob.MustJoinRoom(t, privateGroup, nil)
t.Log("Alice sees Bob join. The room still has no avatar.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, func(response *sync3.Response) error {
matchNoAvatarChange := m.MatchRoomSubscription(privateGroup, m.MatchRoomUnchangedAvatar())
if err := matchNoAvatarChange(response); err != nil {
t.Fatalf("Saw group avatar change: %s", err)
}
matchJoin := m.MatchRoomSubscription(privateGroup, MatchRoomTimelineMostRecent(1, []Event{
{
Type: "m.room.member",
Sender: bob.UserID,
StateKey: ptr(bob.UserID),
},
}))
return matchJoin(response)
})
t.Log("Alice invites Chris to the group, who accepts.")
alice.MustInviteRoom(t, privateGroup, chris.UserID)
chris.MustJoinRoom(t, privateGroup, nil)
t.Log("Alice sees Chris join. The room still has no avatar.")
res = alice.SlidingSyncUntil(t, res.Pos, sync3.Request{}, func(response *sync3.Response) error {
matchNoAvatarChange := m.MatchRoomSubscription(privateGroup, m.MatchRoomUnchangedAvatar())
if err := matchNoAvatarChange(response); err != nil {
t.Fatalf("Saw group avatar change: %s", err)
}
matchJoin := m.MatchRoomSubscription(privateGroup, MatchRoomTimelineMostRecent(1, []Event{
{
Type: "m.room.member",
Sender: chris.UserID,
StateKey: ptr(chris.UserID),
},
}))
return matchJoin(response)
})
})
})
}
// Regression test for https://github.com/element-hq/element-x-ios/issues/2003
// Ensure that a group chat with 1 other person has no avatar field set. Only DMs should have this set.
func TestAvatarUnsetInTwoPersonRoom(t *testing.T) {
alice := registerNamedUser(t, "alice")
bob := registerNamedUser(t, "bob")
bobAvatar := alice.UploadContent(t, smallPNG, "bob.png", "image/png")
bob.SetAvatar(t, bobAvatar)
roomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "trusted_private_chat",
"name": "Nice test room",
"invite": []string{bob.UserID},
})
res := alice.SlidingSync(t, sync3.Request{
Lists: map[string]sync3.RequestList{
"a": {
Ranges: sync3.SliceRanges{{0, 20}},
},
},
})
m.MatchResponse(t, res, m.MatchRoomSubscriptionsStrict(map[string][]m.RoomMatcher{
roomID: {
m.MatchRoomUnsetAvatar(),
m.MatchInviteCount(1),
m.MatchRoomName("Nice test room"),
},
}))
}