mirror of
https://github.com/matrix-org/sliding-sync.git
synced 2025-03-10 13:37:11 +00:00
626 lines
15 KiB
Go
626 lines
15 KiB
Go
package syncv3
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matrix-org/sliding-sync/sync2"
|
|
"github.com/matrix-org/sliding-sync/sync3"
|
|
"github.com/matrix-org/sliding-sync/testutils"
|
|
"github.com/matrix-org/sliding-sync/testutils/m"
|
|
)
|
|
|
|
// Test that filters work initially and whilst streamed.
|
|
func TestFiltersEncryption(t *testing.T) {
|
|
boolTrue := true
|
|
boolFalse := false
|
|
rig := NewTestRig(t)
|
|
defer rig.Finish()
|
|
encryptedRoomID := "!TestFilters_encrypted:localhost"
|
|
unencryptedRoomID := "!TestFilters_unencrypted:localhost"
|
|
rig.SetupV2RoomsForUser(t, alice, NoFlush, map[string]RoomDescriptor{
|
|
encryptedRoomID: {
|
|
IsEncrypted: true,
|
|
},
|
|
unencryptedRoomID: {
|
|
IsEncrypted: false,
|
|
},
|
|
})
|
|
aliceToken := rig.Token(alice)
|
|
|
|
// connect and make sure either the encrypted room or not depending on what the filter says
|
|
res := rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"enc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsEncrypted: &boolTrue,
|
|
},
|
|
},
|
|
"noenc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsEncrypted: &boolFalse,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"enc": {
|
|
m.MatchV3Count(1),
|
|
m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 0, []string{encryptedRoomID}),
|
|
),
|
|
},
|
|
"noenc": {
|
|
m.MatchV3Count(1),
|
|
m.MatchV3Ops(
|
|
// Note: expect position 0 here---this is a separate list
|
|
m.MatchV3SyncOp(0, 0, []string{unencryptedRoomID}),
|
|
),
|
|
},
|
|
},
|
|
))
|
|
|
|
// change the unencrypted room into an encrypted room
|
|
rig.EncryptRoom(t, alice, unencryptedRoomID)
|
|
|
|
// now requesting the encrypted list should include it (added)
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"enc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
// sticky; should remember filters
|
|
},
|
|
"noenc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
// sticky; should remember filters
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"enc": {
|
|
m.MatchV3Count(2),
|
|
m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(1), m.MatchV3InsertOp(0, unencryptedRoomID),
|
|
),
|
|
},
|
|
"noenc": {
|
|
m.MatchV3Count(0),
|
|
m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(0),
|
|
),
|
|
},
|
|
},
|
|
))
|
|
|
|
// requesting the encrypted list from scratch returns 2 rooms now
|
|
res = rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"enc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsEncrypted: &boolTrue,
|
|
},
|
|
}},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"enc": {
|
|
m.MatchV3Count(2),
|
|
m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 1, []string{unencryptedRoomID, encryptedRoomID}),
|
|
),
|
|
},
|
|
},
|
|
))
|
|
|
|
// requesting the unencrypted stream from scratch returns 0 rooms
|
|
res = rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"noenc": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 1}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsEncrypted: &boolFalse,
|
|
},
|
|
}},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"noenc": {
|
|
m.MatchV3Count(0),
|
|
},
|
|
},
|
|
))
|
|
}
|
|
|
|
func TestFiltersInvite(t *testing.T) {
|
|
boolTrue := true
|
|
boolFalse := false
|
|
rig := NewTestRig(t)
|
|
defer rig.Finish()
|
|
roomID := "!a:localhost"
|
|
t.Log("Alice is invited to a room.")
|
|
rig.SetupV2RoomsForUser(t, alice, NoFlush, map[string]RoomDescriptor{
|
|
roomID: {
|
|
MembershipOfSyncer: "invite",
|
|
},
|
|
})
|
|
aliceToken := rig.Token(alice)
|
|
|
|
t.Log("Alice sliding syncs, requesting two separate lists: invites and joined rooms.")
|
|
res := rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"inv": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsInvite: &boolTrue,
|
|
},
|
|
},
|
|
"noinv": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
IsInvite: &boolFalse,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
t.Log("Alice should see the room appear in the invites list, and nothing in the joined rooms list.")
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"inv": {
|
|
m.MatchV3Count(1),
|
|
m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 0, []string{roomID}),
|
|
),
|
|
},
|
|
"noinv": {
|
|
m.MatchV3Count(0),
|
|
},
|
|
},
|
|
))
|
|
|
|
t.Log("Alice accepts the invite.")
|
|
rig.V2.queueResponse(alice, sync2.SyncResponse{
|
|
Rooms: sync2.SyncRoomsResponse{
|
|
Join: map[string]sync2.SyncV2JoinResponse{
|
|
roomID: {
|
|
State: sync2.EventsResponse{
|
|
Events: createRoomState(t, "@creator:other", time.Now()),
|
|
},
|
|
Timeline: sync2.TimelineResponse{
|
|
Events: []json.RawMessage{testutils.NewJoinEvent(t, alice)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// now the room should move from one room to another
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"inv": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
// sticky; should remember filters
|
|
},
|
|
"noinv": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
// sticky; should remember filters
|
|
},
|
|
},
|
|
})
|
|
// the room swaps from the invite list to the join list
|
|
m.MatchResponse(t, res, m.MatchLists(
|
|
map[string][]m.ListMatcher{
|
|
"inv": {
|
|
m.MatchV3Count(0),
|
|
m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(0),
|
|
),
|
|
},
|
|
"noinv": {
|
|
m.MatchV3Count(1),
|
|
m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(0),
|
|
m.MatchV3InsertOp(0, roomID),
|
|
),
|
|
},
|
|
},
|
|
))
|
|
}
|
|
|
|
func TestFiltersRoomName(t *testing.T) {
|
|
rig := NewTestRig(t)
|
|
defer rig.Finish()
|
|
ridApple := "!a:localhost"
|
|
ridPear := "!b:localhost"
|
|
ridLemon := "!c:localhost"
|
|
ridOrange := "!d:localhost"
|
|
ridPineapple := "!e:localhost"
|
|
ridBanana := "!f:localhost"
|
|
ridKiwi := "!g:localhost"
|
|
rig.SetupV2RoomsForUser(t, alice, NoFlush, map[string]RoomDescriptor{
|
|
ridApple: {
|
|
Name: "apple",
|
|
},
|
|
ridPear: {
|
|
Name: "PEAR",
|
|
},
|
|
ridLemon: {
|
|
Name: "Lemon Lemon",
|
|
},
|
|
ridOrange: {
|
|
Name: "ooooorange",
|
|
},
|
|
ridPineapple: {
|
|
Name: "PineApple",
|
|
},
|
|
ridBanana: {
|
|
Name: "BaNaNaNaNaNaNaN",
|
|
},
|
|
ridKiwi: {
|
|
Name: "kiwiwiwiwiwiwi",
|
|
},
|
|
})
|
|
aliceToken := rig.Token(alice)
|
|
|
|
// make sure the room name filter works
|
|
res := rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomNameFilter: "a",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchList("a",
|
|
m.MatchV3Count(5),
|
|
m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 4, []string{
|
|
ridApple, ridPear, ridOrange, ridPineapple, ridBanana,
|
|
}, true),
|
|
),
|
|
))
|
|
|
|
// refine the filter
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomNameFilter: "app",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchList("a",
|
|
m.MatchV3Count(2),
|
|
m.MatchV3Ops(
|
|
m.MatchV3InvalidateOp(0, 4),
|
|
m.MatchV3SyncOp(0, 1, []string{
|
|
ridApple, ridPineapple,
|
|
}, true),
|
|
),
|
|
))
|
|
}
|
|
|
|
func TestFiltersRoomTypes(t *testing.T) {
|
|
rig := NewTestRig(t)
|
|
defer rig.Finish()
|
|
spaceRoomID := "!spaceRoomID:localhost"
|
|
otherRoomID := "!other:localhost"
|
|
roomID := "!roomID:localhost"
|
|
roomType := "m.space"
|
|
otherRoomType := "something_else"
|
|
invalid := "lalalalaala"
|
|
rig.SetupV2RoomsForUser(t, alice, NoFlush, map[string]RoomDescriptor{
|
|
spaceRoomID: {
|
|
RoomType: roomType,
|
|
},
|
|
roomID: {},
|
|
otherRoomID: {
|
|
RoomType: otherRoomType,
|
|
},
|
|
})
|
|
aliceToken := rig.Token(alice)
|
|
|
|
// make sure the room_types and not_room_types filters works
|
|
res := rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
// returns spaceRoomID only due to direct match
|
|
"a": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomTypes: []*string{&roomType},
|
|
},
|
|
},
|
|
// returns roomID only due to direct match (null = things without a room type)
|
|
"b": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomTypes: []*string{nil},
|
|
},
|
|
},
|
|
// returns roomID and otherRoomID due to exclusion
|
|
"c": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
NotRoomTypes: []*string{&roomType},
|
|
},
|
|
},
|
|
// returns otherRoomID due to otherRoomType inclusive, roomType is excluded (override)
|
|
"d": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomTypes: []*string{&roomType, &otherRoomType},
|
|
NotRoomTypes: []*string{&roomType},
|
|
},
|
|
},
|
|
// returns no rooms as filtered room type isn't set on any rooms
|
|
"e": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
RoomTypes: []*string{&invalid},
|
|
},
|
|
},
|
|
// returns all rooms as filtered not room type isn't set on any rooms
|
|
"f": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20}, // all rooms
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
NotRoomTypes: []*string{&invalid},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchLists(map[string][]m.ListMatcher{
|
|
"a": {
|
|
m.MatchV3Count(1), m.MatchV3Ops(m.MatchV3SyncOp(0, 0, []string{spaceRoomID})),
|
|
},
|
|
"b": {
|
|
m.MatchV3Count(1), m.MatchV3Ops(m.MatchV3SyncOp(0, 0, []string{roomID})),
|
|
},
|
|
"c": {
|
|
m.MatchV3Count(2), m.MatchV3Ops(m.MatchV3SyncOp(0, 1, []string{roomID, otherRoomID}, true)),
|
|
},
|
|
"d": {
|
|
m.MatchV3Count(1), m.MatchV3Ops(m.MatchV3SyncOp(0, 0, []string{otherRoomID})),
|
|
},
|
|
"e": {
|
|
m.MatchV3Count(0),
|
|
},
|
|
"f": {
|
|
m.MatchV3Count(3), m.MatchV3Ops(m.MatchV3SyncOp(0, 2, []string{roomID, otherRoomID, spaceRoomID}, true)),
|
|
},
|
|
}))
|
|
}
|
|
|
|
func TestFiltersTags(t *testing.T) {
|
|
tagFav := "m.favourite"
|
|
tagLow := "m.lowpriority"
|
|
rig := NewTestRig(t)
|
|
defer rig.Finish()
|
|
fav1RoomID := "!fav1:localhost"
|
|
fav2RoomID := "!fav2:localhost"
|
|
favAndLowRoomID := "!favlow:localhost"
|
|
low1RoomID := "!low1:localhost"
|
|
low2RoomID := "!low2:localhost"
|
|
rig.SetupV2RoomsForUser(t, alice, NoFlush, map[string]RoomDescriptor{
|
|
fav1RoomID: {
|
|
Tags: map[string]float64{
|
|
tagFav: 0.5,
|
|
},
|
|
},
|
|
fav2RoomID: {
|
|
Tags: map[string]float64{
|
|
tagFav: 0.3,
|
|
},
|
|
},
|
|
favAndLowRoomID: {
|
|
Tags: map[string]float64{
|
|
tagFav: 0.5,
|
|
tagLow: 0.9,
|
|
},
|
|
},
|
|
low1RoomID: {
|
|
Tags: map[string]float64{
|
|
tagLow: 0.2,
|
|
},
|
|
},
|
|
low2RoomID: {
|
|
Tags: map[string]float64{
|
|
tagLow: 0.92,
|
|
},
|
|
},
|
|
})
|
|
aliceToken := rig.Token(alice)
|
|
res := rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"fav": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
Tags: []string{tagFav},
|
|
},
|
|
},
|
|
"lp": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
Tags: []string{tagLow},
|
|
},
|
|
},
|
|
"favlp": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
Tags: []string{tagFav, tagLow},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchList("fav", m.MatchV3Count(3), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 2, []string{fav1RoomID, fav2RoomID, favAndLowRoomID}, true),
|
|
)), m.MatchList("lp", m.MatchV3Count(3), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 2, []string{low1RoomID, low2RoomID, favAndLowRoomID}, true),
|
|
)), m.MatchList("favlp", m.MatchV3Count(5), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 4, []string{fav1RoomID, fav2RoomID, favAndLowRoomID, low1RoomID, low2RoomID}, true),
|
|
)))
|
|
|
|
// first bump the fav1 room
|
|
rig.FlushEvent(t, alice, fav1RoomID, testutils.NewMessageEvent(t, alice, "Hi"))
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"fav": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
"lp": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
"favlp": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
},
|
|
})
|
|
|
|
// now nuke it by removing the tag
|
|
rig.V2.queueResponse(alice, sync2.SyncResponse{
|
|
Rooms: sync2.SyncRoomsResponse{
|
|
Join: map[string]sync2.SyncV2JoinResponse{
|
|
fav1RoomID: {
|
|
AccountData: sync2.EventsResponse{
|
|
Events: []json.RawMessage{
|
|
testutils.NewAccountData(t, "m.tag", map[string]interface{}{}), // empty content
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
rig.V2.waitUntilEmpty(t, alice)
|
|
|
|
// we should see DELETEs
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"fav": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
"lp": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
"favlp": {Ranges: sync3.SliceRanges{{0, 20}}},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchList("fav", m.MatchV3Count(2), m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(0),
|
|
)), m.MatchList("lp", m.MatchV3Ops()), m.MatchList("favlp", m.MatchV3Count(4), m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(0),
|
|
)))
|
|
|
|
// check not_tags works
|
|
res = rig.V3.mustDoV3Request(t, aliceToken, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"fav": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
Tags: []string{tagFav},
|
|
},
|
|
},
|
|
"nofav": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
Filters: &sync3.RequestFilters{
|
|
NotTags: []string{tagFav},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
m.MatchResponse(t, res, m.MatchList("fav", m.MatchV3Count(2), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 1, []string{fav2RoomID, favAndLowRoomID}, true),
|
|
)), m.MatchList("nofav", m.MatchV3Count(3), m.MatchV3Ops(
|
|
m.MatchV3SyncOp(0, 2, []string{low1RoomID, low2RoomID, fav1RoomID}, true),
|
|
)))
|
|
|
|
// remove a fav, it should move to the other list
|
|
rig.V2.queueResponse(alice, sync2.SyncResponse{
|
|
Rooms: sync2.SyncRoomsResponse{
|
|
Join: map[string]sync2.SyncV2JoinResponse{
|
|
fav2RoomID: {
|
|
AccountData: sync2.EventsResponse{
|
|
Events: []json.RawMessage{
|
|
testutils.NewAccountData(t, "m.tag", map[string]interface{}{
|
|
"tags": map[string]interface{}{},
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
rig.V2.waitUntilEmpty(t, alice)
|
|
res = rig.V3.mustDoV3RequestWithPos(t, aliceToken, res.Pos, sync3.Request{
|
|
Lists: map[string]sync3.RequestList{
|
|
"fav": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
},
|
|
"nofav": {
|
|
Ranges: sync3.SliceRanges{
|
|
[2]int64{0, 20},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Room ordering is: (recent first)
|
|
// FAV1, LOW2, LOW1, FAVLOW, FAV2
|
|
// so lists before were:
|
|
// FAVLOW, FAV2
|
|
// FAV1, LOW2, LOW1
|
|
// now we have removed fav tag on FAV2 so new lists are:
|
|
// FAVLOW
|
|
// FAV1, LOW2, LOW1, FAV2
|
|
m.MatchResponse(t, res, m.MatchList("fav", m.MatchV3Count(1), m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(1),
|
|
)), m.MatchList("nofav", m.MatchV3Count(4), m.MatchV3Ops(
|
|
m.MatchV3DeleteOp(3),
|
|
m.MatchV3InsertOp(3, fav2RoomID),
|
|
)))
|
|
}
|