@@ -89,6 +89,134 @@ func TestCommandSessionListJSONL(t *testing.T) {
8989 }
9090}
9191
92+ // TestCommandSessionListPaginatesBeyond100 is the regression guard for the
93+ // limit>100 bug: the /safari/session/list handler binds limit with "lte=100",
94+ // so a single request with limit 200 is a hard 400 bind failure, not a clamp.
95+ // `session list --limit 200` must therefore satisfy the request by paginating —
96+ // issuing MULTIPLE page requests each with limit<=100 and advancing p — then
97+ // concatenating the rows. This test serves 250 matching sessions in pages of at
98+ // most 100 and asserts the command (a) never asks for more than 100 in any page,
99+ // (b) advances p across pages, and (c) returns exactly the requested 200 rows.
100+ func TestCommandSessionListPaginatesBeyond100 (t * testing.T ) {
101+ saveAndResetGlobals (t )
102+ stub := newGFStub (t )
103+
104+ const totalAvailable = 250
105+ // Serve a page computed from the request's p/limit so we exercise the real
106+ // loop: each page returns min(limit, remaining) sessions, never more than
107+ // the server-accepted ceiling.
108+ stub .dataFor = func (body map [string ]any ) any {
109+ p := int (asFloat (body ["p" ]))
110+ limit := int (asFloat (body ["limit" ]))
111+ if p < 1 {
112+ p = 1
113+ }
114+ if limit > 100 {
115+ // Mirror the real handler: limit>100 is a bind FAILURE, never a
116+ // clamp. If the CLI ever sends this, the test must fail loudly.
117+ t .Fatalf ("page request used limit=%d (>100) — server would 400, CLI must paginate" , limit )
118+ }
119+ offset := (p - 1 ) * limit
120+ sessions := make ([]map [string ]any , 0 , limit )
121+ for i := offset ; i < offset + limit && i < totalAvailable ; i ++ {
122+ sessions = append (sessions , map [string ]any {
123+ "session_id" : fmt .Sprintf ("sess-%03d" , i ),
124+ "app_name" : "ai-sre" ,
125+ "updated_at" : 1779432894000 ,
126+ "session_name" : fmt .Sprintf ("row %d" , i ),
127+ })
128+ }
129+ return map [string ]any {"sessions" : sessions , "total" : totalAvailable }
130+ }
131+
132+ out , err := execCommand ("session" , "list" , "--app" , "ai-sre" , "--limit" , "200" , "--format" , "jsonl" )
133+ if err != nil {
134+ t .Fatalf ("[session-paginate] unexpected error: %v" , err )
135+ }
136+
137+ // (a) Multiple page requests were issued, and (b) p advanced across them.
138+ if stub .requests < 2 {
139+ t .Fatalf ("[session-paginate] expected >=2 page requests for limit 200, got %d" , stub .requests )
140+ }
141+ seenPages := make (map [int ]bool )
142+ for i , b := range stub .bodies {
143+ limit := int (asFloat (b ["limit" ]))
144+ if limit > 100 {
145+ t .Errorf ("[session-paginate] request %d used limit=%d, want <=100" , i , limit )
146+ }
147+ seenPages [int (asFloat (b ["p" ]))] = true
148+ }
149+ if ! seenPages [1 ] || ! seenPages [2 ] {
150+ t .Errorf ("[session-paginate] expected requests for p=1 and p=2, saw pages %v" , seenPages )
151+ }
152+
153+ // (c) Exactly 200 rows came back, concatenated and in order across pages.
154+ lines := nonEmptyLines (out )
155+ if len (lines ) != 200 {
156+ t .Fatalf ("[session-paginate] expected 200 concatenated rows, got %d" , len (lines ))
157+ }
158+ var first , last flashduty.SessionItem
159+ if err := json .Unmarshal ([]byte (lines [0 ]), & first ); err != nil {
160+ t .Fatalf ("[session-paginate] line 0 not a SessionItem: %v" , err )
161+ }
162+ if err := json .Unmarshal ([]byte (lines [199 ]), & last ); err != nil {
163+ t .Fatalf ("[session-paginate] line 199 not a SessionItem: %v" , err )
164+ }
165+ if first .SessionID != "sess-000" {
166+ t .Errorf ("[session-paginate] first row = %q, want sess-000" , first .SessionID )
167+ }
168+ if last .SessionID != "sess-199" {
169+ t .Errorf ("[session-paginate] last row = %q, want sess-199" , last .SessionID )
170+ }
171+ }
172+
173+ // TestCommandSessionListStopsWhenServerExhausted proves the loop terminates when
174+ // the server returns fewer rows than requested (a short page) even though
175+ // --limit asks for more, rather than spinning forever.
176+ func TestCommandSessionListStopsWhenServerExhausted (t * testing.T ) {
177+ saveAndResetGlobals (t )
178+ stub := newGFStub (t )
179+
180+ const totalAvailable = 130 // exhausts mid-way through page 2
181+ stub .dataFor = func (body map [string ]any ) any {
182+ p := int (asFloat (body ["p" ]))
183+ limit := int (asFloat (body ["limit" ]))
184+ if limit > 100 {
185+ t .Fatalf ("page request used limit=%d (>100)" , limit )
186+ }
187+ offset := (p - 1 ) * limit
188+ sessions := make ([]map [string ]any , 0 , limit )
189+ for i := offset ; i < offset + limit && i < totalAvailable ; i ++ {
190+ sessions = append (sessions , map [string ]any {
191+ "session_id" : fmt .Sprintf ("sess-%03d" , i ),
192+ "app_name" : "ai-sre" ,
193+ "updated_at" : 1779432894000 ,
194+ })
195+ }
196+ return map [string ]any {"sessions" : sessions , "total" : totalAvailable }
197+ }
198+
199+ out , err := execCommand ("session" , "list" , "--app" , "ai-sre" , "--limit" , "200" , "--format" , "jsonl" )
200+ if err != nil {
201+ t .Fatalf ("[session-exhaust] unexpected error: %v" , err )
202+ }
203+ lines := nonEmptyLines (out )
204+ if len (lines ) != totalAvailable {
205+ t .Fatalf ("[session-exhaust] expected %d rows (server exhausted), got %d" , totalAvailable , len (lines ))
206+ }
207+ // Page 1 (100) + page 2 (30, short) → exactly 2 requests, no extra spin.
208+ if stub .requests != 2 {
209+ t .Errorf ("[session-exhaust] expected exactly 2 requests, got %d" , stub .requests )
210+ }
211+ }
212+
213+ // asFloat coerces a decoded JSON number (always float64) to float64, tolerating
214+ // a missing key (returns 0).
215+ func asFloat (v any ) float64 {
216+ f , _ := v .(float64 )
217+ return f
218+ }
219+
92220// TestCommandSessionListSinceFiltersClientSide proves --since drops rows older
93221// than the window using the response's updated_at (the API has no time filter).
94222func TestCommandSessionListSinceFiltersClientSide (t * testing.T ) {
0 commit comments