diff --git a/src/pkg/backup/details/exchange.go b/src/pkg/backup/details/exchange.go index a43dffc16..d3c042f75 100644 --- a/src/pkg/backup/details/exchange.go +++ b/src/pkg/backup/details/exchange.go @@ -112,8 +112,6 @@ func (i *ExchangeInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) } func (i *ExchangeInfo) updateFolder(f *FolderInfo) error { - // Use a switch instead of a rather large if-statement. Just make sure it's an - // Exchange type. If it's not return an error. switch i.ItemType { case ExchangeContact, ExchangeEvent, ExchangeMail: default: diff --git a/src/pkg/backup/details/groups.go b/src/pkg/backup/details/groups.go index 34f167e77..69c3f4883 100644 --- a/src/pkg/backup/details/groups.go +++ b/src/pkg/backup/details/groups.go @@ -79,7 +79,7 @@ type ChannelMessageInfo struct { Subject string `json:"subject,omitempty"` } -// Headers returns the human-readable names of properties in a SharePointInfo +// Headers returns the human-readable names of properties in a groupsInfo // for printing out to a terminal in a columnar display. func (i GroupsInfo) Headers() []string { switch i.ItemType { diff --git a/src/pkg/backup/details/iteminfo.go b/src/pkg/backup/details/iteminfo.go index e2eaf357e..22391d2d8 100644 --- a/src/pkg/backup/details/iteminfo.go +++ b/src/pkg/backup/details/iteminfo.go @@ -41,6 +41,9 @@ const ( // Groups/Teams(40x) GroupsChannelMessage ItemType = 401 GroupsConversationPost ItemType = 402 + + // Teams Chat + TeamsChat ItemType = 501 ) func UpdateItem(item *ItemInfo, newLocPath *path.Builder) { @@ -73,6 +76,7 @@ type ItemInfo struct { SharePoint *SharePointInfo `json:"sharePoint,omitempty"` OneDrive *OneDriveInfo `json:"oneDrive,omitempty"` Groups *GroupsInfo `json:"groups,omitempty"` + TeamsChats *TeamsChatsInfo `json:"teamsChats,omitempty"` // Optional item extension data Extension *ExtensionData `json:"extension,omitempty"` } @@ -99,6 +103,9 @@ func (i ItemInfo) infoType() ItemType { case i.Groups != nil: return i.Groups.ItemType + + case i.TeamsChats != nil: + return i.TeamsChats.ItemType } return UnknownType @@ -120,6 +127,9 @@ func (i ItemInfo) size() int64 { case i.Folder != nil: return i.Folder.Size + + case i.TeamsChats != nil: + return int64(i.TeamsChats.Chat.MessageCount) } return 0 @@ -141,6 +151,9 @@ func (i ItemInfo) Modified() time.Time { case i.Folder != nil: return i.Folder.Modified + + case i.TeamsChats != nil: + return i.TeamsChats.Modified } return time.Time{} @@ -160,6 +173,9 @@ func (i ItemInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) { case i.Groups != nil: return i.Groups.uniqueLocation(baseLoc) + case i.TeamsChats != nil: + return i.TeamsChats.uniqueLocation(baseLoc) + default: return nil, clues.New("unsupported type") } @@ -179,6 +195,9 @@ func (i ItemInfo) updateFolder(f *FolderInfo) error { case i.Groups != nil: return i.Groups.updateFolder(f) + case i.TeamsChats != nil: + return i.TeamsChats.updateFolder(f) + default: return clues.New("unsupported type") } diff --git a/src/pkg/backup/details/iteminfo_test.go b/src/pkg/backup/details/iteminfo_test.go index 1073edb27..87a28873c 100644 --- a/src/pkg/backup/details/iteminfo_test.go +++ b/src/pkg/backup/details/iteminfo_test.go @@ -71,12 +71,21 @@ func (suite *ItemInfoUnitSuite) TestItemInfo_IsDriveItem() { { name: "exchange anything", ii: ItemInfo{ - Groups: &GroupsInfo{ + Exchange: &ExchangeInfo{ ItemType: ExchangeMail, }, }, expect: assert.False, }, + { + name: "teams chat", + ii: ItemInfo{ + TeamsChats: &TeamsChatsInfo{ + ItemType: TeamsChat, + }, + }, + expect: assert.False, + }, } for _, test := range table { suite.Run(test.name, func() { diff --git a/src/pkg/backup/details/teamsChats.go b/src/pkg/backup/details/teamsChats.go new file mode 100644 index 000000000..0974941a3 --- /dev/null +++ b/src/pkg/backup/details/teamsChats.go @@ -0,0 +1,118 @@ +package details + +import ( + "fmt" + "strconv" + "time" + + "github.com/alcionai/clues" + + "github.com/alcionai/corso/src/pkg/dttm" + "github.com/alcionai/corso/src/pkg/path" +) + +// NewChatsLocationIDer builds a LocationIDer for the chats. +func NewChatsLocationIDer( + category path.CategoryType, + escapedFolders ...string, +) (uniqueLoc, error) { + if err := path.ValidateServiceAndCategory(path.TeamsChatsService, category); err != nil { + return uniqueLoc{}, clues.Wrap(err, "making chats LocationIDer") + } + + pb := path.Builder{}.Append(category.String()).Append(escapedFolders...) + + return uniqueLoc{ + pb: pb, + prefixElems: 1, + }, nil +} + +// TeamsChatsInfo describes a chat within teams chats. +type TeamsChatsInfo struct { + ItemType ItemType `json:"itemType,omitempty"` + Modified time.Time `json:"modified,omitempty"` + ParentPath string `json:"parentPath,omitempty"` + + Chat ChatInfo `json:"chat,omitempty"` +} + +type ChatInfo struct { + CreatedAt time.Time `json:"createdAt,omitempty"` + HasExternalMembers bool `json:"hasExternalMembers,omitempty"` + LastMessageAt time.Time `json:"lastMessageAt,omitempty"` + LastMessagePreview string `json:"preview,omitempty"` + Members []string `json:"members,omitempty"` + MessageCount int `json:"messageCount,omitempty"` + Name string `json:"name,omitempty"` +} + +// Headers returns the human-readable names of properties in a ChatsInfo +// for printing out to a terminal in a columnar display. +func (i TeamsChatsInfo) Headers() []string { + switch i.ItemType { + case TeamsChat: + return []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"} + } + + return []string{} +} + +// Values returns the values matching the Headers list for printing +// out to a terminal in a columnar display. +func (i TeamsChatsInfo) Values() []string { + switch i.ItemType { + case TeamsChat: + members := "" + icmLen := len(i.Chat.Members) + + if icmLen > 0 { + members = i.Chat.Members[0] + } + + if icmLen > 1 { + members = fmt.Sprintf("%s, and %d more", members, icmLen-1) + } + + return []string{ + i.Chat.Name, + i.Chat.LastMessagePreview, + dttm.FormatToTabularDisplay(i.Chat.LastMessageAt), + strconv.Itoa(i.Chat.MessageCount), + dttm.FormatToTabularDisplay(i.Chat.CreatedAt), + members, + } + } + + return []string{} +} + +func (i *TeamsChatsInfo) UpdateParentPath(newLocPath *path.Builder) { + i.ParentPath = newLocPath.String() +} + +func (i *TeamsChatsInfo) uniqueLocation(baseLoc *path.Builder) (*uniqueLoc, error) { + var category path.CategoryType + + switch i.ItemType { + case TeamsChat: + category = path.ChatsCategory + } + + loc, err := NewChatsLocationIDer(category, baseLoc.Elements()...) + + return &loc, err +} + +func (i *TeamsChatsInfo) updateFolder(f *FolderInfo) error { + switch i.ItemType { + case TeamsChat: + default: + return clues.New("unsupported non-Chats ItemType"). + With("item_type", i.ItemType) + } + + f.DataType = i.ItemType + + return nil +} diff --git a/src/pkg/backup/details/teamsChats_test.go b/src/pkg/backup/details/teamsChats_test.go new file mode 100644 index 000000000..476a32341 --- /dev/null +++ b/src/pkg/backup/details/teamsChats_test.go @@ -0,0 +1,71 @@ +package details_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/dttm" +) + +type ChatsUnitSuite struct { + tester.Suite +} + +func TestChatsUnitSuite(t *testing.T) { + suite.Run(t, &ChatsUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *ChatsUnitSuite) TestChatsPrintable() { + now := time.Now() + then := now.Add(time.Minute) + + table := []struct { + name string + info details.TeamsChatsInfo + expectHs []string + expectVs []string + }{ + { + name: "channel message", + info: details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + ParentPath: "parentpath", + Chat: details.ChatInfo{ + CreatedAt: now, + HasExternalMembers: true, + LastMessageAt: then, + LastMessagePreview: "last message preview", + Members: []string{"foo@bar.baz", "fnords@smarf.zoomba"}, + MessageCount: 42, + Name: "chat name", + }, + }, + expectHs: []string{"Name", "Last message", "Last message at", "Message count", "Created", "Members"}, + expectVs: []string{ + "chat name", + "last message preview", + dttm.FormatToTabularDisplay(then), + "42", + dttm.FormatToTabularDisplay(now), + "foo@bar.baz, and 1 more", + }, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + hs := test.info.Headers() + vs := test.info.Values() + + assert.Equal(t, len(hs), len(vs)) + assert.Equal(t, test.expectHs, hs) + assert.Equal(t, test.expectVs, vs) + }) + } +} diff --git a/src/pkg/path/category_type.go b/src/pkg/path/category_type.go index 6dff9ebeb..7ca16740f 100644 --- a/src/pkg/path/category_type.go +++ b/src/pkg/path/category_type.go @@ -28,6 +28,7 @@ const ( DetailsCategory CategoryType = 8 // details ChannelMessagesCategory CategoryType = 9 // channelMessages ConversationPostsCategory CategoryType = 10 // conversationPosts + ChatsCategory CategoryType = 11 // chats ) var strToCat = map[string]CategoryType{ @@ -41,6 +42,7 @@ var strToCat = map[string]CategoryType{ strings.ToLower(DetailsCategory.String()): DetailsCategory, strings.ToLower(ChannelMessagesCategory.String()): ChannelMessagesCategory, strings.ToLower(ConversationPostsCategory.String()): ConversationPostsCategory, + strings.ToLower(ChatsCategory.String()): ChatsCategory, } func ToCategoryType(s string) CategoryType { @@ -63,6 +65,7 @@ var catToHuman = map[CategoryType]string{ DetailsCategory: "Details", ChannelMessagesCategory: "Messages", ConversationPostsCategory: "Posts", + ChatsCategory: "Chats", } // HumanString produces a more human-readable string version of the category. @@ -100,6 +103,9 @@ var serviceCategories = map[ServiceType]map[CategoryType]struct{}{ ConversationPostsCategory: {}, LibrariesCategory: {}, }, + TeamsChatsService: { + ChatsCategory: {}, + }, } func validateServiceAndCategoryStrings(s, c string) (ServiceType, CategoryType, error) { diff --git a/src/pkg/path/category_type_test.go b/src/pkg/path/category_type_test.go index 639eccb60..7333b9a8e 100644 --- a/src/pkg/path/category_type_test.go +++ b/src/pkg/path/category_type_test.go @@ -35,6 +35,7 @@ func (suite *CategoryTypeUnitSuite) TestToCategoryType() { {input: "details", expect: 8}, {input: "channelmessages", expect: 9}, {input: "conversationposts", expect: 10}, + {input: "chats", expect: 11}, } for _, test := range table { suite.Run(test.input, func() { @@ -62,6 +63,7 @@ func (suite *CategoryTypeUnitSuite) TestHumanString() { {input: 8, expect: "Details"}, {input: 9, expect: "Messages"}, {input: 10, expect: "Posts"}, + {input: 11, expect: "Chats"}, } for _, test := range table { suite.Run(test.input.String(), func() { diff --git a/src/pkg/path/categorytype_string.go b/src/pkg/path/categorytype_string.go index 98abbbed5..4b2c9126e 100644 --- a/src/pkg/path/categorytype_string.go +++ b/src/pkg/path/categorytype_string.go @@ -19,11 +19,12 @@ func _() { _ = x[DetailsCategory-8] _ = x[ChannelMessagesCategory-9] _ = x[ConversationPostsCategory-10] + _ = x[ChatsCategory-11] } -const _CategoryType_name = "UnknownCategoryemailcontactseventsfileslistslibrariespagesdetailschannelMessagesconversationPosts" +const _CategoryType_name = "UnknownCategoryemailcontactseventsfileslistslibrariespagesdetailschannelMessagesconversationPostschats" -var _CategoryType_index = [...]uint8{0, 15, 20, 28, 34, 39, 44, 53, 58, 65, 80, 97} +var _CategoryType_index = [...]uint8{0, 15, 20, 28, 34, 39, 44, 53, 58, 65, 80, 97, 102} func (i CategoryType) String() string { if i < 0 || i >= CategoryType(len(_CategoryType_index)-1) { diff --git a/src/pkg/path/service_category_test.go b/src/pkg/path/service_category_test.go index 789e1a2f4..ce58d61c3 100644 --- a/src/pkg/path/service_category_test.go +++ b/src/pkg/path/service_category_test.go @@ -118,6 +118,14 @@ func (suite *ServiceCategoryUnitSuite) TestValidateServiceAndCategory() { expectedCategory: LibrariesCategory, check: assert.NoError, }, + { + name: "ChatsChats", + service: TeamsChatsService.String(), + category: ChatsCategory.String(), + expectedService: TeamsChatsService, + expectedCategory: ChatsCategory, + check: assert.NoError, + }, } for _, test := range table { suite.Run(test.name, func() { diff --git a/src/pkg/path/service_type.go b/src/pkg/path/service_type.go index b68e60ad6..9363fc3ab 100644 --- a/src/pkg/path/service_type.go +++ b/src/pkg/path/service_type.go @@ -23,39 +23,38 @@ type ServiceType int //go:generate stringer -type=ServiceType -linecomment const ( UnknownService ServiceType = 0 - ExchangeService ServiceType = 1 // exchange - OneDriveService ServiceType = 2 // onedrive - SharePointService ServiceType = 3 // sharepoint - ExchangeMetadataService ServiceType = 4 // exchangeMetadata - OneDriveMetadataService ServiceType = 5 // onedriveMetadata - SharePointMetadataService ServiceType = 6 // sharepointMetadata - GroupsService ServiceType = 7 // groups - GroupsMetadataService ServiceType = 8 // groupsMetadata + ExchangeService ServiceType = 1 // exchange + OneDriveService ServiceType = 2 // onedrive + SharePointService ServiceType = 3 // sharepoint + ExchangeMetadataService ServiceType = 4 // exchangeMetadata + OneDriveMetadataService ServiceType = 5 // onedriveMetadata + SharePointMetadataService ServiceType = 6 // sharepointMetadata + GroupsService ServiceType = 7 // groups + GroupsMetadataService ServiceType = 8 // groupsMetadata + TeamsChatsService ServiceType = 9 // teamsChats + TeamsChatsMetadataService ServiceType = 10 // teamsChatsMetadata ) -func ToServiceType(service string) ServiceType { - s := strings.ToLower(service) +var strToSvc = map[string]ServiceType{ + strings.ToLower(ExchangeService.String()): ExchangeService, + strings.ToLower(ExchangeMetadataService.String()): ExchangeMetadataService, + strings.ToLower(OneDriveService.String()): OneDriveService, + strings.ToLower(OneDriveMetadataService.String()): OneDriveMetadataService, + strings.ToLower(SharePointService.String()): SharePointService, + strings.ToLower(SharePointMetadataService.String()): SharePointMetadataService, + strings.ToLower(GroupsService.String()): GroupsService, + strings.ToLower(GroupsMetadataService.String()): GroupsMetadataService, + strings.ToLower(TeamsChatsService.String()): TeamsChatsService, + strings.ToLower(TeamsChatsMetadataService.String()): TeamsChatsMetadataService, +} - switch s { - case strings.ToLower(ExchangeService.String()): - return ExchangeService - case strings.ToLower(OneDriveService.String()): - return OneDriveService - case strings.ToLower(SharePointService.String()): - return SharePointService - case strings.ToLower(GroupsService.String()): - return GroupsService - case strings.ToLower(ExchangeMetadataService.String()): - return ExchangeMetadataService - case strings.ToLower(OneDriveMetadataService.String()): - return OneDriveMetadataService - case strings.ToLower(SharePointMetadataService.String()): - return SharePointMetadataService - case strings.ToLower(GroupsMetadataService.String()): - return GroupsMetadataService - default: - return UnknownService +func ToServiceType(service string) ServiceType { + st, ok := strToSvc[strings.ToLower(service)] + if !ok { + st = UnknownService } + + return st } var serviceToHuman = map[ServiceType]string{ @@ -63,6 +62,7 @@ var serviceToHuman = map[ServiceType]string{ OneDriveService: "OneDrive", SharePointService: "SharePoint", GroupsService: "Groups", + TeamsChatsService: "Chats", } // HumanString produces a more human-readable string version of the service. @@ -78,26 +78,19 @@ func (svc ServiceType) HumanString() string { func (svc ServiceType) ToMetadata() ServiceType { //exhaustive:enforce switch svc { - case ExchangeService: + case ExchangeService, ExchangeMetadataService: return ExchangeMetadataService - case OneDriveService: + case OneDriveService, OneDriveMetadataService: return OneDriveMetadataService - case SharePointService: + case SharePointService, SharePointMetadataService: return SharePointMetadataService - case GroupsService: + case GroupsService, GroupsMetadataService: return GroupsMetadataService - - case ExchangeMetadataService: - fallthrough - case OneDriveMetadataService: - fallthrough - case SharePointMetadataService: - fallthrough - case GroupsMetadataService: - fallthrough + case TeamsChatsService, TeamsChatsMetadataService: + return TeamsChatsMetadataService case UnknownService: - return svc + fallthrough + default: + return UnknownService } - - return UnknownService } diff --git a/src/pkg/path/servicetype_string.go b/src/pkg/path/servicetype_string.go index 6fa499364..24d0ca4a9 100644 --- a/src/pkg/path/servicetype_string.go +++ b/src/pkg/path/servicetype_string.go @@ -17,11 +17,13 @@ func _() { _ = x[SharePointMetadataService-6] _ = x[GroupsService-7] _ = x[GroupsMetadataService-8] + _ = x[TeamsChatsService-9] + _ = x[TeamsChatsMetadataService-10] } -const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadatagroupsgroupsMetadata" +const _ServiceType_name = "UnknownServiceexchangeonedrivesharepointexchangeMetadataonedriveMetadatasharepointMetadatagroupsgroupsMetadatachatschatsMetadata" -var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90, 96, 110} +var _ServiceType_index = [...]uint8{0, 14, 22, 30, 40, 56, 72, 90, 96, 110, 115, 128} func (i ServiceType) String() string { if i < 0 || i >= ServiceType(len(_ServiceType_index)-1) { diff --git a/src/pkg/selectors/exchange.go b/src/pkg/selectors/exchange.go index 26af761ea..ba824a89e 100644 --- a/src/pkg/selectors/exchange.go +++ b/src/pkg/selectors/exchange.go @@ -591,7 +591,7 @@ func (ec exchangeCategory) isLeaf() bool { // pathValues transforms the two paths to maps of identified properties. // // Example: -// [tenantID, service, userPN, category, mailFolder, mailID] +// [tenantID, service, userID, category, mailFolder, mailID] // => {exchMailFolder: mailFolder, exchMail: mailID} func (ec exchangeCategory) pathValues( repo path.Path, @@ -772,7 +772,7 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool { infoCat := s.InfoCategory() - cfpc := categoryFromItemType(info.ItemType) + cfpc := exchangeCategoryFromItemType(info.ItemType) if !typeAndCategoryMatches(infoCat, cfpc) { return false } @@ -801,10 +801,10 @@ func (s ExchangeScope) matchesInfo(dii details.ItemInfo) bool { return s.Matches(infoCat, i) } -// categoryFromItemType interprets the category represented by the ExchangeInfo +// exchangeCategoryFromItemType interprets the category represented by the ExchangeInfo // struct. Since every ExchangeInfo can hold all exchange data info, the exact // type that the struct represents must be compared using its ItemType prop. -func categoryFromItemType(pct details.ItemType) exchangeCategory { +func exchangeCategoryFromItemType(pct details.ItemType) exchangeCategory { switch pct { case details.ExchangeContact: return ExchangeContact diff --git a/src/pkg/selectors/exchange_test.go b/src/pkg/selectors/exchange_test.go index 16d2541da..4dc56c529 100644 --- a/src/pkg/selectors/exchange_test.go +++ b/src/pkg/selectors/exchange_test.go @@ -1602,7 +1602,7 @@ func (suite *ExchangeSelectorSuite) TestCategoryFromItemType() { suite.Run(test.name, func() { t := suite.T() - result := categoryFromItemType(test.input) + result := exchangeCategoryFromItemType(test.input) assert.Equal(t, test.expect, result) }) } diff --git a/src/pkg/selectors/selectors.go b/src/pkg/selectors/selectors.go index b1fdb6433..4c49f637b 100644 --- a/src/pkg/selectors/selectors.go +++ b/src/pkg/selectors/selectors.go @@ -26,6 +26,7 @@ const ( ServiceOneDrive service = 2 // OneDrive ServiceSharePoint service = 3 // SharePoint ServiceGroups service = 4 // Groups + ServiceTeamsChats service = 5 // TeamsChats ) var serviceToPathType = map[service]path.ServiceType{ @@ -34,6 +35,7 @@ var serviceToPathType = map[service]path.ServiceType{ ServiceOneDrive: path.OneDriveService, ServiceSharePoint: path.SharePointService, ServiceGroups: path.GroupsService, + ServiceTeamsChats: path.TeamsChatsService, } var ( diff --git a/src/pkg/selectors/teamsChats.go b/src/pkg/selectors/teamsChats.go new file mode 100644 index 000000000..60df1a08b --- /dev/null +++ b/src/pkg/selectors/teamsChats.go @@ -0,0 +1,525 @@ +package selectors + +import ( + "context" + "fmt" + "strings" + + "github.com/alcionai/clues" + + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/backup/identity" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/filters" + "github.com/alcionai/corso/src/pkg/path" +) + +// --------------------------------------------------------------------------- +// Selectors +// --------------------------------------------------------------------------- + +type ( + // teamsChats provides an api for selecting + // data scopes applicable to the TeamsChats service. + teamsChats struct { + Selector + } + + // TeamsChatsBackup provides an api for selecting + // data scopes applicable to the TeamsChats service, + // plus backup-specific methods. + TeamsChatsBackup struct { + teamsChats + } + + // TeamsChatsRestore provides an api for selecting + // data scopes applicable to the TeamsChats service, + // plus restore-specific methods. + TeamsChatsRestore struct { + teamsChats + } +) + +var ( + _ Reducer = &TeamsChatsRestore{} + _ pathCategorier = &TeamsChatsRestore{} + _ reasoner = &TeamsChatsRestore{} +) + +// NewTeamsChats produces a new Selector with the service set to ServiceTeamsChats. +func NewTeamsChatsBackup(users []string) *TeamsChatsBackup { + src := TeamsChatsBackup{ + teamsChats{ + newSelector(ServiceTeamsChats, users), + }, + } + + return &src +} + +// ToTeamsChatsBackup transforms the generic selector into an TeamsChatsBackup. +// Errors if the service defined by the selector is not ServiceTeamsChats. +func (s Selector) ToTeamsChatsBackup() (*TeamsChatsBackup, error) { + if s.Service != ServiceTeamsChats { + return nil, badCastErr(ServiceTeamsChats, s.Service) + } + + src := TeamsChatsBackup{teamsChats{s}} + + return &src, nil +} + +func (s TeamsChatsBackup) SplitByResourceOwner(users []string) []TeamsChatsBackup { + sels := splitByProtectedResource[TeamsChatsScope](s.Selector, users, TeamsChatsUser) + + ss := make([]TeamsChatsBackup, 0, len(sels)) + for _, sel := range sels { + ss = append(ss, TeamsChatsBackup{teamsChats{sel}}) + } + + return ss +} + +// NewTeamsChatsRestore produces a new Selector with the service set to ServiceTeamsChats. +func NewTeamsChatsRestore(users []string) *TeamsChatsRestore { + src := TeamsChatsRestore{ + teamsChats{ + newSelector(ServiceTeamsChats, users), + }, + } + + return &src +} + +// ToTeamsChatsRestore transforms the generic selector into an TeamsChatsRestore. +// Errors if the service defined by the selector is not ServiceTeamsChats. +func (s Selector) ToTeamsChatsRestore() (*TeamsChatsRestore, error) { + if s.Service != ServiceTeamsChats { + return nil, badCastErr(ServiceTeamsChats, s.Service) + } + + src := TeamsChatsRestore{teamsChats{s}} + + return &src, nil +} + +func (sr TeamsChatsRestore) SplitByResourceOwner(users []string) []TeamsChatsRestore { + sels := splitByProtectedResource[TeamsChatsScope](sr.Selector, users, TeamsChatsUser) + + ss := make([]TeamsChatsRestore, 0, len(sels)) + for _, sel := range sels { + ss = append(ss, TeamsChatsRestore{teamsChats{sel}}) + } + + return ss +} + +// PathCategories produces the aggregation of discrete users described by each type of scope. +func (s teamsChats) PathCategories() selectorPathCategories { + return selectorPathCategories{ + Excludes: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Excludes), + Filters: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Filters), + Includes: pathCategoriesIn[TeamsChatsScope, teamsChatsCategory](s.Includes), + } +} + +// Reasons returns a deduplicated set of the backup reasons produced +// using the selector's discrete owner and each scopes' service and +// category types. +func (s teamsChats) Reasons(tenantID string, useOwnerNameForID bool) []identity.Reasoner { + return reasonsFor(s, tenantID, useOwnerNameForID) +} + +// --------------------------------------------------------------------------- +// Stringers and Concealers +// --------------------------------------------------------------------------- + +func (s TeamsChatsScope) Conceal() string { return conceal(s) } +func (s TeamsChatsScope) Format(fs fmt.State, r rune) { format(s, fs, r) } +func (s TeamsChatsScope) String() string { return conceal(s) } +func (s TeamsChatsScope) PlainString() string { return plainString(s) } + +// ------------------- +// Exclude/Includes + +// Exclude appends the provided scopes to the selector's exclusion set. +// Every Exclusion scope applies globally, affecting all inclusion scopes. +// Data is excluded if it matches ANY exclusion (of the same data category). +// +// All parts of the scope must match for data to be exclucded. +// Ex: Mail(u1, f1, m1) => only excludes mail if it is owned by user u1, +// located in folder f1, and ID'd as m1. MailSender(foo) => only excludes +// mail whose sender is foo. Use selectors.Any() to wildcard a scope value. +// No value will match if selectors.None() is provided. +// +// Group-level scopes will automatically apply the Any() wildcard to +// child properties. +// ex: User(u1) automatically cascades to all chats, +func (s *teamsChats) Exclude(scopes ...[]TeamsChatsScope) { + s.Excludes = appendScopes(s.Excludes, scopes...) +} + +// Filter appends the provided scopes to the selector's filters set. +// A selector with >0 filters and 0 inclusions will include any data +// that passes all filters. +// A selector with >0 filters and >0 inclusions will reduce the +// inclusion set to only the data that passes all filters. +// Data is retained if it passes ALL filters (of the same data category). +// +// All parts of the scope must match for data to pass the filter. +// Ex: Mail(u1, f1, m1) => only passes mail that is owned by user u1, +// located in folder f1, and ID'd as m1. MailSender(foo) => only passes +// mail whose sender is foo. Use selectors.Any() to wildcard a scope value. +// No value will match if selectors.None() is provided. +// +// Group-level scopes will automatically apply the Any() wildcard to +// child properties. +// ex: User(u1) automatically cascades to all chats, +func (s *teamsChats) Filter(scopes ...[]TeamsChatsScope) { + s.Filters = appendScopes(s.Filters, scopes...) +} + +// Include appends the provided scopes to the selector's inclusion set. +// Data is included if it matches ANY inclusion. +// The inclusion set is later filtered (all included data must pass ALL +// filters) and excluded (all included data must not match ANY exclusion). +// Data is included if it matches ANY inclusion (of the same data category). +// +// All parts of the scope must match for data to be included. +// Ex: Mail(u1, f1, m1) => only includes mail if it is owned by user u1, +// located in folder f1, and ID'd as m1. MailSender(foo) => only includes +// mail whose sender is foo. Use selectors.Any() to wildcard a scope value. +// No value will match if selectors.None() is provided. +// +// Group-level scopes will automatically apply the Any() wildcard to +// child properties. +// ex: User(u1) automatically cascades to all chats, +func (s *teamsChats) Include(scopes ...[]TeamsChatsScope) { + s.Includes = appendScopes(s.Includes, scopes...) +} + +// Scopes retrieves the list of teamsChatsScopes in the selector. +func (s *teamsChats) Scopes() []TeamsChatsScope { + return scopes[TeamsChatsScope](s.Selector) +} + +type TeamsChatsItemScopeConstructor func([]string, []string, ...option) []TeamsChatsScope + +// ------------------- +// Scope Factories + +// Chats produces one or more teamsChats scopes. +// If any slice contains selectors.Any, that slice is reduced to [selectors.Any] +// If any slice contains selectors.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +// options are only applied to the folder scopes. +func (s *teamsChats) Chats(chats []string, opts ...option) []TeamsChatsScope { + scopes := []TeamsChatsScope{} + + scopes = append( + scopes, + makeScope[TeamsChatsScope](TeamsChatsChat, chats, defaultItemOptions(s.Cfg)...)) + + return scopes +} + +// Retrieves all teamsChats data. +// Each user id generates a scope for each data type: chats (only one data type at this time). +// If any slice contains selectors.Any, that slice is reduced to [selectors.Any] +// If any slice contains selectors.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +func (s *teamsChats) AllData() []TeamsChatsScope { + scopes := []TeamsChatsScope{} + + scopes = append(scopes, makeScope[TeamsChatsScope](TeamsChatsChat, Any())) + + return scopes +} + +// ------------------- +// ItemInfo Factories + +// ChatMember produces one or more teamsChats chat member info scopes. +// Matches any chat member whose email contains the provided string. +// If any slice contains selectors.Any, that slice is reduced to [selectors.Any] +// If any slice contains selectors.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +func (sr *TeamsChatsRestore) ChatMember(memberID string) []TeamsChatsScope { + return []TeamsChatsScope{ + makeInfoScope[TeamsChatsScope]( + TeamsChatsChat, + TeamsChatsInfoChatMember, + []string{memberID}, + filters.In), + } +} + +// ChatName produces one or more teamsChats chat name info scopes. +// Matches any chat whose name contains the provided string. +// If any slice contains selectors.Any, that slice is reduced to [selectors.Any] +// If any slice contains selectors.None, that slice is reduced to [selectors.None] +// If any slice is empty, it defaults to [selectors.None] +func (sr *TeamsChatsRestore) ChatName(memberID string) []TeamsChatsScope { + return []TeamsChatsScope{ + makeInfoScope[TeamsChatsScope]( + TeamsChatsChat, + TeamsChatsInfoChatName, + []string{memberID}, + filters.In), + } +} + +// --------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------- + +// teamsChatsCategory enumerates the type of the lowest level +// of data specified by the scope. +type teamsChatsCategory string + +// interface compliance checks +var _ categorizer = TeamsChatsCategoryUnknown + +const ( + TeamsChatsCategoryUnknown teamsChatsCategory = "" + + // types of data identified by teamsChats + TeamsChatsUser teamsChatsCategory = "TeamsChatsUser" + TeamsChatsChat teamsChatsCategory = "TeamsChatsChat" + + // data contained within details.ItemInfo + TeamsChatsInfoChatMember teamsChatsCategory = "TeamsChatsInfoChatMember" + TeamsChatsInfoChatName teamsChatsCategory = "TeamsChatsInfoChatName" +) + +// teamsChatsLeafProperties describes common metadata of the leaf categories +var teamsChatsLeafProperties = map[categorizer]leafProperty{ + TeamsChatsChat: { + pathKeys: []categorizer{TeamsChatsChat}, + pathType: path.ChatsCategory, + }, + TeamsChatsUser: { // the root category must be represented, even though it isn't a leaf + pathKeys: []categorizer{TeamsChatsUser}, + pathType: path.UnknownCategory, + }, +} + +func (ec teamsChatsCategory) String() string { + return string(ec) +} + +// leafCat returns the leaf category of the receiver. +// If the receiver category has multiple leaves (ex: User) or no leaves, +// (ex: Unknown), the receiver itself is returned. +// If the receiver category is an info type (ex: TeamsChatsInfoChatMember), +// returns the category covered by the info. +// Ex: TeamsChatsChatFolder.leafCat() => TeamsChatsChat +// Ex: TeamsChatsUser.leafCat() => TeamsChatsUser +func (ec teamsChatsCategory) leafCat() categorizer { + switch ec { + case TeamsChatsChat, TeamsChatsInfoChatMember, TeamsChatsInfoChatName: + return TeamsChatsChat + } + + return ec +} + +// rootCat returns the root category type. +func (ec teamsChatsCategory) rootCat() categorizer { + return TeamsChatsUser +} + +// unknownCat returns the unknown category type. +func (ec teamsChatsCategory) unknownCat() categorizer { + return TeamsChatsCategoryUnknown +} + +// isUnion returns true if c is a user +func (ec teamsChatsCategory) isUnion() bool { + return ec == ec.rootCat() +} + +// isLeaf is true if the category is a mail, event, or contact category. +func (ec teamsChatsCategory) isLeaf() bool { + return ec == ec.leafCat() +} + +// pathValues transforms the two paths to maps of identified properties. +// +// Example: +// [tenantID, service, userID, category, chatID] +// => {teamsChat: chatID} +func (ec teamsChatsCategory) pathValues( + repo path.Path, + ent details.Entry, + cfg Config, +) (map[categorizer][]string, error) { + var itemCat categorizer + + switch ec { + case TeamsChatsChat: + itemCat = TeamsChatsChat + + default: + return nil, clues.New("bad Chat Category").With("category", ec) + } + + item := ent.ItemRef + if len(item) == 0 { + item = repo.Item() + } + + items := []string{ent.ShortRef, item} + + // only include the item ID when the user is NOT matching + // item names. TeamsChats data does not contain an item name, + // only an ID, and we don't want to mix up the two. + if cfg.OnlyMatchItemNames { + items = []string{ent.ShortRef} + } + + result := map[categorizer][]string{ + itemCat: items, + } + + return result, nil +} + +// pathKeys returns the path keys recognized by the receiver's leaf type. +func (ec teamsChatsCategory) pathKeys() []categorizer { + return teamsChatsLeafProperties[ec.leafCat()].pathKeys +} + +// PathType converts the category's leaf type into the matching path.CategoryType. +func (ec teamsChatsCategory) PathType() path.CategoryType { + return teamsChatsLeafProperties[ec.leafCat()].pathType +} + +// --------------------------------------------------------------------------- +// Scopes +// --------------------------------------------------------------------------- + +// TeamsChatsScope specifies the data available +// when interfacing with the TeamsChats service. +type TeamsChatsScope scope + +// interface compliance checks +var _ scoper = &TeamsChatsScope{} + +// Category describes the type of the data in scope. +func (s TeamsChatsScope) Category() teamsChatsCategory { + return teamsChatsCategory(getCategory(s)) +} + +// categorizer type is a generic wrapper around Category. +// Primarily used by scopes.go to for abstract comparisons. +func (s TeamsChatsScope) categorizer() categorizer { + return s.Category() +} + +// Matches returns true if the category is included in the scope's +// data type, and the target string matches that category's comparator. +func (s TeamsChatsScope) Matches(cat teamsChatsCategory, target string) bool { + return matches(s, cat, target) +} + +// InfoCategory returns the category enum of the scope info. +// If the scope is not an info type, returns TeamsChatsUnknownCategory. +func (s TeamsChatsScope) InfoCategory() teamsChatsCategory { + return teamsChatsCategory(getInfoCategory(s)) +} + +// IncludeCategory checks whether the scope includes a certain category of data. +// Ex: to check if the scope includes mail data: +// s.IncludesCategory(selector.TeamsChatsMail) +func (s TeamsChatsScope) IncludesCategory(cat teamsChatsCategory) bool { + return categoryMatches(s.Category(), cat) +} + +// returns true if the category is included in the scope's data type, +// and the value is set to Any(). +func (s TeamsChatsScope) IsAny(cat teamsChatsCategory) bool { + return IsAnyTarget(s, cat) +} + +// Get returns the data category in the scope. If the scope +// contains all data types for a user, it'll return the +// TeamsChatsUser category. +func (s TeamsChatsScope) Get(cat teamsChatsCategory) []string { + return getCatValue(s, cat) +} + +// commenting out for now, but leaving in place; it's likely to return when we add filters +// // sets a value by category to the scope. Only intended for internal use. +// func (s TeamsChatsScope) set(cat teamsChatsCategory, v []string, opts ...option) TeamsChatsScope { +// return set(s, cat, v, opts...) +// } + +// setDefaults ensures that contact folder, mail folder, and user category +// scopes all express `AnyTgt` for their child category types. +func (s TeamsChatsScope) setDefaults() { + switch s.Category() { + case TeamsChatsUser: + s[TeamsChatsChat.String()] = passAny + } +} + +// --------------------------------------------------------------------------- +// Backup Details Filtering +// --------------------------------------------------------------------------- + +// Reduce filters the entries in a details struct to only those that match the +// inclusions, filters, and exclusions in the selector. +func (s teamsChats) Reduce( + ctx context.Context, + deets *details.Details, + errs *fault.Bus, +) *details.Details { + return reduce[TeamsChatsScope]( + ctx, + deets, + s.Selector, + map[path.CategoryType]teamsChatsCategory{ + path.ChatsCategory: TeamsChatsChat, + }, + errs) +} + +// matchesInfo handles the standard behavior when comparing a scope and an TeamsChatsInfo +// returns true if the scope and info match for the provided category. +func (s TeamsChatsScope) matchesInfo(dii details.ItemInfo) bool { + info := dii.TeamsChats + if info == nil { + return false + } + + infoCat := s.InfoCategory() + + cfpc := teamsChatsCategoryFromItemType(info.ItemType) + if !typeAndCategoryMatches(infoCat, cfpc) { + return false + } + + i := "" + + switch infoCat { + case TeamsChatsInfoChatMember: + i = strings.Join(info.Chat.Members, ",") + case TeamsChatsInfoChatName: + i = info.Chat.Name + } + + return s.Matches(infoCat, i) +} + +// teamsChatsCategoryFromItemType interprets the category represented by the TeamsChatsInfo +// struct. Since every TeamsChatsInfo can hold all teamsChats data info, the exact +// type that the struct represents must be compared using its ItemType prop. +func teamsChatsCategoryFromItemType(pct details.ItemType) teamsChatsCategory { + switch pct { + case details.TeamsChat: + return TeamsChatsChat + } + + return TeamsChatsCategoryUnknown +} diff --git a/src/pkg/selectors/teamsChats_test.go b/src/pkg/selectors/teamsChats_test.go new file mode 100644 index 000000000..f3b695494 --- /dev/null +++ b/src/pkg/selectors/teamsChats_test.go @@ -0,0 +1,843 @@ +package selectors + +import ( + "strings" + "testing" + "time" + + "github.com/alcionai/clues" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/filters" + "github.com/alcionai/corso/src/pkg/path" +) + +type TeamsChatsSelectorSuite struct { + tester.Suite +} + +func TestTeamsChatsSelectorSuite(t *testing.T) { + suite.Run(t, &TeamsChatsSelectorSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *TeamsChatsSelectorSuite) TestNewTeamsChatsBackup() { + t := suite.T() + eb := NewTeamsChatsBackup(nil) + assert.Equal(t, eb.Service, ServiceTeamsChats) + assert.NotZero(t, eb.Scopes()) +} + +func (suite *TeamsChatsSelectorSuite) TestToTeamsChatsBackup() { + t := suite.T() + eb := NewTeamsChatsBackup(nil) + s := eb.Selector + eb, err := s.ToTeamsChatsBackup() + require.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, eb.Service, ServiceTeamsChats) + assert.NotZero(t, eb.Scopes()) +} + +func (suite *TeamsChatsSelectorSuite) TestNewTeamsChatsRestore() { + t := suite.T() + er := NewTeamsChatsRestore(nil) + assert.Equal(t, er.Service, ServiceTeamsChats) + assert.NotZero(t, er.Scopes()) +} + +func (suite *TeamsChatsSelectorSuite) TestToTeamsChatsRestore() { + t := suite.T() + eb := NewTeamsChatsRestore(nil) + s := eb.Selector + eb, err := s.ToTeamsChatsRestore() + require.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, eb.Service, ServiceTeamsChats) + assert.NotZero(t, eb.Scopes()) +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Exclude_TeamsChats() { + t := suite.T() + + const ( + user = "user" + folder = AnyTgt + c1 = "c1" + c2 = "c2" + ) + + sel := NewTeamsChatsBackup([]string{user}) + sel.Exclude(sel.Chats([]string{c1, c2})) + scopes := sel.Excludes + require.Len(t, scopes, 1) + + scopeMustHave( + t, + TeamsChatsScope(scopes[0]), + map[categorizer][]string{ + TeamsChatsChat: {c1, c2}, + }) +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Include_TeamsChats() { + t := suite.T() + + const ( + user = "user" + folder = AnyTgt + c1 = "c1" + c2 = "c2" + ) + + sel := NewTeamsChatsBackup([]string{user}) + sel.Include(sel.Chats([]string{c1, c2})) + scopes := sel.Includes + require.Len(t, scopes, 1) + + scopeMustHave( + t, + TeamsChatsScope(scopes[0]), + map[categorizer][]string{ + TeamsChatsChat: {c1, c2}, + }) + + assert.Equal(t, sel.Scopes()[0].Category(), TeamsChatsChat) +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Exclude_AllData() { + t := suite.T() + + const ( + u1 = "u1" + u2 = "u2" + ) + + sel := NewTeamsChatsBackup([]string{u1, u2}) + sel.Exclude(sel.AllData()) + scopes := sel.Excludes + require.Len(t, scopes, 1) + + for _, sc := range scopes { + if sc[scopeKeyCategory].Compare(TeamsChatsChat.String()) { + scopeMustHave( + t, + TeamsChatsScope(sc), + map[categorizer][]string{ + TeamsChatsChat: Any(), + }) + } + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsSelector_Include_AllData() { + t := suite.T() + + const ( + u1 = "u1" + u2 = "u2" + ) + + sel := NewTeamsChatsBackup([]string{u1, u2}) + sel.Include(sel.AllData()) + scopes := sel.Includes + require.Len(t, scopes, 1) + + for _, sc := range scopes { + if sc[scopeKeyCategory].Compare(TeamsChatsChat.String()) { + scopeMustHave( + t, + TeamsChatsScope(sc), + map[categorizer][]string{ + TeamsChatsChat: Any(), + }) + } + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsBackup_Scopes() { + eb := NewTeamsChatsBackup(Any()) + eb.Include(eb.AllData()) + + scopes := eb.Scopes() + assert.Len(suite.T(), scopes, 1) + + for _, sc := range scopes { + cat := sc.Category() + suite.Run(cat.String(), func() { + t := suite.T() + + switch sc.Category() { + case TeamsChatsChat: + assert.True(t, sc.IsAny(TeamsChatsChat)) + } + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_Category() { + table := []struct { + is teamsChatsCategory + expect teamsChatsCategory + check assert.ComparisonAssertionFunc + }{ + {TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown, assert.Equal}, + {TeamsChatsCategoryUnknown, TeamsChatsUser, assert.NotEqual}, + {TeamsChatsChat, TeamsChatsChat, assert.Equal}, + {TeamsChatsUser, TeamsChatsUser, assert.Equal}, + {TeamsChatsUser, TeamsChatsCategoryUnknown, assert.NotEqual}, + } + for _, test := range table { + suite.Run(test.is.String()+test.expect.String(), func() { + eb := NewTeamsChatsBackup(Any()) + eb.Includes = []scope{ + {scopeKeyCategory: filters.Identity(test.is.String())}, + } + scope := eb.Scopes()[0] + test.check(suite.T(), test.expect, scope.Category()) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_IncludesCategory() { + table := []struct { + is teamsChatsCategory + expect teamsChatsCategory + check assert.BoolAssertionFunc + }{ + {TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown, assert.False}, + {TeamsChatsCategoryUnknown, TeamsChatsUser, assert.True}, + {TeamsChatsUser, TeamsChatsUser, assert.True}, + {TeamsChatsUser, TeamsChatsCategoryUnknown, assert.True}, + } + for _, test := range table { + suite.Run(test.is.String()+test.expect.String(), func() { + eb := NewTeamsChatsBackup(Any()) + eb.Includes = []scope{ + {scopeKeyCategory: filters.Identity(test.is.String())}, + } + scope := eb.Scopes()[0] + test.check(suite.T(), scope.IncludesCategory(test.expect)) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_Get() { + eb := NewTeamsChatsBackup(Any()) + eb.Include(eb.AllData()) + + scopes := eb.Scopes() + + table := []teamsChatsCategory{ + TeamsChatsChat, + } + for _, test := range table { + suite.Run(test.String(), func() { + t := suite.T() + + for _, sc := range scopes { + switch sc.Category() { + case TeamsChatsChat: + assert.Equal(t, Any(), sc.Get(TeamsChatsChat)) + } + assert.Equal(t, None(), sc.Get(TeamsChatsCategoryUnknown)) + } + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesInfo() { + cs := NewTeamsChatsRestore(Any()) + + const ( + name = "smarf mcfnords" + member = "cooks@2many.smarf" + subject = "I have seen the fnords!" + ) + + var ( + now = time.Now() + future = now.Add(1 * time.Minute) + ) + + infoWith := func(itype details.ItemType) details.ItemInfo { + return details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: itype, + Chat: details.ChatInfo{ + CreatedAt: now, + HasExternalMembers: false, + LastMessageAt: future, + LastMessagePreview: "preview", + Members: []string{member}, + MessageCount: 1, + Name: name, + }, + }, + } + } + + table := []struct { + name string + itype details.ItemType + scope []TeamsChatsScope + expect assert.BoolAssertionFunc + }{ + {"chat with a different member", details.TeamsChat, cs.ChatMember("blarps"), assert.False}, + {"chat with the same member", details.TeamsChat, cs.ChatMember(member), assert.True}, + {"chat with a member submatch search", details.TeamsChat, cs.ChatMember(member[2:5]), assert.True}, + {"chat with a different name", details.TeamsChat, cs.ChatName("blarps"), assert.False}, + {"chat with the same name", details.TeamsChat, cs.ChatName(name), assert.True}, + {"chat with a subname search", details.TeamsChat, cs.ChatName(name[2:5]), assert.True}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + scopes := setScopesToDefault(test.scope) + for _, scope := range scopes { + test.expect(t, scope.matchesInfo(infoWith(test.itype))) + } + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsScope_MatchesPath() { + const ( + user = "userID" + chat = "chatID" + ) + + repoRef, err := path.Build("tid", user, path.TeamsChatsService, path.ChatsCategory, true, chat) + require.NoError(suite.T(), err, clues.ToCore(err)) + + var ( + loc = strings.Join([]string{chat}, "/") + short = "thisisahashofsomekind" + cs = NewTeamsChatsRestore(Any()) + ent = details.Entry{ + RepoRef: repoRef.String(), + ShortRef: short, + ItemRef: chat, + LocationRef: loc, + } + ) + + table := []struct { + name string + scope []TeamsChatsScope + shortRef string + expect assert.BoolAssertionFunc + }{ + {"all items", cs.AllData(), "", assert.True}, + {"all chats", cs.Chats(Any()), "", assert.True}, + {"no chats", cs.Chats(None()), "", assert.False}, + {"matching chats", cs.Chats([]string{chat}), "", assert.True}, + {"non-matching chats", cs.Chats([]string{"smarf"}), "", assert.False}, + {"one of multiple chats", cs.Chats([]string{"smarf", chat}), "", assert.True}, + {"chats short ref", cs.Chats([]string{short}), short, assert.True}, + {"non-leaf short ref", cs.Chats([]string{"foo"}), short, assert.False}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + scopes := setScopesToDefault(test.scope) + var aMatch bool + for _, scope := range scopes { + pvs, err := TeamsChatsChat.pathValues(repoRef, ent, Config{}) + require.NoError(t, err) + + if matchesPathValues(scope, TeamsChatsChat, pvs) { + aMatch = true + break + } + } + test.expect(t, aMatch) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsRestore_Reduce() { + chat, err := path.Build("tid", "uid", path.TeamsChatsService, path.ChatsCategory, true, "cid") + require.NoError(suite.T(), err, clues.ToCore(err)) + + toRR := func(p path.Path) string { + newElems := []string{} + + for _, e := range p.Folders() { + newElems = append(newElems, e+".d") + } + + joinedFldrs := strings.Join(newElems, "/") + + return stubRepoRef(p.Service(), p.Category(), p.ProtectedResource(), joinedFldrs, p.Item()) + } + + makeDeets := func(refs ...path.Path) *details.Details { + deets := &details.Details{ + DetailsModel: details.DetailsModel{ + Entries: []details.Entry{}, + }, + } + + for _, r := range refs { + itype := details.UnknownType + + switch r { + case chat: + itype = details.TeamsChat + } + + deets.Entries = append(deets.Entries, details.Entry{ + RepoRef: toRR(r), + // Don't escape because we assume nice paths. + LocationRef: r.Folder(false), + ItemInfo: details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: itype, + }, + }, + }) + } + + return deets + } + + table := []struct { + name string + deets *details.Details + makeSelector func() *TeamsChatsRestore + expect []string + }{ + { + "no refs", + makeDeets(), + func() *TeamsChatsRestore { + er := NewTeamsChatsRestore(Any()) + er.Include(er.AllData()) + return er + }, + []string{}, + }, + { + "chat only", + makeDeets(chat), + func() *TeamsChatsRestore { + er := NewTeamsChatsRestore(Any()) + er.Include(er.AllData()) + return er + }, + []string{toRR(chat)}, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + sel := test.makeSelector() + results := sel.Reduce(ctx, test.deets, fault.New(true)) + paths := results.Paths() + assert.Equal(t, test.expect, paths) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsRestore_Reduce_locationRef() { + var ( + chat = stubRepoRef(path.TeamsChatsService, path.ChatsCategory, "uid", "", "cid") + chatLocation = "chatname" + ) + + makeDeets := func(refs ...string) *details.Details { + deets := &details.Details{ + DetailsModel: details.DetailsModel{ + Entries: []details.Entry{}, + }, + } + + for _, r := range refs { + var ( + location string + itype = details.UnknownType + ) + + switch r { + case chat: + itype = details.TeamsChat + location = chatLocation + } + + deets.Entries = append(deets.Entries, details.Entry{ + RepoRef: r, + LocationRef: location, + ItemInfo: details.ItemInfo{ + TeamsChats: &details.TeamsChatsInfo{ + ItemType: itype, + }, + }, + }) + } + + return deets + } + + arr := func(s ...string) []string { + return s + } + + table := []struct { + name string + deets *details.Details + makeSelector func() *TeamsChatsRestore + expect []string + }{ + { + "no refs", + makeDeets(), + func() *TeamsChatsRestore { + er := NewTeamsChatsRestore(Any()) + er.Include(er.AllData()) + return er + }, + []string{}, + }, + { + "chat only", + makeDeets(chat), + func() *TeamsChatsRestore { + er := NewTeamsChatsRestore(Any()) + er.Include(er.AllData()) + return er + }, + arr(chat), + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + ctx, flush := tester.NewContext(t) + defer flush() + + sel := test.makeSelector() + results := sel.Reduce(ctx, test.deets, fault.New(true)) + paths := results.Paths() + assert.Equal(t, test.expect, paths) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestScopesByCategory() { + var ( + cs = NewTeamsChatsRestore(Any()) + teamsChats = cs.Chats(Any()) + ) + + type expect struct { + chat int + } + + type input []scope + + makeInput := func(cs ...[]TeamsChatsScope) []scope { + mss := []scope{} + + for _, sl := range cs { + for _, s := range sl { + mss = append(mss, scope(s)) + } + } + + return mss + } + cats := map[path.CategoryType]teamsChatsCategory{ + path.ChatsCategory: TeamsChatsChat, + } + + table := []struct { + name string + scopes input + expect expect + }{ + {"teamsChats only", makeInput(teamsChats), expect{1}}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + result := scopesByCategory[TeamsChatsScope](test.scopes, cats, false) + assert.Len(t, result[TeamsChatsChat], test.expect.chat) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestPasses() { + const ( + chatID = "chatID" + cat = TeamsChatsChat + ) + + short := "thisisahashofsomekind" + entry := details.Entry{ + ShortRef: short, + ItemRef: chatID, + } + + repoRef, err := path.Build("tid", "user", path.TeamsChatsService, path.ChatsCategory, true, chatID) + require.NoError(suite.T(), err, clues.ToCore(err)) + + var ( + cs = NewTeamsChatsRestore(Any()) + otherChat = setScopesToDefault(cs.Chats([]string{"smarf"})) + chat = setScopesToDefault(cs.Chats([]string{chatID})) + noChat = setScopesToDefault(cs.Chats(None())) + allChats = setScopesToDefault(cs.Chats(Any())) + ent = details.Entry{ + RepoRef: repoRef.String(), + } + ) + + table := []struct { + name string + excludes, filters, includes []TeamsChatsScope + expect assert.BoolAssertionFunc + }{ + {"empty", nil, nil, nil, assert.False}, + {"in Chat", nil, nil, chat, assert.True}, + {"in Other", nil, nil, otherChat, assert.False}, + {"in no Chat", nil, nil, noChat, assert.False}, + {"ex None filter chat", allChats, chat, nil, assert.False}, + {"ex Chat", chat, nil, allChats, assert.False}, + {"ex Other", otherChat, nil, allChats, assert.True}, + {"in and ex Chat", chat, nil, chat, assert.False}, + {"filter Chat", nil, chat, allChats, assert.True}, + {"filter Other", nil, otherChat, allChats, assert.False}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + pvs, err := cat.pathValues(repoRef, ent, Config{}) + require.NoError(t, err) + + result := passes( + cat, + pvs, + entry, + test.excludes, + test.filters, + test.includes) + test.expect(t, result) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestContains() { + target := "fnords" + + var ( + cs = NewTeamsChatsRestore(Any()) + noChat = setScopesToDefault(cs.Chats(None())) + does = setScopesToDefault(cs.Chats([]string{target})) + doesNot = setScopesToDefault(cs.Chats([]string{"smarf"})) + ) + + table := []struct { + name string + scopes []TeamsChatsScope + expect assert.BoolAssertionFunc + }{ + {"no chat", noChat, assert.False}, + {"does contain", does, assert.True}, + {"does not contain", doesNot, assert.False}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + var result bool + for _, scope := range test.scopes { + if scope.Matches(TeamsChatsChat, target) { + result = true + break + } + } + test.expect(t, result) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestIsAny() { + var ( + cs = NewTeamsChatsRestore(Any()) + specificChat = setScopesToDefault(cs.Chats([]string{"chat"})) + anyChat = setScopesToDefault(cs.Chats(Any())) + ) + + table := []struct { + name string + scopes []TeamsChatsScope + cat teamsChatsCategory + expect assert.BoolAssertionFunc + }{ + {"specific chat", specificChat, TeamsChatsChat, assert.False}, + {"any chat", anyChat, TeamsChatsChat, assert.True}, + {"wrong category", anyChat, TeamsChatsUser, assert.False}, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + var result bool + for _, scope := range test.scopes { + if scope.IsAny(test.cat) { + result = true + break + } + } + test.expect(t, result) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_leafCat() { + table := []struct { + cat teamsChatsCategory + expect teamsChatsCategory + }{ + {teamsChatsCategory("foo"), teamsChatsCategory("foo")}, + {TeamsChatsCategoryUnknown, TeamsChatsCategoryUnknown}, + {TeamsChatsUser, TeamsChatsUser}, + {TeamsChatsChat, TeamsChatsChat}, + } + for _, test := range table { + suite.Run(test.cat.String(), func() { + assert.Equal(suite.T(), test.expect, test.cat.leafCat(), test.cat.String()) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_PathValues() { + t := suite.T() + + chatPath, err := path.Build("tid", "u", path.TeamsChatsService, path.ChatsCategory, true, "chatitem.d") + require.NoError(t, err, clues.ToCore(err)) + + chatLoc, err := path.Build("tid", "u", path.TeamsChatsService, path.ChatsCategory, true, "chatitem") + require.NoError(t, err, clues.ToCore(err)) + + var ( + chatMap = map[categorizer][]string{ + TeamsChatsChat: {chatPath.Item(), "chat-short"}, + } + chatOnlyNameMap = map[categorizer][]string{ + TeamsChatsChat: {"chat-short"}, + } + ) + + table := []struct { + cat teamsChatsCategory + path path.Path + loc path.Path + short string + expect map[categorizer][]string + expectOnlyName map[categorizer][]string + }{ + {TeamsChatsChat, chatPath, chatLoc, "chat-short", chatMap, chatOnlyNameMap}, + } + for _, test := range table { + suite.Run(string(test.cat), func() { + t := suite.T() + ent := details.Entry{ + RepoRef: test.path.String(), + ShortRef: test.short, + LocationRef: test.loc.Folder(true), + ItemRef: test.path.Item(), + } + + pvs, err := test.cat.pathValues(test.path, ent, Config{}) + require.NoError(t, err) + + for k := range test.expect { + assert.ElementsMatch(t, test.expect[k], pvs[k]) + } + + pvs, err = test.cat.pathValues(test.path, ent, Config{OnlyMatchItemNames: true}) + require.NoError(t, err) + + for k := range test.expectOnlyName { + assert.ElementsMatch(t, test.expectOnlyName[k], pvs[k], k) + } + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestTeamsChatsCategory_PathKeys() { + chat := []categorizer{TeamsChatsChat} + user := []categorizer{TeamsChatsUser} + + var empty []categorizer + + table := []struct { + cat teamsChatsCategory + expect []categorizer + }{ + {TeamsChatsCategoryUnknown, empty}, + {TeamsChatsChat, chat}, + {TeamsChatsUser, user}, + } + for _, test := range table { + suite.Run(string(test.cat), func() { + assert.Equal(suite.T(), test.cat.pathKeys(), test.expect) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestCategoryFromItemType() { + table := []struct { + name string + input details.ItemType + expect teamsChatsCategory + }{ + { + name: "chat", + input: details.TeamsChat, + expect: TeamsChatsChat, + }, + { + name: "unknown", + input: details.UnknownType, + expect: TeamsChatsCategoryUnknown, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + result := teamsChatsCategoryFromItemType(test.input) + assert.Equal(t, test.expect, result) + }) + } +} + +func (suite *TeamsChatsSelectorSuite) TestCategory_PathType() { + table := []struct { + cat teamsChatsCategory + pathType path.CategoryType + }{ + {TeamsChatsCategoryUnknown, path.UnknownCategory}, + {TeamsChatsChat, path.ChatsCategory}, + {TeamsChatsUser, path.UnknownCategory}, + } + for _, test := range table { + suite.Run(test.cat.String(), func() { + assert.Equal(suite.T(), test.pathType, test.cat.PathType()) + }) + } +} diff --git a/src/pkg/services/m365/api/teamdChats_test.go b/src/pkg/services/m365/api/teamdChats_test.go new file mode 100644 index 000000000..db079420e --- /dev/null +++ b/src/pkg/services/m365/api/teamdChats_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "testing" + "time" + + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/pkg/backup/details" +) + +type ChatsAPIUnitSuite struct { + tester.Suite +} + +func TestChatsAPIUnitSuite(t *testing.T) { + suite.Run(t, &ChatsAPIUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *ChatsAPIUnitSuite) TestChatsInfo() { + start := time.Now() + + tests := []struct { + name string + chatAndExpected func() (models.Chatable, *details.TeamsChatsInfo) + }{ + { + name: "Empty chat", + chatAndExpected: func() (models.Chatable, *details.TeamsChatsInfo) { + chat := models.NewChat() + + i := &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + Chat: details.ChatInfo{}, + } + + return chat, i + }, + }, + { + name: "All fields", + chatAndExpected: func() (models.Chatable, *details.TeamsChatsInfo) { + now := time.Now() + then := now.Add(1 * time.Hour) + + chat := models.NewChat() + chat.SetTopic(ptr.To("Hello world")) + chat.SetCreatedDateTime(&now) + chat.SetLastUpdatedDateTime(&then) + + i := &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + Chat: details.ChatInfo{ + Name: "Hello world", + }, + } + + return chat, i + }, + }, + } + for _, test := range tests { + suite.Run(test.name, func() { + t := suite.T() + chat, expected := test.chatAndExpected() + result := TeamsChatInfo(chat) + + assert.Equal(t, expected.Chat.Name, result.Chat.Name) + + expectLastUpdated := chat.GetLastUpdatedDateTime() + if expectLastUpdated != nil { + assert.Equal(t, ptr.Val(expectLastUpdated), result.Modified) + } else { + assert.True(t, result.Modified.After(start)) + } + + expectCreated := chat.GetCreatedDateTime() + if expectCreated != nil { + assert.Equal(t, ptr.Val(expectCreated), result.Chat.CreatedAt) + } else { + assert.True(t, result.Chat.CreatedAt.After(start)) + } + }) + } +} diff --git a/src/pkg/services/m365/api/teamsChats.go b/src/pkg/services/m365/api/teamsChats.go new file mode 100644 index 000000000..1d3b7d0c6 --- /dev/null +++ b/src/pkg/services/m365/api/teamsChats.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + + "github.com/microsoftgraph/msgraph-sdk-go/chats" + "github.com/microsoftgraph/msgraph-sdk-go/models" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/pkg/backup/details" + "github.com/alcionai/corso/src/pkg/services/m365/api/graph" +) + +// --------------------------------------------------------------------------- +// controller +// --------------------------------------------------------------------------- + +func (c Client) Chats() Chats { + return Chats{c} +} + +// Chats is an interface-compliant provider of the client. +type Chats struct { + Client +} + +// --------------------------------------------------------------------------- +// Chats +// --------------------------------------------------------------------------- + +func (c Chats) GetChatByID( + ctx context.Context, + chatID string, + cc CallConfig, +) (models.Chatable, *details.TeamsChatsInfo, error) { + config := &chats.ChatItemRequestBuilderGetRequestConfiguration{ + QueryParameters: &chats.ChatItemRequestBuilderGetQueryParameters{}, + } + + if len(cc.Select) > 0 { + config.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + config.QueryParameters.Expand = cc.Expand + } + + resp, err := c.Stable. + Client(). + Chats(). + ByChatId(chatID). + Get(ctx, config) + if err != nil { + return nil, nil, graph.Stack(ctx, err) + } + + return resp, TeamsChatInfo(resp), nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func TeamsChatInfo(chat models.Chatable) *details.TeamsChatsInfo { + return &details.TeamsChatsInfo{ + ItemType: details.TeamsChat, + Modified: ptr.OrNow(chat.GetLastUpdatedDateTime()), + + Chat: details.ChatInfo{ + CreatedAt: ptr.OrNow(chat.GetCreatedDateTime()), + Name: ptr.Val(chat.GetTopic()), + }, + } +} diff --git a/src/pkg/services/m365/api/teamsChats_pager.go b/src/pkg/services/m365/api/teamsChats_pager.go new file mode 100644 index 000000000..0268530ee --- /dev/null +++ b/src/pkg/services/m365/api/teamsChats_pager.go @@ -0,0 +1,172 @@ +package api + +import ( + "context" + + "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/chats" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/microsoftgraph/msgraph-sdk-go/users" + + "github.com/alcionai/corso/src/pkg/services/m365/api/graph" + "github.com/alcionai/corso/src/pkg/services/m365/api/pagers" +) + +// --------------------------------------------------------------------------- +// chat message pager +// --------------------------------------------------------------------------- + +// delta queries are not supported +var _ pagers.NonDeltaHandler[models.ChatMessageable] = &chatMessagePageCtrl{} + +type chatMessagePageCtrl struct { + chatID string + gs graph.Servicer + builder *chats.ItemMessagesRequestBuilder + options *chats.ItemMessagesRequestBuilderGetRequestConfiguration +} + +func (p *chatMessagePageCtrl) SetNextLink(nextLink string) { + p.builder = chats.NewItemMessagesRequestBuilder(nextLink, p.gs.Adapter()) +} + +func (p *chatMessagePageCtrl) GetPage( + ctx context.Context, +) (pagers.NextLinkValuer[models.ChatMessageable], error) { + resp, err := p.builder.Get(ctx, p.options) + return resp, graph.Stack(ctx, err).OrNil() +} + +func (p *chatMessagePageCtrl) ValidModTimes() bool { + return true +} + +func (c Chats) NewChatMessagePager( + chatID string, + cc CallConfig, +) *chatMessagePageCtrl { + builder := c.Stable. + Client(). + Chats(). + ByChatId(chatID). + Messages() + + options := &chats.ItemMessagesRequestBuilderGetRequestConfiguration{ + QueryParameters: &chats.ItemMessagesRequestBuilderGetQueryParameters{}, + Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)), + } + + if len(cc.Select) > 0 { + options.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + options.QueryParameters.Expand = cc.Expand + } + + return &chatMessagePageCtrl{ + chatID: chatID, + builder: builder, + gs: c.Stable, + options: options, + } +} + +// GetChatMessages fetches a delta of all messages in the chat. +func (c Chats) GetChatMessages( + ctx context.Context, + chatID string, + cc CallConfig, +) ([]models.ChatMessageable, error) { + ctx = clues.Add(ctx, "chat_id", chatID) + pager := c.NewChatMessagePager(chatID, cc) + items, err := pagers.BatchEnumerateItems[models.ChatMessageable](ctx, pager) + + return items, graph.Stack(ctx, err).OrNil() +} + +// GetChatMessageIDs fetches a delta of all messages in the chat. +// returns two maps: addedItems, deletedItems +func (c Chats) GetChatMessageIDs( + ctx context.Context, + chatID string, + cc CallConfig, +) (pagers.AddedAndRemoved, error) { + aar, err := pagers.GetAddedAndRemovedItemIDs[models.ChatMessageable]( + ctx, + c.NewChatMessagePager(chatID, CallConfig{}), + nil, + "", + false, // delta queries are not supported + 0, + pagers.AddedAndRemovedByDeletedDateTime[models.ChatMessageable], + IsNotSystemMessage) + + return aar, clues.Stack(err).OrNil() +} + +// --------------------------------------------------------------------------- +// chat pager +// --------------------------------------------------------------------------- + +var _ pagers.NonDeltaHandler[models.Chatable] = &chatPageCtrl{} + +type chatPageCtrl struct { + gs graph.Servicer + builder *users.ItemChatsRequestBuilder + options *users.ItemChatsRequestBuilderGetRequestConfiguration +} + +func (p *chatPageCtrl) SetNextLink(nextLink string) { + p.builder = users.NewItemChatsRequestBuilder(nextLink, p.gs.Adapter()) +} + +func (p *chatPageCtrl) GetPage( + ctx context.Context, +) (pagers.NextLinkValuer[models.Chatable], error) { + resp, err := p.builder.Get(ctx, p.options) + return resp, graph.Stack(ctx, err).OrNil() +} + +func (p *chatPageCtrl) ValidModTimes() bool { + return false +} + +func (c Chats) NewChatPager( + userID string, + cc CallConfig, +) *chatPageCtrl { + options := &users.ItemChatsRequestBuilderGetRequestConfiguration{ + QueryParameters: &users.ItemChatsRequestBuilderGetQueryParameters{}, + Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)), + } + + if len(cc.Select) > 0 { + options.QueryParameters.Select = cc.Select + } + + if len(cc.Expand) > 0 { + options.QueryParameters.Expand = cc.Expand + } + + res := &chatPageCtrl{ + gs: c.Stable, + options: options, + builder: c.Stable. + Client(). + Users(). + ByUserId(userID). + Chats(), + } + + return res +} + +// GetChats fetches all chats in the team. +func (c Chats) GetChats( + ctx context.Context, + userID string, + cc CallConfig, +) ([]models.Chatable, error) { + return pagers.BatchEnumerateItems[models.Chatable](ctx, c.NewChatPager(userID, cc)) +} diff --git a/src/pkg/services/m365/api/teamsChats_pager_test.go b/src/pkg/services/m365/api/teamsChats_pager_test.go new file mode 100644 index 000000000..c74d5f733 --- /dev/null +++ b/src/pkg/services/m365/api/teamsChats_pager_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "testing" + + "github.com/alcionai/clues" + "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/ptr" + "github.com/alcionai/corso/src/internal/tester" + "github.com/alcionai/corso/src/internal/tester/tconfig" +) + +type ChatsPagerIntgSuite struct { + tester.Suite + its intgTesterSetup +} + +func TestChatsPagerIntgSuite(t *testing.T) { + suite.Run(t, &ChatsPagerIntgSuite{ + Suite: tester.NewIntegrationSuite( + t, + [][]string{tconfig.M365AcctCredEnvs}), + }) +} + +func (suite *ChatsPagerIntgSuite) SetupSuite() { + suite.its = newIntegrationTesterSetup(suite.T()) +} + +func (suite *ChatsPagerIntgSuite) TestEnumerateChats() { + var ( + t = suite.T() + ac = suite.its.ac.Chats() + ) + + ctx, flush := tester.NewContext(t) + defer flush() + + cc := CallConfig{ + Expand: []string{"lastMessagePreview"}, + } + + chats, err := ac.GetChats(ctx, suite.its.user.id, cc) + require.NoError(t, err, clues.ToCore(err)) + require.NotEmpty(t, chats) + + for _, chat := range chats { + chatID := ptr.Val(chat.GetId()) + + suite.Run("chat_"+chatID, func() { + testGetChatByID(suite.T(), ac, chatID) + }) + + suite.Run("chat_messages_"+chatID, func() { + testEnumerateChatMessages( + suite.T(), + ac, + chatID, + chat.GetLastMessagePreview()) + }) + } +} + +func testGetChatByID( + t *testing.T, + ac Chats, + chatID string, +) { + ctx, flush := tester.NewContext(t) + defer flush() + + cc := CallConfig{} + + chat, _, err := ac.GetChatByID(ctx, chatID, cc) + require.NoError(t, err, clues.ToCore(err)) + require.NotNil(t, chat) +} + +func testEnumerateChatMessages( + t *testing.T, + ac Chats, + chatID string, + lastMessagePreview models.ChatMessageInfoable, +) { + ctx, flush := tester.NewContext(t) + defer flush() + + cc := CallConfig{} + + messages, err := ac.GetChatMessages(ctx, chatID, cc) + require.NoError(t, err, clues.ToCore(err)) + + var lastID string + if lastMessagePreview != nil { + lastID = ptr.Val(lastMessagePreview.GetId()) + } + + for _, msg := range messages { + msgID := ptr.Val(msg.GetId()) + + assert.Equal( + t, + chatID, + ptr.Val(msg.GetChatId()), + "message:", + msgID) + + if msgID == lastID { + previewContent := ptr.Val(lastMessagePreview.GetBody().GetContent()) + msgContent := ptr.Val(msg.GetBody().GetContent()) + + previewContent = replaceAttachmentMarkup(previewContent, nil) + msgContent = replaceAttachmentMarkup(msgContent, nil) + + assert.Equal( + t, + previewContent, + msgContent) + } + } +}