diff --git a/app/api/hellenistic/ask-seer/route.ts b/app/api/hellenistic/ask-seer/route.ts index 23bda8f9..5f9a81c0 100644 --- a/app/api/hellenistic/ask-seer/route.ts +++ b/app/api/hellenistic/ask-seer/route.ts @@ -28,26 +28,16 @@ const REFUSAL_MESSAGE = export async function POST(request: NextRequest) { try { const body = (await request.json()) as HellenisticSeerRequest; - const __toolSeerGate = await enforceToolSeerGate(request, body, 'hellenistic_ask_seer'); + const { userId, question, hellenisticContext } = body; + const missingContextError = !question || !question.trim() + ? 'Question is required' + : !hellenisticContext + ? 'Missing Hellenistic chart data. Please generate a reading first.' + : null; + const __toolSeerGate = await enforceToolSeerGate(request, body, 'hellenistic_ask_seer', { + missingContextError, + }); if (__toolSeerGate) return __toolSeerGate; - const { userId, question, userProfile, hellenisticContext, sessionId } = body; - - if (!question || !question.trim()) { - return NextResponse.json( - { success: false, error: 'Question is required' }, - { status: 400 } - ); - } - - if (!hellenisticContext) { - return NextResponse.json( - { - success: false, - error: 'Missing Hellenistic chart data. Please generate a reading first.', - }, - { status: 400 } - ); - } devLog.info('🔮 Hellenistic Seer API: Processing question for user:', userId, 'ask-hellenistic-seer'); diff --git a/lib/enforceToolSeerGate.ts b/lib/enforceToolSeerGate.ts index 6716b09a..1a33722e 100644 --- a/lib/enforceToolSeerGate.ts +++ b/lib/enforceToolSeerGate.ts @@ -26,6 +26,12 @@ export interface EnforceToolSeerGateOptions { * - json: JSON body (e.g. medical-seer) */ blockedResponseFormat?: ToolSeerBlockedResponseFormat; + /** + * Route-specific required context (chart, reading, profile payload). + * When set, the gate returns 400 **before** rate-limit and billing so PAYG + * users are not charged for a question the tool cannot answer yet. + */ + missingContextError?: string | null; } /** Extract trimmed `question` from a tool Seer POST body. */ @@ -90,6 +96,17 @@ export async function enforceToolSeerGate( rateUid = auth.uid; } + const missingContextError = + typeof options?.missingContextError === 'string' ? options.missingContextError.trim() : ''; + if (missingContextError) { + const res = NextResponse.json( + { success: false, error: missingContextError }, + { status: 400 }, + ); + res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet'); + return res; + } + const rl = await checkRateLimitWithOptionalFirestore( rateLimiters.ai, `tool_seer_${routeLogicalKey}`, diff --git a/tests/unit/enforceToolSeerGate.test.ts b/tests/unit/enforceToolSeerGate.test.ts index b62dc9f2..3f934d3a 100644 --- a/tests/unit/enforceToolSeerGate.test.ts +++ b/tests/unit/enforceToolSeerGate.test.ts @@ -8,6 +8,7 @@ import { extractToolSeerQuestion, } from '@/lib/enforceToolSeerGate'; import { SEER_INPUT_BLOCKED_MESSAGE } from '@/lib/seerInputGuard'; +import { consumeBillingAction } from '@/lib/billingCreditsServer'; jest.mock('@/lib/userApiAuth', () => ({ verifyUserRequest: jest.fn(async () => ({ ok: true, uid: 'user-1' })), @@ -27,6 +28,20 @@ jest.mock('@/lib/aiAuditEvents', () => ({ recordAiAuditEvent: jest.fn(), })); +jest.mock('@/lib/billingCreditsServer', () => ({ + consumeBillingAction: jest.fn(async () => ({ + ok: true, + charged: true, + creditsCharged: 1, + creditBalance: 9, + usedFreeInstance: false, + })), +})); + +const consumeBillingActionMock = consumeBillingAction as jest.MockedFunction< + typeof consumeBillingAction +>; + describe('enforceToolSeerGate', () => { function post(body: Record) { return new NextRequest('http://localhost/api/ask-tarot-seer', { @@ -36,6 +51,10 @@ describe('enforceToolSeerGate', () => { }); } + beforeEach(() => { + consumeBillingActionMock.mockClear(); + }); + it('extractToolSeerQuestion trims question field', () => { expect(extractToolSeerQuestion({ question: ' hello ' })).toBe('hello'); expect(extractToolSeerQuestion({})).toBe(''); @@ -55,6 +74,7 @@ describe('enforceToolSeerGate', () => { expect(res!.headers.get('Content-Type')).toBe('text/event-stream'); const text = await res!.text(); expect(text).toBe(SEER_INPUT_BLOCKED_MESSAGE); + expect(consumeBillingActionMock).not.toHaveBeenCalled(); }); it('returns JSON when blockedResponseFormat is json', async () => { @@ -70,6 +90,7 @@ describe('enforceToolSeerGate', () => { const data = await res!.json(); expect(data.inputBlocked).toBe(true); expect(data.response).toBe(SEER_INPUT_BLOCKED_MESSAGE); + expect(consumeBillingActionMock).not.toHaveBeenCalled(); }); it('passes through when question is empty (route handles 400)', async () => { @@ -88,5 +109,35 @@ describe('enforceToolSeerGate', () => { 'ask_tarot_seer', ); expect(res).toBeNull(); + expect(consumeBillingActionMock).toHaveBeenCalledTimes(1); + }); + + it('rejects missing Hellenistic chart context before debiting credits', async () => { + const res = await enforceToolSeerGate( + post({ + userId: 'user-1', + question: 'Which areas of my life are most active?', + userProfile: { displayName: 'Ada' }, + hellenisticContext: null, + }), + { + userId: 'user-1', + question: 'Which areas of my life are most active?', + userProfile: { displayName: 'Ada' }, + hellenisticContext: null, + }, + 'hellenistic_ask_seer', + { + missingContextError: + 'Missing Hellenistic chart data. Please generate a reading first.', + }, + ); + + expect(res).not.toBeNull(); + expect(res!.status).toBe(400); + const data = await res!.json(); + expect(data.success).toBe(false); + expect(data.error).toMatch(/Hellenistic chart data/); + expect(consumeBillingActionMock).not.toHaveBeenCalled(); }); });