@@ -200,6 +200,28 @@ describe('redis store test', () => {
200200 expect ( await client . get ( 'rl:test-store-two' ) ) . toEqual ( null )
201201 } )
202202
203+ it ( 'starts new window with count 1 when TTL expired (race fix)' , async ( ) => {
204+ const store = new RedisStore ( { sendCommand } )
205+ const windowMs = 50
206+ store . init ( { windowMs } as Options )
207+
208+ const key = 'test-expired-window'
209+
210+ // First hit in first window
211+ const first = await store . increment ( key )
212+ expect ( first . totalHits ) . toEqual ( 1 )
213+ expect ( Number ( await client . pttl ( `rl:${ key } ` ) ) ) . toEqual ( windowMs )
214+
215+ // Advance beyond expiry so Redis reports expired (-2)
216+ jest . advanceTimersByTime ( windowMs + 1 )
217+
218+ // Next increment should start a fresh window with count=1 and TTL reset
219+ const afterExpiry = await store . increment ( key )
220+ expect ( afterExpiry . totalHits ) . toEqual ( 1 )
221+ expect ( Number ( await client . get ( `rl:${ key } ` ) ) ) . toEqual ( 1 )
222+ expect ( Number ( await client . pttl ( `rl:${ key } ` ) ) ) . toEqual ( windowMs )
223+ } )
224+
203225 it . skip ( 'do not reset the expiration when the ttl is very close to 0' , async ( ) => {
204226 const store = new RedisStore ( { sendCommand } )
205227 const windowMs = 60
@@ -236,4 +258,91 @@ describe('redis store test', () => {
236258 expect ( Number ( await client . get ( 'rl:test-store' ) ) ) . toEqual ( 1 )
237259 expect ( Number ( await client . pttl ( 'rl:test-store' ) ) ) . toEqual ( 10 )
238260 } )
261+
262+ it ( 'unit: when PTTL==0 but key exists, script starts new window with 1' , async ( ) => {
263+ // In-memory fake Redis state for a single key
264+ const state : { value : number ; ttl : number } = { value : 0 , ttl : - 2 }
265+ let loadedIncrementScript = ''
266+
267+ // Stub that simulates Redis primitives and applies logic depending on script order
268+ const sendCommandStub = async ( ...args : string [ ] ) : Promise < RedisReply > => {
269+ const [ command , ...rest ] = args
270+ if ( command === 'SCRIPT' && rest [ 0 ] === 'LOAD' ) {
271+ const scriptBody = rest [ 1 ]
272+ if ( scriptBody . includes ( 'INCR' ) ) loadedIncrementScript = scriptBody
273+ // Return a fake sha
274+ return 'sha-' + ( scriptBody . includes ( 'INCR' ) ? 'incr' : 'get' )
275+ }
276+
277+ if ( command === 'EVALSHA' ) {
278+ const sha = rest [ 0 ]
279+ const numberKeys = rest [ 1 ]
280+ const key = rest [ 2 ]
281+ const resetOnChange = rest [ 3 ] === '1'
282+ const windowMs = Number ( rest [ 4 ] )
283+ if ( sha . endsWith ( 'incr' ) && numberKeys === '1' && key ) {
284+ // Determine algorithm: does the script read PTTL before INCR?
285+ const pttlIndex = loadedIncrementScript . indexOf ( 'PTTL' )
286+ const incrIndex = loadedIncrementScript . indexOf ( 'INCR' )
287+
288+ let totalHits = 0
289+ let { ttl } = state
290+
291+ if ( pttlIndex > - 1 && incrIndex > - 1 && pttlIndex < incrIndex ) {
292+ // NEW script: check ttl first
293+ if ( ttl <= 0 ) {
294+ state . value = 1
295+ state . ttl = windowMs
296+ totalHits = 1
297+ ttl = windowMs
298+ } else {
299+ state . value += 1
300+ totalHits = state . value
301+ if ( resetOnChange ) state . ttl = windowMs
302+ ttl = state . ttl
303+ }
304+ } else {
305+ // OLD script: INCR first, then PTTL; only resets when ttl<0
306+ state . value += 1
307+ totalHits = state . value
308+ // Read pttl after incr; here ttl is 0 (exists but expired)
309+ if ( ttl < 0 || resetOnChange ) {
310+ state . ttl = windowMs
311+ ttl = windowMs
312+ } else {
313+ // Ttl == 0 branch
314+ ttl = 0
315+ }
316+ }
317+
318+ return [ totalHits as unknown as number , ttl as unknown as number ]
319+ }
320+ }
321+
322+ // Fallback for get script
323+ if ( command === 'EVALSHA' && args [ 0 ] . endsWith ( 'get' ) ) {
324+ return [
325+ state . value as unknown as number ,
326+ state . ttl as unknown as number ,
327+ ]
328+ }
329+
330+ return - 99
331+ }
332+
333+ const store = new RedisStore ( { sendCommand : sendCommandStub } )
334+ store . init ( { windowMs : 60 } as Options )
335+
336+ const key = 'pttl-zero-exists'
337+
338+ // First increment to create key with value=1 and ttl=60
339+ await store . increment ( key )
340+ state . value = 1
341+ state . ttl = 0 // Simulate edge: key still exists but PTTL==0 exactly
342+
343+ const result = await store . increment ( key )
344+
345+ // With NEW script we expect a fresh window: hits=1 and ttl reset
346+ expect ( result . totalHits ) . toEqual ( 1 )
347+ } )
239348} )
0 commit comments