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..6ad54207b --- /dev/null +++ b/src/pkg/services/m365/api/teamsChats_pager_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "regexp" + "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) +} + +var attachmentHtmlRegexp = regexp.MustCompile("") + +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) + } + } +}