Skip to content

Commit 7a1531b

Browse files
committed
test: add unit tests for subscription limit enforcement in chat completions API
1 parent 9047000 commit 7a1531b

File tree

1 file changed

+263
-0
lines changed

1 file changed

+263
-0
lines changed

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1820
describe('/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

Comments
 (0)