@@ -199,6 +199,79 @@ describe("Bulk actions API", () => {
199199 expect ( response . status ) . toBe ( 500 ) ;
200200 await expect ( response . json ( ) ) . resolves . toEqual ( { error : "Failed to create bulk action" } ) ;
201201 } ) ;
202+
203+ it ( "blocks a new replay once the concurrent-replay limit is reached" , async ( ) => {
204+ const server = getTestServer ( ) ;
205+ const { apiKey, project, environment } = await seedTestEnvironment ( server . prisma ) ;
206+
207+ // Fill the per-environment concurrent-replay slots with fresh, in-flight replays.
208+ // The guard runs before the ClickHouse count, so this asserts cleanly without it.
209+ for ( let i = 0 ; i < 3 ; i ++ ) {
210+ await seedBulkAction ( server . prisma , project , environment , {
211+ type : BulkActionType . REPLAY ,
212+ status : BulkActionStatus . PENDING ,
213+ } ) ;
214+ }
215+
216+ const response = await server . webapp . fetch ( "/api/v1/bulk-actions" , {
217+ method : "POST" ,
218+ headers : authHeaders ( apiKey ) ,
219+ body : JSON . stringify ( { action : "replay" , filter : { status : "FAILED" } } ) ,
220+ } ) ;
221+
222+ expect ( response . status ) . toBe ( 429 ) ;
223+ // The cap is a semantic limit, not a transient rate limit, so the SDK must not retry it.
224+ expect ( response . headers . get ( "x-should-retry" ) ) . toBe ( "false" ) ;
225+ const body = await response . json ( ) ;
226+ expect ( body . error ) . toContain ( "bulk replays at a time" ) ;
227+ } ) ;
228+
229+ it ( "does not count stale replays that have stopped making progress" , async ( ) => {
230+ const server = getTestServer ( ) ;
231+ const { apiKey, project, environment } = await seedTestEnvironment ( server . prisma ) ;
232+
233+ for ( let i = 0 ; i < 3 ; i ++ ) {
234+ await seedBulkAction ( server . prisma , project , environment , {
235+ type : BulkActionType . REPLAY ,
236+ status : BulkActionStatus . PENDING ,
237+ } ) ;
238+ }
239+
240+ // Backdate updatedAt past the in-flight window so these look like dead replays.
241+ // (updatedAt is @updatedAt, so it can only be set via raw SQL, not on create.)
242+ await server . prisma . $executeRawUnsafe (
243+ `UPDATE "BulkActionGroup" SET "updatedAt" = now() - interval '31 minutes' WHERE "environmentId" = $1` ,
244+ environment . id
245+ ) ;
246+
247+ const response = await server . webapp . fetch ( "/api/v1/bulk-actions" , {
248+ method : "POST" ,
249+ headers : authHeaders ( apiKey ) ,
250+ body : JSON . stringify ( { action : "replay" , filter : { status : "FAILED" } } ) ,
251+ } ) ;
252+
253+ // Stale replays don't hold a slot, so the guard lets the request through and it
254+ // reaches the count step, which fails (no ClickHouse in this suite) with a 500 rather
255+ // than being blocked by the concurrency guard's 429.
256+ expect ( response . status ) . toBe ( 500 ) ;
257+ } ) ;
258+
259+ it ( "rejects create requests with more runIds than the allowed maximum" , async ( ) => {
260+ const server = getTestServer ( ) ;
261+ const { apiKey } = await seedTestEnvironment ( server . prisma ) ;
262+
263+ const runIds = Array . from ( { length : 501 } , ( _ , i ) => `run_${ i } ` ) ;
264+
265+ const response = await server . webapp . fetch ( "/api/v1/bulk-actions" , {
266+ method : "POST" ,
267+ headers : authHeaders ( apiKey ) ,
268+ body : JSON . stringify ( { action : "cancel" , runIds } ) ,
269+ } ) ;
270+
271+ expect ( response . status ) . toBe ( 400 ) ;
272+ const body = await response . json ( ) ;
273+ expect ( body . error ) . toContain ( "Too many runIds" ) ;
274+ } ) ;
202275} ) ;
203276
204277function authHeaders ( apiKey : string ) {
0 commit comments