Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions web/src/app/api/v1/_helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import { NextResponse } from 'next/server'

import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
Expand Down Expand Up @@ -40,7 +39,8 @@ export const parseJsonBody = async <T>(params: {
validationErrorEvent: AnalyticsEvent
userId?: string
}): Promise<HandlerResult<T>> => {
const { req, schema, logger, trackEvent, validationErrorEvent, userId } = params
const { req, schema, logger, trackEvent, validationErrorEvent, userId } =
params
const trackingUserId = userId ?? 'unknown'

let json: unknown
Expand Down Expand Up @@ -151,7 +151,10 @@ export const checkCreditsAndCharge = async (params: {
insufficientCreditsEvent: AnalyticsEvent
getUserUsageData: GetUserUsageDataFn
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<unknown>
ensureSubscriberBlockGrant?: (params: {
userId: string
logger: Logger
}) => Promise<unknown>
}): Promise<HandlerResult<{ creditsUsed: number }>> => {
const {
userId,
Expand All @@ -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:
Expand Down
62 changes: 44 additions & 18 deletions web/src/app/api/v1/docs-search/__tests__/docs-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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: {
Expand All @@ -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 () => {
Expand All @@ -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<BlockGrantResult | null>
})) as unknown as (params: {
userId: string
logger: Logger
}) => Promise<BlockGrantResult | null>

const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
method: 'POST',
Expand All @@ -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: {
Expand All @@ -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<BlockGrantResult | null>
const mockEnsureSubscriberBlockGrant = mock(
async () => null,
) as unknown as (params: {
userId: string
logger: Logger
}) => Promise<BlockGrantResult | null>

const req = new NextRequest('http://localhost:3000/api/v1/docs-search', {
method: 'POST',
Expand All @@ -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()
})
})
58 changes: 41 additions & 17 deletions web/src/app/api/v1/web-search/__tests__/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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<BlockGrantResult | null>
})) as unknown as (params: {
userId: string
logger: Logger
}) => Promise<BlockGrantResult | null>

const req = new NextRequest('http://localhost:3000/api/v1/web-search', {
method: 'POST',
Expand All @@ -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: {
Expand All @@ -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<BlockGrantResult | null>
const mockEnsureSubscriberBlockGrant = mock(
async () => null,
) as unknown as (params: {
userId: string
logger: Logger
}) => Promise<BlockGrantResult | null>

const req = new NextRequest('http://localhost:3000/api/v1/web-search', {
method: 'POST',
Expand All @@ -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()
})
})
8 changes: 4 additions & 4 deletions web/src/app/api/v1/web-search/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -43,7 +40,10 @@ export async function postWebSearch(params: {
consumeCreditsWithFallback: ConsumeCreditsWithFallbackFn
fetch: typeof globalThis.fetch
serverEnv: LinkupEnv
ensureSubscriberBlockGrant?: (params: { userId: string; logger: Logger }) => Promise<BlockGrantResult | null>
ensureSubscriberBlockGrant?: (params: {
userId: string
logger: Logger
}) => Promise<BlockGrantResult | null>
}) {
const {
req,
Expand Down
Loading