Skip to content

Commit c4106f5

Browse files
authored
fix(mail): resolve folder/label filter once per +triage list call (#1512)
buildListParams used to re-call resolveFolderID / resolveFolderName (and the label counterparts) on every list page to assemble folder_id / label_id. Because resolveListFilter already resolves the filter once before the pagination loop, the second pass hit the folders/labels list API again on every page — 1 + page_count calls total, which easily trips rate limits. buildListParams now only assembles API params from the already-resolved FolderID / LabelID produced by resolveListFilter; it no longer resolves names or aliases. The default folder_id=INBOX is still applied when no explicit filter is present, and only overridden when the caller supplied a canonical folder ID. The runtime / mailboxID / dryRun parameters are kept for signature stability (resolveListFilter and buildSearchParams share the same call shape). Adds TestMailTriageCustomFolderResolvesOnceAcrossListPages: a custom-folder filter forced across two messages-list pages, with a non-reusable folders list stub so any second folders API call fails the test. Updated the two existing buildListParams alias tests to run resolveListFilter first, mirroring the real DryRun/Execute call order. sprint: S1 Co-authored-by: xukuncx <283114605+xukuncx@users.noreply.github.com>
1 parent 736b131 commit c4106f5

2 files changed

Lines changed: 184 additions & 30 deletions

File tree

shortcuts/mail/mail_triage.go

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -770,13 +770,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
770770
params["folder_id"] = folderIDFromFilter
771771
}
772772
} else {
773-
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
774-
if err != nil {
775-
return nil, err
776-
}
777-
if resolved != "" {
778-
params["folder_id"] = resolved
779-
}
773+
params["folder_id"] = folderIDFromFilter
780774
}
781775
} else if folderFromFilter != "" {
782776
if dryRun {
@@ -786,13 +780,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
786780
params["folder_id"] = folderFromFilter
787781
}
788782
} else {
789-
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
790-
if err != nil {
791-
return nil, err
792-
}
793-
if resolved != "" {
794-
params["folder_id"] = resolved
795-
}
783+
params["folder_id"] = folderFromFilter
796784
}
797785
}
798786

@@ -811,13 +799,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
811799
params["label_id"] = labelIDFromFilter
812800
}
813801
} else {
814-
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
815-
if err != nil {
816-
return nil, err
817-
}
818-
if resolved != "" {
819-
params["label_id"] = resolved
820-
}
802+
params["label_id"] = labelIDFromFilter
821803
}
822804
} else if labelFromFilter != "" {
823805
if dryRun {
@@ -827,13 +809,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
827809
params["label_id"] = labelFromFilter
828810
}
829811
} else {
830-
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
831-
if err != nil {
832-
return nil, err
833-
}
834-
if resolved != "" {
835-
params["label_id"] = resolved
836-
}
812+
params["label_id"] = labelFromFilter
837813
}
838814
}
839815

shortcuts/mail/mail_triage_test.go

Lines changed: 180 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"testing"
1313

1414
"github.com/larksuite/cli/errs"
15+
"github.com/larksuite/cli/internal/auth"
1516
"github.com/larksuite/cli/internal/cmdutil"
1617
"github.com/larksuite/cli/internal/httpmock"
1718
"github.com/larksuite/cli/shortcuts/common"
@@ -974,7 +975,11 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
974975
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
975976
rt := runtimeForMailTriageTest(t, nil)
976977
f := triageFilter{Folder: "sent"}
977-
got, err := buildListParams(rt, "me", f, 20, "", true)
978+
resolved, err := resolveListFilter(rt, "me", f, true)
979+
if err != nil {
980+
t.Fatalf("resolveListFilter: %v", err)
981+
}
982+
got, err := buildListParams(rt, "me", resolved, 20, "", true)
978983
if err != nil {
979984
t.Fatal(err)
980985
}
@@ -983,10 +988,30 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
983988
}
984989
}
985990

991+
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
992+
rt := runtimeForMailTriageTest(t, nil)
993+
f := triageFilter{Folder: "team-folder"}
994+
resolved, err := resolveListFilter(rt, "me", f, true)
995+
if err != nil {
996+
t.Fatalf("resolveListFilter: %v", err)
997+
}
998+
got, err := buildListParams(rt, "me", resolved, 20, "", true)
999+
if err != nil {
1000+
t.Fatal(err)
1001+
}
1002+
if got["folder_id"] != "team-folder" {
1003+
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
1004+
}
1005+
}
1006+
9861007
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
9871008
rt := runtimeForMailTriageTest(t, nil)
9881009
f := triageFilter{Label: "flagged"}
989-
got, err := buildListParams(rt, "me", f, 10, "", true)
1010+
resolved, err := resolveListFilter(rt, "me", f, true)
1011+
if err != nil {
1012+
t.Fatalf("resolveListFilter: %v", err)
1013+
}
1014+
got, err := buildListParams(rt, "me", resolved, 10, "", true)
9901015
if err != nil {
9911016
t.Fatal(err)
9921017
}
@@ -995,6 +1020,25 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
9951020
}
9961021
}
9971022

1023+
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
1024+
rt := runtimeForMailTriageTest(t, nil)
1025+
f := triageFilter{Label: "custom-label"}
1026+
resolved, err := resolveListFilter(rt, "me", f, true)
1027+
if err != nil {
1028+
t.Fatalf("resolveListFilter: %v", err)
1029+
}
1030+
got, err := buildListParams(rt, "me", resolved, 10, "", true)
1031+
if err != nil {
1032+
t.Fatal(err)
1033+
}
1034+
if _, ok := got["folder_id"]; ok {
1035+
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
1036+
}
1037+
if got["label_id"] != "custom-label" {
1038+
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
1039+
}
1040+
}
1041+
9981042
// --- buildSearchParams additional coverage ---
9991043

10001044
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
@@ -1791,3 +1835,137 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
17911835
},
17921836
}
17931837
}
1838+
1839+
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
1840+
// mailbox folders list API. Because it is non-reusable, any second hit returns
1841+
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
1842+
// use to prove resolveListFilter runs once and buildListParams does NOT
1843+
// re-resolve. folderID/folderName is the single custom folder the API reports.
1844+
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
1845+
reg.Register(&httpmock.Stub{
1846+
Method: "GET",
1847+
URL: mailboxPath(mailbox, "folders"),
1848+
Body: map[string]interface{}{
1849+
"code": 0,
1850+
"data": map[string]interface{}{
1851+
"items": []interface{}{
1852+
map[string]interface{}{
1853+
"id": folderID,
1854+
"name": folderName,
1855+
},
1856+
},
1857+
},
1858+
},
1859+
})
1860+
}
1861+
1862+
// registerMailTriageListPageStub registers one page of the messages list API,
1863+
// disambiguated from sibling pages by a URL substring unique to that page
1864+
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
1865+
// must NOT depend on query-param ordering: map iteration makes param order
1866+
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
1867+
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
1868+
// so reg.Verify catches under- or over-consumption.
1869+
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
1870+
data := map[string]interface{}{
1871+
"items": items,
1872+
"has_more": hasMore,
1873+
}
1874+
if pageToken != "" {
1875+
data["page_token"] = pageToken
1876+
}
1877+
reg.Register(&httpmock.Stub{
1878+
Method: "GET",
1879+
URL: urlSubstring,
1880+
Body: map[string]interface{}{
1881+
"code": 0,
1882+
"data": data,
1883+
},
1884+
})
1885+
}
1886+
1887+
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
1888+
// for the bug where buildListParams re-called resolveFolderID on every list
1889+
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
1890+
// easily tripping rate limits.
1891+
//
1892+
// Setup: a custom folder filter that forces resolveListFilter to hit the
1893+
// folders list API once (to map folder name "team-folder" to folder_id), then two
1894+
// messages-list pages. The folders list stub is non-reusable, so if
1895+
// buildListParams re-resolves, the second hit fails with "no stub". The
1896+
// messages-list stubs are page-specific (disambiguated by page_size in the
1897+
// URL), so both pages are served and Verify asserts each fired exactly once.
1898+
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
1899+
f, stdout, _, reg := mailShortcutTestFactory(t)
1900+
defer reg.Verify(t)
1901+
1902+
// listMailboxFolders (called once by resolveListFilter) gates on the
1903+
// mail:user_mailbox.folder:read scope, which the default test token does
1904+
// not carry. Re-store the token with that scope appended so the folders
1905+
// API call is actually exercised (and thus the non-reusable folders stub
1906+
// is the load-bearing "exactly once" assertion).
1907+
const folderScope = "mail:user_mailbox.folder:read"
1908+
cfg := mailTestConfig()
1909+
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
1910+
if !strings.Contains(stored.Scope, folderScope) {
1911+
stored.Scope = stored.Scope + " " + folderScope
1912+
if err := auth.SetStoredToken(stored); err != nil {
1913+
t.Fatalf("re-store token with folder scope: %v", err)
1914+
}
1915+
}
1916+
}
1917+
1918+
const (
1919+
mailbox = "me"
1920+
folderName = "team-folder"
1921+
folderID = "fld_custom_team"
1922+
page2Token = "tok_page2"
1923+
)
1924+
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
1925+
// on page 2. The page_size query value disambiguates the two list stubs.
1926+
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
1927+
page2IDs := []string{"msg_d", "msg_e"}
1928+
1929+
// Folders list: registered exactly once, non-reusable. Any second folder
1930+
// lookup (the bug) fails the test with "no stub for GET .../folders".
1931+
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
1932+
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
1933+
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
1934+
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
1935+
// Messages list, page 2: 2 ids, terminal.
1936+
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
1937+
// Batch metadata fetch for all 5 ids.
1938+
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
1939+
mailTriageBatchMessage("msg_a", "Subject A"),
1940+
mailTriageBatchMessage("msg_b", "Subject B"),
1941+
mailTriageBatchMessage("msg_c", "Subject C"),
1942+
mailTriageBatchMessage("msg_d", "Subject D"),
1943+
mailTriageBatchMessage("msg_e", "Subject E"),
1944+
})
1945+
1946+
args := []string{
1947+
"+triage",
1948+
"--as", "user",
1949+
"--mailbox", mailbox,
1950+
"--filter", `{"folder":"` + folderName + `"}`,
1951+
"--max", "5",
1952+
"--format", "json",
1953+
}
1954+
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
1955+
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
1956+
}
1957+
1958+
data := decodeMailTriageJSONOutput(t, stdout)
1959+
messages := mailTriageMessagesFromOutput(t, data)
1960+
if len(messages) != 5 {
1961+
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
1962+
}
1963+
if got := data["has_more"]; got != false {
1964+
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
1965+
}
1966+
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
1967+
// non-reusable; reg.Verify (deferred above) asserts each was matched
1968+
// exactly once. Combined with the non-reusable folders stub, this is the
1969+
// proof that the folders list API was called exactly once across both
1970+
// pages — the core invariant the fix restores.
1971+
}

0 commit comments

Comments
 (0)