boilerplate introduction of the conversations api (#4570)

adds basic get requests for the conversations api set to the api package.  Plust some other minor change for formatting, correctness, or necessity.

---

#### Does this PR need a docs update or release note?

- [x]  No

#### Type of change

- [x] 🌻 Feature

#### Issue(s)

* #4536 

#### Test Plan

- [x] 💪 Manual
- [x]  Unit test
- [x] 💚 E2E
This commit is contained in:
Keepers 2023-11-06 13:05:52 -07:00 committed by GitHub
parent dddd8ac722
commit 3261eefda2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 777 additions and 19 deletions

View File

@ -738,6 +738,7 @@ func verifyExtensionData(
case path.OneDriveService:
detailsSize = itemInfo.OneDrive.Size
case path.GroupsService:
// FIXME: needs update for message.
detailsSize = itemInfo.Groups.Size
default:
assert.Fail(t, "unrecognized data type")

View File

@ -42,8 +42,11 @@ type GroupsInfo struct {
Modified time.Time `json:"modified,omitempty"`
// Channels Specific
Message ChannelMessageInfo `json:"message"`
LastReply ChannelMessageInfo `json:"lastReply"`
Message ChannelMessageInfo `json:"message,omitempty"`
LastReply ChannelMessageInfo `json:"lastReply,omitempty"`
// Conversations Specific
Post ConversationPostInfo `json:"post,omitempty"`
// SharePoint specific
Created time.Time `json:"created,omitempty"`
@ -57,6 +60,14 @@ type GroupsInfo struct {
WebURL string `json:"webURL,omitempty"`
}
type ConversationPostInfo struct {
CreatedAt time.Time `json:"createdAt,omitempty"`
Creator string `json:"creator,omitempty"`
Preview string `json:"preview,omitempty"`
Size int64 `json:"size,omitempty"`
Subject string `json:"subject,omitempty"`
}
type ChannelMessageInfo struct {
AttachmentNames []string `json:"attachmentNames,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`

View File

@ -40,6 +40,7 @@ const (
// Groups/Teams(40x)
GroupsChannelMessage ItemType = 401
GroupsConversationPost ItemType = 402
)
func UpdateItem(item *ItemInfo, newLocPath *path.Builder) {

View File

@ -52,11 +52,8 @@ func (c Channels) GetChannel(
Channels().
ByChannelId(containerID).
Get(ctx, config)
if err != nil {
return nil, graph.Stack(ctx, err)
}
return resp, nil
return resp, graph.Stack(ctx, err).OrNil()
}
// GetChannelByName fetches a channel by name

View File

@ -76,7 +76,6 @@ func (c Channels) NewChannelMessagePager(
}
// GetChannelMessages fetches a delta of all messages in the channel.
// returns two maps: addedItems, deletedItems
func (c Channels) GetChannelMessages(
ctx context.Context,
teamID, channelID string,

View File

@ -0,0 +1,130 @@
package api
import (
"context"
"github.com/alcionai/clues"
"github.com/jaytaylor/html2text"
"github.com/microsoftgraph/msgraph-sdk-go/groups"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/common/str"
"github.com/alcionai/corso/src/internal/m365/graph"
"github.com/alcionai/corso/src/pkg/backup/details"
)
// ---------------------------------------------------------------------------
// controller
// ---------------------------------------------------------------------------
func (c Client) Conversations() Conversations {
return Conversations{c}
}
// Conversations is an interface-compliant provider of the client.
type Conversations struct {
Client
}
// ---------------------------------------------------------------------------
// Item (conversation thread post)
// ---------------------------------------------------------------------------
func (c Conversations) GetConversationPost(
ctx context.Context,
groupID, conversationID, threadID, postID string,
cc CallConfig,
) (models.Postable, *details.GroupsInfo, error) {
config := &groups.ItemConversationsItemThreadsItemPostsPostItemRequestBuilderGetRequestConfiguration{
QueryParameters: &groups.ItemConversationsItemThreadsItemPostsPostItemRequestBuilderGetQueryParameters{},
}
if len(cc.Select) > 0 {
config.QueryParameters.Select = cc.Select
}
if len(cc.Expand) > 0 {
config.QueryParameters.Expand = append(config.QueryParameters.Expand, cc.Expand...)
}
post, err := c.Stable.
Client().
Groups().
ByGroupId(groupID).
Conversations().
ByConversationId(conversationID).
Threads().
ByConversationThreadId(threadID).
Posts().
ByPostId(postID).
Get(ctx, config)
if err != nil {
return nil, nil, graph.Stack(ctx, err)
}
return post, conversationPostInfo(post), graph.Stack(ctx, err).OrNil()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func conversationPostInfo(
post models.Postable,
) *details.GroupsInfo {
if post == nil {
return nil
}
preview, contentLen, err := getConversationPostContentPreview(post)
if err != nil {
preview = "malformed or unparseable html" + preview
}
var sender string
if post.GetSender() != nil && post.GetSender().GetEmailAddress() != nil {
sender = ptr.Val(post.GetSender().GetEmailAddress().GetAddress())
}
size := contentLen
for _, a := range post.GetAttachments() {
size += int64(ptr.Val(a.GetSize()))
}
cpi := details.ConversationPostInfo{
CreatedAt: ptr.Val(post.GetCreatedDateTime()),
Creator: sender,
Preview: preview,
Size: size,
}
return &details.GroupsInfo{
ItemType: details.GroupsConversationPost,
Modified: ptr.Val(post.GetLastModifiedDateTime()),
Post: cpi,
}
}
func getConversationPostContentPreview(post models.Postable) (string, int64, error) {
content, origSize, err := stripConversationPostHTML(post)
return str.Preview(content, 128), origSize, clues.Stack(err).OrNil()
}
func stripConversationPostHTML(post models.Postable) (string, int64, error) {
var (
content string
origSize int64
)
if post.GetBody() != nil {
content = ptr.Val(post.GetBody().GetContent())
}
origSize = int64(len(content))
content, err := html2text.FromString(content)
return content, origSize, clues.Stack(err).OrNil()
}

View File

@ -0,0 +1,232 @@
package api
import (
"context"
"github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/groups"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/alcionai/corso/src/internal/m365/graph"
"github.com/alcionai/corso/src/pkg/services/m365/api/pagers"
)
// ---------------------------------------------------------------------------
// conversation pager
// ---------------------------------------------------------------------------
var _ pagers.NonDeltaHandler[models.Conversationable] = &conversationsPageCtrl{}
type conversationsPageCtrl struct {
resourceID string
gs graph.Servicer
builder *groups.ItemConversationsRequestBuilder
options *groups.ItemConversationsRequestBuilderGetRequestConfiguration
}
func (p *conversationsPageCtrl) SetNextLink(nextLink string) {
p.builder = groups.NewItemConversationsRequestBuilder(nextLink, p.gs.Adapter())
}
func (p *conversationsPageCtrl) GetPage(
ctx context.Context,
) (pagers.NextLinkValuer[models.Conversationable], error) {
resp, err := p.builder.Get(ctx, p.options)
return resp, graph.Stack(ctx, err).OrNil()
}
func (p *conversationsPageCtrl) ValidModTimes() bool {
return true
}
func (c Conversations) NewConversationsPager(
groupID string,
cc CallConfig,
) *conversationsPageCtrl {
builder := c.Stable.
Client().
Groups().
ByGroupId(groupID).
Conversations()
options := &groups.ItemConversationsRequestBuilderGetRequestConfiguration{
QueryParameters: &groups.ItemConversationsRequestBuilderGetQueryParameters{},
Headers: newPreferHeaders(preferPageSize(maxNonDeltaPageSize)),
}
if len(cc.Select) > 0 {
options.QueryParameters.Select = cc.Select
}
return &conversationsPageCtrl{
resourceID: groupID,
builder: builder,
gs: c.Stable,
options: options,
}
}
// GetConversations fetches all conversations in the group.
func (c Conversations) GetConversations(
ctx context.Context,
groupID string,
cc CallConfig,
) ([]models.Conversationable, error) {
pager := c.NewConversationsPager(groupID, cc)
items, err := pagers.BatchEnumerateItems[models.Conversationable](ctx, pager)
return items, graph.Stack(ctx, err).OrNil()
}
// ---------------------------------------------------------------------------
// conversation thread pager
// ---------------------------------------------------------------------------
var _ pagers.NonDeltaHandler[models.ConversationThreadable] = &conversationThreadsPageCtrl{}
type conversationThreadsPageCtrl struct {
resourceID, conversationID string
gs graph.Servicer
builder *groups.ItemConversationsItemThreadsRequestBuilder
options *groups.ItemConversationsItemThreadsRequestBuilderGetRequestConfiguration
}
func (p *conversationThreadsPageCtrl) SetNextLink(nextLink string) {
p.builder = groups.NewItemConversationsItemThreadsRequestBuilder(nextLink, p.gs.Adapter())
}
func (p *conversationThreadsPageCtrl) GetPage(
ctx context.Context,
) (pagers.NextLinkValuer[models.ConversationThreadable], error) {
resp, err := p.builder.Get(ctx, p.options)
return resp, graph.Stack(ctx, err).OrNil()
}
func (p *conversationThreadsPageCtrl) ValidModTimes() bool {
return true
}
func (c Conversations) NewConversationThreadsPager(
groupID, conversationID string,
cc CallConfig,
) *conversationThreadsPageCtrl {
builder := c.Stable.
Client().
Groups().
ByGroupId(groupID).
Conversations().
ByConversationId(conversationID).
Threads()
options := &groups.ItemConversationsItemThreadsRequestBuilderGetRequestConfiguration{
QueryParameters: &groups.ItemConversationsItemThreadsRequestBuilderGetQueryParameters{},
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 &conversationThreadsPageCtrl{
resourceID: groupID,
conversationID: conversationID,
builder: builder,
gs: c.Stable,
options: options,
}
}
// GetConversations fetches all conversations in the group.
func (c Conversations) GetConversationThreads(
ctx context.Context,
groupID, conversationID string,
cc CallConfig,
) ([]models.ConversationThreadable, error) {
ctx = clues.Add(ctx, "conversation_id", conversationID)
pager := c.NewConversationThreadsPager(groupID, conversationID, cc)
items, err := pagers.BatchEnumerateItems[models.ConversationThreadable](ctx, pager)
return items, graph.Stack(ctx, err).OrNil()
}
// ---------------------------------------------------------------------------
// conversation thread post pager
// ---------------------------------------------------------------------------
var _ pagers.NonDeltaHandler[models.Postable] = &conversationThreadPostsPageCtrl{}
type conversationThreadPostsPageCtrl struct {
resourceID, conversationID, threadID string
gs graph.Servicer
builder *groups.ItemConversationsItemThreadsItemPostsRequestBuilder
options *groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetRequestConfiguration
}
func (p *conversationThreadPostsPageCtrl) SetNextLink(nextLink string) {
p.builder = groups.NewItemConversationsItemThreadsItemPostsRequestBuilder(nextLink, p.gs.Adapter())
}
func (p *conversationThreadPostsPageCtrl) GetPage(
ctx context.Context,
) (pagers.NextLinkValuer[models.Postable], error) {
resp, err := p.builder.Get(ctx, p.options)
return resp, graph.Stack(ctx, err).OrNil()
}
func (p *conversationThreadPostsPageCtrl) ValidModTimes() bool {
return true
}
func (c Conversations) NewConversationThreadPostsPager(
groupID, conversationID, threadID string,
cc CallConfig,
) *conversationThreadPostsPageCtrl {
builder := c.Stable.
Client().
Groups().
ByGroupId(groupID).
Conversations().
ByConversationId(conversationID).
Threads().
ByConversationThreadId(threadID).
Posts()
options := &groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetRequestConfiguration{
QueryParameters: &groups.ItemConversationsItemThreadsItemPostsRequestBuilderGetQueryParameters{},
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 &conversationThreadPostsPageCtrl{
resourceID: groupID,
conversationID: conversationID,
threadID: threadID,
builder: builder,
gs: c.Stable,
options: options,
}
}
// GetConversations fetches all conversations in the group.
func (c Conversations) GetConversationThreadPosts(
ctx context.Context,
groupID, conversationID, threadID string,
cc CallConfig,
) ([]models.Postable, error) {
ctx = clues.Add(ctx, "conversation_id", conversationID)
pager := c.NewConversationThreadPostsPager(groupID, conversationID, threadID, cc)
items, err := pagers.BatchEnumerateItems[models.Postable](ctx, pager)
return items, graph.Stack(ctx, err).OrNil()
}

View File

@ -0,0 +1,120 @@
package api
import (
"testing"
"github.com/alcionai/clues"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"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 ConversationsPagerIntgSuite struct {
tester.Suite
its intgTesterSetup
}
func TestConversationsPagerIntgSuite(t *testing.T) {
suite.Run(t, &ConversationsPagerIntgSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tconfig.M365AcctCredEnvs}),
})
}
func (suite *ConversationsPagerIntgSuite) SetupSuite() {
suite.its = newIntegrationTesterSetup(suite.T())
}
func (suite *ConversationsPagerIntgSuite) TestEnumerateConversations_withThreadsAndPosts() {
var (
t = suite.T()
ac = suite.its.ac.Conversations()
)
ctx, flush := tester.NewContext(t)
defer flush()
convs, err := ac.GetConversations(ctx, suite.its.group.id, CallConfig{})
require.NoError(t, err, clues.ToCore(err))
require.NotEmpty(t, convs)
for _, conv := range convs {
threads := testEnumerateConvThreads(suite, conv)
for _, thread := range threads {
posts := testEnumerateConvPosts(suite, conv, thread)
for _, post := range posts {
testGetPostByID(suite, conv, thread, post)
}
}
}
}
func testEnumerateConvThreads(
suite *ConversationsPagerIntgSuite,
conv models.Conversationable,
) []models.ConversationThreadable {
var threads []models.ConversationThreadable
suite.Run("threads", func() {
var (
t = suite.T()
ac = suite.its.ac.Conversations()
err error
)
ctx, flush := tester.NewContext(t)
defer flush()
threads, err = ac.GetConversationThreads(
ctx,
suite.its.group.id,
ptr.Val(conv.GetId()),
CallConfig{})
require.NoError(t, err, clues.ToCore(err))
// to the best of our knowledge, there's only ever one
// thread per conversation. Even trying to create a new
// thread within a conversation will create an entirely
// new conversation. We want this test to fail as a potential
// identifier if that changes on us.
require.Equal(t, 1, len(threads))
})
return threads
}
func testEnumerateConvPosts(
suite *ConversationsPagerIntgSuite,
conv models.Conversationable,
thread models.ConversationThreadable,
) []models.Postable {
var posts []models.Postable
suite.Run("posts", func() {
var (
t = suite.T()
ac = suite.its.ac.Conversations()
err error
)
ctx, flush := tester.NewContext(t)
defer flush()
posts, err = ac.GetConversationThreadPosts(
ctx,
suite.its.group.id,
ptr.Val(conv.GetId()),
ptr.Val(thread.GetId()),
CallConfig{})
require.NoError(t, err, clues.ToCore(err))
require.NotEmpty(t, posts)
})
return posts
}

View File

@ -0,0 +1,267 @@
package api
import (
"testing"
"time"
"github.com/alcionai/clues"
"github.com/h2non/gock"
"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"
"github.com/alcionai/corso/src/pkg/backup/details"
)
// called by the pager test, since it is already enumerating
// posts.
func testGetPostByID(
suite *ConversationsPagerIntgSuite,
conv models.Conversationable,
thread models.ConversationThreadable,
post models.Postable,
) {
suite.Run("post_by_id", func() {
var (
t = suite.T()
ac = suite.its.ac.Conversations()
)
ctx, flush := tester.NewContext(t)
defer flush()
resp, _, err := ac.GetConversationPost(
ctx,
suite.its.group.id,
ptr.Val(conv.GetId()),
ptr.Val(thread.GetId()),
ptr.Val(post.GetId()),
CallConfig{})
require.NoError(t, err, clues.ToCore(err))
require.Equal(t, ptr.Val(post.GetId()), ptr.Val(resp.GetId()))
})
}
type ConversationsAPIUnitSuite struct {
tester.Suite
}
func TestConversationsAPIUnitSuite(t *testing.T) {
suite.Run(t, &ConversationsAPIUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *ConversationsAPIUnitSuite) TestConversationPostInfo() {
var (
now = time.Now()
content = "content"
body = models.NewItemBody()
)
body.SetContent(ptr.To(content))
tests := []struct {
name string
postAndInfo func() (models.Postable, *details.GroupsInfo)
}{
{
name: "No body",
postAndInfo: func() (models.Postable, *details.GroupsInfo) {
post := models.NewPost()
post.SetCreatedDateTime(&now)
post.SetLastModifiedDateTime(&now)
sender := "foo@bar.com"
sea := models.NewEmailAddress()
sea.SetAddress(&sender)
recipient := models.NewRecipient()
recipient.SetEmailAddress(sea)
post.SetSender(recipient)
iden := models.NewIdentity()
iden.SetDisplayName(ptr.To("user"))
i := &details.GroupsInfo{
ItemType: details.GroupsConversationPost,
Modified: now,
Post: details.ConversationPostInfo{
CreatedAt: now,
Creator: "foo@bar.com",
Preview: "",
Size: 0,
// TODO: feed the subject in from the conversation
Subject: "",
},
}
return post, i
},
},
}
for _, test := range tests {
suite.Run(test.name, func() {
t := suite.T()
chMsg, expected := test.postAndInfo()
result := conversationPostInfo(chMsg)
assert.Equal(t, expected, result)
})
}
}
type ConversationAPIIntgSuite struct {
tester.Suite
its intgTesterSetup
}
// We do end up mocking the actual request, but creating the rest
// similar to full integration tests.
func TestConversationAPIIntgSuite(t *testing.T) {
suite.Run(t, &ConversationAPIIntgSuite{
Suite: tester.NewIntegrationSuite(
t,
[][]string{tconfig.M365AcctCredEnvs}),
})
}
func (suite *ConversationAPIIntgSuite) SetupSuite() {
suite.its = newIntegrationTesterSetup(suite.T())
}
func (suite *ConversationAPIIntgSuite) TestConversations_attachmentListDownload() {
pid := "fake-post-id"
aid := "fake-attachment-id"
tests := []struct {
name string
setupf func()
attachmentCount int
size int64
expect assert.ErrorAssertionFunc
}{
{
name: "no attachments",
setupf: func() {
itm := models.NewPost()
itm.SetId(&pid)
interceptV1Path(
"groups",
"group",
"conversations",
"conv",
"threads",
"thread",
"posts",
pid).
Reply(200).
JSON(requireParseableToMap(suite.T(), itm))
},
expect: assert.NoError,
},
{
name: "fetch with attachment",
setupf: func() {
itm := models.NewPost()
itm.SetId(&pid)
itm.SetHasAttachments(ptr.To(true))
attch := models.NewAttachment()
attch.SetSize(ptr.To[int32](50))
itm.SetAttachments([]models.Attachmentable{attch})
interceptV1Path(
"groups",
"group",
"conversations",
"conv",
"threads",
"thread",
"posts",
pid).
Reply(200).
JSON(requireParseableToMap(suite.T(), itm))
},
attachmentCount: 1,
size: 50,
expect: assert.NoError,
},
{
name: "fetch multiple individual attachments",
setupf: func() {
truthy := true
itm := models.NewPost()
itm.SetId(&pid)
itm.SetHasAttachments(&truthy)
attch := models.NewAttachment()
attch.SetId(&aid)
attch.SetSize(ptr.To[int32](200))
itm.SetAttachments([]models.Attachmentable{attch, attch, attch, attch, attch})
interceptV1Path(
"groups",
"group",
"conversations",
"conv",
"threads",
"thread",
"posts",
pid).
Reply(200).
JSON(requireParseableToMap(suite.T(), itm))
},
attachmentCount: 5,
size: 1000,
expect: assert.NoError,
},
}
for _, test := range tests {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
defer gock.Off()
test.setupf()
item, _, err := suite.its.gockAC.
Conversations().
GetConversationPost(
ctx,
"group",
"conv",
"thread",
pid,
CallConfig{})
test.expect(t, err)
var size int64
if item.GetBody() != nil {
content := ptr.Val(item.GetBody().GetContent())
size = int64(len(content))
}
attachments := item.GetAttachments()
for _, attachment := range attachments {
size += int64(*attachment.GetSize())
}
assert.Equal(t, *item.GetId(), pid)
assert.Equal(t, test.attachmentCount, len(attachments), "attachment count")
assert.Equal(t, test.size, size, "item size")
assert.True(t, gock.IsDone(), "made all requests")
})
}
}

View File

@ -324,7 +324,7 @@ func (c Mail) GetItem(
logger.CtxErr(ctx, err).Info("fetching all attachments by id")
// Getting size just to log in case of error
attachConfig.QueryParameters.Select = []string{"id", "size"}
attachConfig.QueryParameters.Select = idAnd("size")
attachments, err := c.LargeItem.
Client().
@ -348,6 +348,11 @@ func (c Mail) GetItem(
Headers: newPreferHeaders(preferImmutableIDs(immutableIDs)),
}
ictx := clues.Add(
ctx,
"attachment_id", ptr.Val(a.GetId()),
"attachment_size", ptr.Val(a.GetSize()))
att, err := c.Stable.
Client().
Users().
@ -356,17 +361,13 @@ func (c Mail) GetItem(
ByMessageId(itemID).
Attachments().
ByAttachmentId(ptr.Val(a.GetId())).
Get(ctx, attachConfig)
Get(ictx, attachConfig)
if err != nil {
// CannotOpenFileAttachment errors are not transient and
// happens possibly from the original item somehow getting
// deleted from M365 and so we can skip these
if graph.IsErrCannotOpenFileAttachment(err) {
logger.CtxErr(ctx, err).
With(
"attachment_id", ptr.Val(a.GetId()),
"attachment_size", ptr.Val(a.GetSize())).
Info("attachment not found")
logger.CtxErr(ictx, err).Info("attachment not found")
// TODO This should use a `AddSkip` once we have
// figured out the semantics for skipping
// subcomponents of an item
@ -374,8 +375,7 @@ func (c Mail) GetItem(
continue
}
return nil, nil, graph.Wrap(ctx, err, "getting mail attachment").
With("attachment_id", ptr.Val(a.GetId()), "attachment_size", ptr.Val(a.GetSize()))
return nil, nil, graph.Wrap(ictx, err, "getting mail attachment")
}
atts = append(atts, att)

View File

@ -204,7 +204,7 @@ func (suite *MailAPIIntgSuite) SetupSuite() {
suite.its = newIntegrationTesterSetup(suite.T())
}
func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() {
func (suite *MailAPIIntgSuite) TestMail_attachmentListDownload() {
mid := "fake-message-id"
aid := "fake-attachment-id"
@ -369,7 +369,7 @@ func (suite *MailAPIIntgSuite) TestHugeAttachmentListDownload() {
}
}
func (suite *MailAPIIntgSuite) TestMail_RestoreLargeAttachment() {
func (suite *MailAPIIntgSuite) TestMail_PostLargeAttachment() {
t := suite.T()
ctx, flush := tester.NewContext(t)