diff --git a/web/src/app/api/v1/_helpers.ts b/web/src/app/api/v1/_helpers.ts index 839490c79d..f281ebe7a1 100644 --- a/web/src/app/api/v1/_helpers.ts +++ b/web/src/app/api/v1/_helpers.ts @@ -1,4 +1,3 @@ - import { NextResponse } from 'next/server' import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' @@ -40,7 +39,8 @@ export const parseJsonBody = async (params: { validationErrorEvent: AnalyticsEvent userId?: string }): Promise> => { - const { req, schema, logger, trackEvent, validationErrorEvent, userId } = params + const { req, schema, logger, trackEvent, validationErrorEvent, userId } = + params const trackingUserId = userId ?? 'unknown' let json: unknown @@ -151,7 +151,10 @@ export const checkCreditsAndCharge = async (params: { insufficientCreditsEvent: AnalyticsEvent getUserUsageData: GetUserUsageDataFn consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn - ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise + ensureSubscriberBlockGrant?: (params: { + userId: string + logger: Logger + }) => Promise }): Promise> => { const { userId, @@ -167,6 +170,10 @@ export const checkCreditsAndCharge = async (params: { ensureSubscriberBlockGrant, } = params + if (creditsToCharge <= 0) { + return { ok: true, data: { creditsUsed: 0 } } + } + // Ensure subscription block grant exists before checking credits. // This creates the grant (if eligible) so its credits appear in the balance below. // When the function is provided, always include subscription credits in the balance: diff --git a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts index 6f3162365d..d3c26c8880 100644 --- a/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts +++ b/web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts @@ -81,7 +81,9 @@ describe('/api/v1/docs-search POST endpoint', () => { headers: { 'Content-Type': 'text/plain' }, }) } - mockFetch = Object.assign(fetchImpl, { preconnect: () => {} }) as typeof fetch + mockFetch = Object.assign(fetchImpl, { + preconnect: () => {}, + }) as typeof fetch }) afterEach(() => { @@ -106,7 +108,7 @@ describe('/api/v1/docs-search POST endpoint', () => { expect(res.status).toBe(401) }) - test('402 when insufficient credits', async () => { + test('200 when zero-credit docs search user has no credits', async () => { mockGetUserUsageData = mock(async () => ({ usageThisCycle: 0, balance: { @@ -133,7 +135,11 @@ describe('/api/v1/docs-search POST endpoint', () => { consumeCreditsWithFallback: mockConsumeCreditsWithFallback, fetch: mockFetch, }) - expect(res.status).toBe(402) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.creditsUsed).toBe(0) + expect(mockGetUserUsageData).not.toHaveBeenCalled() + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) test('200 on success', async () => { @@ -155,26 +161,37 @@ describe('/api/v1/docs-search POST endpoint', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.documentation).toContain('Some documentation text') + expect(body.creditsUsed).toBe(0) + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => { - mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({ - usageThisCycle: 0, - balance: { - totalRemaining: includeSubscriptionCredits ? 350 : 0, - totalDebt: 0, - netBalance: includeSubscriptionCredits ? 350 : 0, - breakdown: {}, - principals: {}, - }, - nextQuotaReset: 'soon', - })) + mockGetUserUsageData = mock( + async ({ + includeSubscriptionCredits, + }: { + includeSubscriptionCredits?: boolean + }) => ({ + usageThisCycle: 0, + balance: { + totalRemaining: includeSubscriptionCredits ? 350 : 0, + totalDebt: 0, + netBalance: includeSubscriptionCredits ? 350 : 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + }), + ) const mockEnsureSubscriberBlockGrant = mock(async () => ({ grantId: 'grant-1', credits: 350, expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), isNew: true, - })) as unknown as (params: { userId: string; logger: Logger }) => Promise + })) as unknown as (params: { + userId: string + logger: Logger + }) => Promise const req = new NextRequest('http://localhost:3000/api/v1/docs-search', { method: 'POST', @@ -195,7 +212,7 @@ describe('/api/v1/docs-search POST endpoint', () => { expect(res.status).toBe(200) }) - test('402 for non-subscriber with 0 credits and no block grant', async () => { + test('200 for non-subscriber with 0 credits and no block grant', async () => { mockGetUserUsageData = mock(async () => ({ usageThisCycle: 0, balance: { @@ -207,7 +224,12 @@ describe('/api/v1/docs-search POST endpoint', () => { }, nextQuotaReset: 'soon', })) - const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise + const mockEnsureSubscriberBlockGrant = mock( + async () => null, + ) as unknown as (params: { + userId: string + logger: Logger + }) => Promise const req = new NextRequest('http://localhost:3000/api/v1/docs-search', { method: 'POST', @@ -225,6 +247,10 @@ describe('/api/v1/docs-search POST endpoint', () => { fetch: mockFetch, ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, }) - expect(res.status).toBe(402) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.creditsUsed).toBe(0) + expect(mockGetUserUsageData).not.toHaveBeenCalled() + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) }) diff --git a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts index 6a30fe9d66..6be2f09b81 100644 --- a/web/src/app/api/v1/web-search/__tests__/web-search.test.ts +++ b/web/src/app/api/v1/web-search/__tests__/web-search.test.ts @@ -89,7 +89,7 @@ describe('/api/v1/web-search POST endpoint', () => { expect(res.status).toBe(401) }) - test('402 when insufficient credits', async () => { + test('200 when zero-credit search user has no credits', async () => { mockGetUserUsageData = mock(async () => ({ usageThisCycle: 0, balance: { @@ -117,7 +117,11 @@ describe('/api/v1/web-search POST endpoint', () => { fetch: mockFetch, serverEnv: testServerEnv, }) - expect(res.status).toBe(402) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.creditsUsed).toBe(0) + expect(mockGetUserUsageData).not.toHaveBeenCalled() + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) test('200 on success', async () => { @@ -140,26 +144,37 @@ describe('/api/v1/web-search POST endpoint', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.result).toBeDefined() + expect(body.creditsUsed).toBe(0) + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) test('200 for subscriber with 0 a-la-carte credits but active block grant', async () => { - mockGetUserUsageData = mock(async ({ includeSubscriptionCredits }: { includeSubscriptionCredits?: boolean }) => ({ - usageThisCycle: 0, - balance: { - totalRemaining: includeSubscriptionCredits ? 350 : 0, - totalDebt: 0, - netBalance: includeSubscriptionCredits ? 350 : 0, - breakdown: {}, - principals: {}, - }, - nextQuotaReset: 'soon', - })) + mockGetUserUsageData = mock( + async ({ + includeSubscriptionCredits, + }: { + includeSubscriptionCredits?: boolean + }) => ({ + usageThisCycle: 0, + balance: { + totalRemaining: includeSubscriptionCredits ? 350 : 0, + totalDebt: 0, + netBalance: includeSubscriptionCredits ? 350 : 0, + breakdown: {}, + principals: {}, + }, + nextQuotaReset: 'soon', + }), + ) const mockEnsureSubscriberBlockGrant = mock(async () => ({ grantId: 'grant-1', credits: 350, expiresAt: new Date(Date.now() + 5 * 60 * 60 * 1000), isNew: true, - })) as unknown as (params: { userId: string; logger: Logger }) => Promise + })) as unknown as (params: { + userId: string + logger: Logger + }) => Promise const req = new NextRequest('http://localhost:3000/api/v1/web-search', { method: 'POST', @@ -181,7 +196,7 @@ describe('/api/v1/web-search POST endpoint', () => { expect(res.status).toBe(200) }) - test('402 for non-subscriber with 0 credits and no block grant', async () => { + test('200 for non-subscriber with 0 credits and no block grant', async () => { mockGetUserUsageData = mock(async () => ({ usageThisCycle: 0, balance: { @@ -193,7 +208,12 @@ describe('/api/v1/web-search POST endpoint', () => { }, nextQuotaReset: 'soon', })) - const mockEnsureSubscriberBlockGrant = mock(async () => null) as unknown as (params: { userId: string; logger: Logger }) => Promise + const mockEnsureSubscriberBlockGrant = mock( + async () => null, + ) as unknown as (params: { + userId: string + logger: Logger + }) => Promise const req = new NextRequest('http://localhost:3000/api/v1/web-search', { method: 'POST', @@ -212,6 +232,10 @@ describe('/api/v1/web-search POST endpoint', () => { serverEnv: testServerEnv, ensureSubscriberBlockGrant: mockEnsureSubscriberBlockGrant, }) - expect(res.status).toBe(402) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.creditsUsed).toBe(0) + expect(mockGetUserUsageData).not.toHaveBeenCalled() + expect(mockConsumeCreditsWithFallback).not.toHaveBeenCalled() }) }) diff --git a/web/src/app/api/v1/web-search/_post.ts b/web/src/app/api/v1/web-search/_post.ts index b91df8ded1..fa276d0c9e 100644 --- a/web/src/app/api/v1/web-search/_post.ts +++ b/web/src/app/api/v1/web-search/_post.ts @@ -24,9 +24,6 @@ import type { import type { BlockGrantResult } from '@codebuff/billing/subscription' import type { NextRequest } from 'next/server' - - - const bodySchema = z.object({ query: z.string().min(1, 'query is required'), depth: z.enum(['standard', 'deep']).optional().default('standard'), @@ -43,7 +40,10 @@ export async function postWebSearch(params: { consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn fetch: typeof globalThis.fetch serverEnv: LinkupEnv - ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise + ensureSubscriberBlockGrant?: (params: { + userId: string + logger: Logger + }) => Promise }) { const { req,