@@ -14,6 +14,8 @@ import type {
1414 Logger ,
1515 LoggerWithContextFn ,
1616} from '@codebuff/common/types/contracts/logger'
17+ import type { BlockGrantResult } from '@codebuff/billing/subscription'
18+ import type { GetUserPreferencesFn } from '../_post'
1719
1820describe ( '/api/v1/chat/completions POST endpoint' , ( ) => {
1921 const mockUserData : Record <
@@ -497,4 +499,265 @@ describe('/api/v1/chat/completions POST endpoint', () => {
497499 expect ( body . choices [ 0 ] . message . content ) . toBe ( 'test response' )
498500 } )
499501 } )
502+
503+ describe ( 'Subscription limit enforcement' , ( ) => {
504+ const createValidRequest = ( ) =>
505+ new NextRequest ( 'http://localhost:3000/api/v1/chat/completions' , {
506+ method : 'POST' ,
507+ headers : { Authorization : 'Bearer test-api-key-123' } ,
508+ body : JSON . stringify ( {
509+ model : 'test/test-model' ,
510+ stream : false ,
511+ codebuff_metadata : {
512+ run_id : 'run-123' ,
513+ client_id : 'test-client-id-123' ,
514+ client_request_id : 'test-client-session-id-123' ,
515+ } ,
516+ } ) ,
517+ } )
518+
519+ it ( 'returns 429 when weekly limit reached and fallback disabled' , async ( ) => {
520+ const weeklyLimitError : BlockGrantResult = {
521+ error : 'weekly_limit_reached' ,
522+ used : 3500 ,
523+ limit : 3500 ,
524+ resetsAt : new Date ( Date . now ( ) + 3 * 24 * 60 * 60 * 1000 ) ,
525+ }
526+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => weeklyLimitError )
527+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
528+ fallbackToALaCarte : false ,
529+ } ) )
530+
531+ const response = await postChatCompletions ( {
532+ req : createValidRequest ( ) ,
533+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
534+ logger : mockLogger ,
535+ trackEvent : mockTrackEvent ,
536+ getUserUsageData : mockGetUserUsageData ,
537+ getAgentRunFromId : mockGetAgentRunFromId ,
538+ fetch : mockFetch ,
539+ insertMessageBigquery : mockInsertMessageBigquery ,
540+ loggerWithContext : mockLoggerWithContext ,
541+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
542+ getUserPreferences : mockGetUserPreferences ,
543+ } )
544+
545+ expect ( response . status ) . toBe ( 429 )
546+ const body = await response . json ( )
547+ expect ( body . error ) . toBe ( 'rate_limit_exceeded' )
548+ expect ( body . message ) . toContain ( 'weekly limit reached' )
549+ expect ( body . message ) . toContain ( 'Enable "Continue with credits"' )
550+ } )
551+
552+ it ( 'returns 429 when block exhausted and fallback disabled' , async ( ) => {
553+ const blockExhaustedError : BlockGrantResult = {
554+ error : 'block_exhausted' ,
555+ blockUsed : 350 ,
556+ blockLimit : 350 ,
557+ resetsAt : new Date ( Date . now ( ) + 4 * 60 * 60 * 1000 ) ,
558+ }
559+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => blockExhaustedError )
560+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
561+ fallbackToALaCarte : false ,
562+ } ) )
563+
564+ const response = await postChatCompletions ( {
565+ req : createValidRequest ( ) ,
566+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
567+ logger : mockLogger ,
568+ trackEvent : mockTrackEvent ,
569+ getUserUsageData : mockGetUserUsageData ,
570+ getAgentRunFromId : mockGetAgentRunFromId ,
571+ fetch : mockFetch ,
572+ insertMessageBigquery : mockInsertMessageBigquery ,
573+ loggerWithContext : mockLoggerWithContext ,
574+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
575+ getUserPreferences : mockGetUserPreferences ,
576+ } )
577+
578+ expect ( response . status ) . toBe ( 429 )
579+ const body = await response . json ( )
580+ expect ( body . error ) . toBe ( 'rate_limit_exceeded' )
581+ expect ( body . message ) . toContain ( '5-hour session limit reached' )
582+ expect ( body . message ) . toContain ( 'Enable "Continue with credits"' )
583+ } )
584+
585+ it ( 'continues when weekly limit reached but fallback is enabled' , async ( ) => {
586+ const weeklyLimitError : BlockGrantResult = {
587+ error : 'weekly_limit_reached' ,
588+ used : 3500 ,
589+ limit : 3500 ,
590+ resetsAt : new Date ( Date . now ( ) + 3 * 24 * 60 * 60 * 1000 ) ,
591+ }
592+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => weeklyLimitError )
593+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
594+ fallbackToALaCarte : true ,
595+ } ) )
596+
597+ const response = await postChatCompletions ( {
598+ req : createValidRequest ( ) ,
599+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
600+ logger : mockLogger ,
601+ trackEvent : mockTrackEvent ,
602+ getUserUsageData : mockGetUserUsageData ,
603+ getAgentRunFromId : mockGetAgentRunFromId ,
604+ fetch : mockFetch ,
605+ insertMessageBigquery : mockInsertMessageBigquery ,
606+ loggerWithContext : mockLoggerWithContext ,
607+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
608+ getUserPreferences : mockGetUserPreferences ,
609+ } )
610+
611+ expect ( response . status ) . toBe ( 200 )
612+ expect ( mockLogger . info ) . toHaveBeenCalled ( )
613+ } )
614+
615+ it ( 'continues when block grant is created successfully' , async ( ) => {
616+ const blockGrant : BlockGrantResult = {
617+ grantId : 'block-123' ,
618+ credits : 350 ,
619+ expiresAt : new Date ( Date . now ( ) + 5 * 60 * 60 * 1000 ) ,
620+ isNew : true ,
621+ }
622+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => blockGrant )
623+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
624+ fallbackToALaCarte : false ,
625+ } ) )
626+
627+ const response = await postChatCompletions ( {
628+ req : createValidRequest ( ) ,
629+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
630+ logger : mockLogger ,
631+ trackEvent : mockTrackEvent ,
632+ getUserUsageData : mockGetUserUsageData ,
633+ getAgentRunFromId : mockGetAgentRunFromId ,
634+ fetch : mockFetch ,
635+ insertMessageBigquery : mockInsertMessageBigquery ,
636+ loggerWithContext : mockLoggerWithContext ,
637+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
638+ getUserPreferences : mockGetUserPreferences ,
639+ } )
640+
641+ expect ( response . status ) . toBe ( 200 )
642+ // getUserPreferences should not be called when block grant succeeds
643+ expect ( mockGetUserPreferences ) . not . toHaveBeenCalled ( )
644+ } )
645+
646+ it ( 'continues when ensureSubscriberBlockGrant throws an error (fail open)' , async ( ) => {
647+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => {
648+ throw new Error ( 'Database connection failed' )
649+ } )
650+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
651+ fallbackToALaCarte : false ,
652+ } ) )
653+
654+ const response = await postChatCompletions ( {
655+ req : createValidRequest ( ) ,
656+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
657+ logger : mockLogger ,
658+ trackEvent : mockTrackEvent ,
659+ getUserUsageData : mockGetUserUsageData ,
660+ getAgentRunFromId : mockGetAgentRunFromId ,
661+ fetch : mockFetch ,
662+ insertMessageBigquery : mockInsertMessageBigquery ,
663+ loggerWithContext : mockLoggerWithContext ,
664+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
665+ getUserPreferences : mockGetUserPreferences ,
666+ } )
667+
668+ // Should continue processing (fail open)
669+ expect ( response . status ) . toBe ( 200 )
670+ expect ( mockLogger . error ) . toHaveBeenCalled ( )
671+ } )
672+
673+ it ( 'continues when user is not a subscriber (null result)' , async ( ) => {
674+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => null )
675+ const mockGetUserPreferences : GetUserPreferencesFn = mock ( async ( ) => ( {
676+ fallbackToALaCarte : false ,
677+ } ) )
678+
679+ const response = await postChatCompletions ( {
680+ req : createValidRequest ( ) ,
681+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
682+ logger : mockLogger ,
683+ trackEvent : mockTrackEvent ,
684+ getUserUsageData : mockGetUserUsageData ,
685+ getAgentRunFromId : mockGetAgentRunFromId ,
686+ fetch : mockFetch ,
687+ insertMessageBigquery : mockInsertMessageBigquery ,
688+ loggerWithContext : mockLoggerWithContext ,
689+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
690+ getUserPreferences : mockGetUserPreferences ,
691+ } )
692+
693+ expect ( response . status ) . toBe ( 200 )
694+ // getUserPreferences should not be called for non-subscribers
695+ expect ( mockGetUserPreferences ) . not . toHaveBeenCalled ( )
696+ } )
697+
698+ it ( 'defaults to allowing fallback when getUserPreferences is not provided' , async ( ) => {
699+ const weeklyLimitError : BlockGrantResult = {
700+ error : 'weekly_limit_reached' ,
701+ used : 3500 ,
702+ limit : 3500 ,
703+ resetsAt : new Date ( Date . now ( ) + 3 * 24 * 60 * 60 * 1000 ) ,
704+ }
705+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => weeklyLimitError )
706+
707+ const response = await postChatCompletions ( {
708+ req : createValidRequest ( ) ,
709+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
710+ logger : mockLogger ,
711+ trackEvent : mockTrackEvent ,
712+ getUserUsageData : mockGetUserUsageData ,
713+ getAgentRunFromId : mockGetAgentRunFromId ,
714+ fetch : mockFetch ,
715+ insertMessageBigquery : mockInsertMessageBigquery ,
716+ loggerWithContext : mockLoggerWithContext ,
717+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
718+ // Note: getUserPreferences is NOT provided
719+ } )
720+
721+ // Should continue processing (default to allowing a-la-carte)
722+ expect ( response . status ) . toBe ( 200 )
723+ } )
724+
725+ it ( 'does not call ensureSubscriberBlockGrant before validation passes' , async ( ) => {
726+ const mockEnsureSubscriberBlockGrant = mock ( async ( ) => null )
727+
728+ // Request with invalid run_id
729+ const req = new NextRequest (
730+ 'http://localhost:3000/api/v1/chat/completions' ,
731+ {
732+ method : 'POST' ,
733+ headers : { Authorization : 'Bearer test-api-key-123' } ,
734+ body : JSON . stringify ( {
735+ model : 'test/test-model' ,
736+ stream : false ,
737+ codebuff_metadata : {
738+ run_id : 'run-nonexistent' ,
739+ } ,
740+ } ) ,
741+ } ,
742+ )
743+
744+ const response = await postChatCompletions ( {
745+ req,
746+ getUserInfoFromApiKey : mockGetUserInfoFromApiKey ,
747+ logger : mockLogger ,
748+ trackEvent : mockTrackEvent ,
749+ getUserUsageData : mockGetUserUsageData ,
750+ getAgentRunFromId : mockGetAgentRunFromId ,
751+ fetch : mockFetch ,
752+ insertMessageBigquery : mockInsertMessageBigquery ,
753+ loggerWithContext : mockLoggerWithContext ,
754+ ensureSubscriberBlockGrant : mockEnsureSubscriberBlockGrant ,
755+ } )
756+
757+ // Should return 400 for invalid run_id
758+ expect ( response . status ) . toBe ( 400 )
759+ // ensureSubscriberBlockGrant should NOT have been called
760+ expect ( mockEnsureSubscriberBlockGrant ) . not . toHaveBeenCalled ( )
761+ } )
762+ } )
500763} )
0 commit comments