@@ -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) {
974975func 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+
9861007func 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
10001044func 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