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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ INNGEST_SIGNING_KEY=your_inngest_signing_key_here
UPSTASH_REDIS_REST_URL=https://your-project.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_token_here

# AI Cost Controls
# Read at request time; set to false to disable the listed AI routes.
AI_ENABLED=true
AI_DAILY_USER_LIMIT=100

# Email Delivery (Resend)
# If missing: Transactional emails (like warnings or notifications) will fail to send.
RESEND_API_KEY=re_your_resend_key_here
Expand Down
130 changes: 130 additions & 0 deletions src/__tests__/api/ai-career-chat-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import axios from "axios";

import { POST } from "@/app/api/ai-career-chat-agent/route";
import {
buildAiProviderErrorResponse,
enforceAiAvailability,
} from "@/lib/ai/kill-switch";

jest.mock("axios", () => ({
__esModule: true,
default: {
post: jest.fn(),
},
}));

jest.mock("@clerk/nextjs/server", () => ({
currentUser: jest.fn().mockResolvedValue({
primaryEmailAddress: { emailAddress: "student@example.com" },
}),
}));

jest.mock("@/lib/auth-utils", () => ({
checkUserBlock: jest.fn().mockResolvedValue({ errorResponse: null }),
}));

jest.mock("@/lib/ai/kill-switch", () => ({
buildAiProviderErrorResponse: jest.fn(
() => new Response(JSON.stringify({ error: "provider unavailable" }), { status: 503 }),
),
enforceAiAvailability: jest.fn().mockResolvedValue(null),
}));

describe("AI Career Chat API Endpoint", () => {
beforeEach(() => {
jest.clearAllMocks();
(enforceAiAvailability as jest.Mock).mockResolvedValue(null);
(buildAiProviderErrorResponse as jest.Mock).mockImplementation(
() => new Response(JSON.stringify({ error: "provider unavailable" }), { status: 503 }),
);
});

it("returns 400 for malformed JSON before quota or provider calls", async () => {
const request = new Request("http://localhost/api/ai-career-chat-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not-json",
});

const response = await POST(request as any);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Invalid JSON body",
});
expect(enforceAiAvailability).not.toHaveBeenCalled();
expect(axios.post).not.toHaveBeenCalled();
});

it.each(["null", "[]"])(
"returns 400 for non-object JSON body %s",
async (body) => {
const request = new Request("http://localhost/api/ai-career-chat-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});

const response = await POST(request as any);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: "Invalid JSON body",
});
expect(enforceAiAvailability).not.toHaveBeenCalled();
expect(axios.post).not.toHaveBeenCalled();
},
);

it("sets a timeout on the provider request", async () => {
(axios.post as jest.Mock).mockResolvedValue({
data: { choices: [{ message: { content: "response" } }] },
});
const request = new Request("http://localhost/api/ai-career-chat-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userInput: "How should I learn TypeScript?" }),
});

const response = await POST(request as any);

expect(response.status).toBe(200);
expect(axios.post).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
expect.objectContaining({ timeout: 15_000 }),
);
});

it("logs only safe provider error fields", async () => {
const error = {
message: "provider failed",
status: 503,
code: "ETIMEDOUT",
config: { headers: { Authorization: "secret" } },
};
(axios.post as jest.Mock).mockRejectedValue(error);
const consoleError = jest.spyOn(console, "error").mockImplementation();
const request = new Request("http://localhost/api/ai-career-chat-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userInput: "How should I learn TypeScript?" }),
});

await POST(request as any);

expect(consoleError).toHaveBeenCalledWith(
"AI Career Chat Provider Error:",
{
message: "provider failed",
status: 503,
code: "ETIMEDOUT",
},
);
expect(consoleError).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ config: expect.anything() }),
);
consoleError.mockRestore();
});
});
8 changes: 8 additions & 0 deletions src/__tests__/api/ask-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ jest.mock('@/lib/ratelimit', () => ({
reset: Date.now() + 60_000,
}),
},
aiDailyLimiter: {
limit: jest.fn().mockResolvedValue({
success: true,
limit: 100,
remaining: 99,
reset: Date.now() + 86_400_000,
}),
},
}));

jest.mock('@clerk/nextjs/server', () => ({
Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/api/confusion-spike-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const mockGroq = jest.fn().mockImplementation(() => ({
chat: { completions: { create: jest.fn() } },
}));

jest.mock('groq-sdk', () => ({
__esModule: true,
default: mockGroq,
}));

jest.mock('@/inngest/client', () => ({
inngest: {
createFunction: jest.fn((_config, _handler) => ({ id: 'detect-confusion-spikes' })),
},
}));

jest.mock('@/configs/db', () => ({ db: {} }));
jest.mock('@/configs/schema', () => ({
doubtsTable: {},
confusionAlertsTable: {},
}));

describe('confusion spike detector configuration', () => {
it('can load during a production build without a Groq API key', async () => {
const originalApiKey = process.env.GROQ_API_KEY;
delete process.env.GROQ_API_KEY;

await import('@/app/api/inngest/ConfusionSpikeDetector');

expect(mockGroq).toHaveBeenCalledWith({ apiKey: 'dummy_key' });

if (originalApiKey) {
process.env.GROQ_API_KEY = originalApiKey;
}
});
});
46 changes: 46 additions & 0 deletions src/__tests__/api/doubts-similarity.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { currentUser } from "@clerk/nextjs/server";

import { POST } from "@/app/api/doubts/check-similarity/route";
import { getAnonymousQuotaIdentifier } from "@/lib/request-identity";
import { getSafeErrorDetails } from "@/lib/safe-error-details";

jest.mock("@clerk/nextjs/server", () => ({
currentUser: jest.fn(),
}));

jest.mock("@/lib/ai/kill-switch", () => ({
buildAiProviderErrorResponse: jest.fn(
() =>
new Response(JSON.stringify({ error: "AI provider unavailable" }), {
status: 503,
}),
),
enforceAiAvailability: jest.fn().mockResolvedValue(null),
}));

const createQueryMock = () => {
const query: any = {
from: () => query,
Expand Down Expand Up @@ -58,6 +70,40 @@ describe("Doubt similarity API endpoint", () => {
expect(currentUserMock).not.toHaveBeenCalled();
});

it("ignores spoofable forwarded IP values for anonymous quota keys", () => {
const req = new Request("http://localhost/api/doubts/check-similarity", {
headers: { "x-forwarded-for": "203.0.113.1" },
});

expect(getAnonymousQuotaIdentifier(req)).toBe("anonymous");
});

it("uses a validated trusted proxy IP for anonymous quota keys", () => {
const req = new Request("http://localhost/api/doubts/check-similarity", {
headers: {
"x-forwarded-for": "spoofed",
"x-real-ip": "203.0.113.7",
},
});

expect(getAnonymousQuotaIdentifier(req)).toBe("ip:203.0.113.7");
});

it("keeps sensitive provider metadata out of error logs", () => {
const error = {
message: "provider failed",
code: "ETIMEDOUT",
response: { status: 503, config: { headers: { Authorization: "secret" } } },
config: { headers: { Authorization: "secret" } },
};

expect(getSafeErrorDetails(error)).toEqual({
message: "provider failed",
status: 503,
code: "ETIMEDOUT",
});
});

it("requires authentication for classroom similarity checks", async () => {
const req = new Request("http://localhost/api/doubts/check-similarity", {
method: "POST",
Expand Down
121 changes: 121 additions & 0 deletions src/__tests__/lib/ai-kill-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { createHash } from "node:crypto";

import {
buildAiProviderErrorResponse,
enforceAiAvailability,
} from "@/lib/ai/kill-switch";
import { aiDailyLimiter } from "@/lib/ratelimit";

jest.mock("@/lib/ratelimit", () => ({
aiDailyLimiter: {
limit: jest.fn(),
},
}));

describe("AI availability controls", () => {
const originalAiEnabled = process.env.AI_ENABLED;
const mockDailyLimit = aiDailyLimiter.limit as jest.MockedFunction<
typeof aiDailyLimiter.limit
>;

beforeEach(() => {
process.env.AI_ENABLED = "true";
mockDailyLimit.mockReset();
mockDailyLimit.mockResolvedValue({
success: true,
limit: 100,
remaining: 99,
reset: Date.now() + 86_400_000,
});
});

afterAll(() => {
if (originalAiEnabled === undefined) {
delete process.env.AI_ENABLED;
} else {
process.env.AI_ENABLED = originalAiEnabled;
}
});

it("disables AI without consuming quota", async () => {
process.env.AI_ENABLED = "false";

const response = await enforceAiAvailability("student@example.com");

expect(response?.status).toBe(503);
expect(mockDailyLimit).not.toHaveBeenCalled();
await expect(response?.json()).resolves.toEqual({
error: "AI features are temporarily unavailable.",
code: "AI_DISABLED",
});
});

it("uses a normalized, hashed per-user daily quota key", async () => {
await expect(
enforceAiAvailability(" Student@Example.COM "),
).resolves.toBeNull();

const digest = createHash("sha256")
.update("student@example.com")
.digest("hex");
expect(mockDailyLimit).toHaveBeenCalledWith(
`ai-daily:${digest}`,
);
});

it("returns a retryable response when the daily quota is exhausted", async () => {
mockDailyLimit.mockResolvedValueOnce({
success: false,
limit: 100,
remaining: 0,
reset: Date.now() + 60_000,
});

const response = await enforceAiAvailability("student@example.com");

expect(response?.status).toBe(429);
expect(response?.headers.get("Retry-After")).toBeTruthy();
await expect(response?.json()).resolves.toEqual({
error: "Your daily AI request limit has been reached.",
code: "AI_DAILY_LIMIT_REACHED",
});
});

it("returns a retryable response when the quota backend fails", async () => {
mockDailyLimit.mockRejectedValueOnce(new Error("redis unavailable"));
const consoleError = jest.spyOn(console, "error").mockImplementation();

const response = await enforceAiAvailability("student@example.com");

expect(response?.status).toBe(503);
expect(response?.headers.get("Retry-After")).toBe("60");
await expect(response?.json()).resolves.toEqual({
error: "AI features are temporarily unavailable.",
code: "AI_QUOTA_UNAVAILABLE",
});
consoleError.mockRestore();
});

it("maps transient provider failures to a graceful 503", async () => {
const response = buildAiProviderErrorResponse({ status: 503 });

expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({
error: "The AI provider is temporarily unavailable. Please try again shortly.",
code: "AI_PROVIDER_UNAVAILABLE",
});
});

it("maps permanent provider failures to a non-leaking 500", async () => {
const response = buildAiProviderErrorResponse({
status: 400,
message: "raw provider detail",
});

expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({
error: "The AI request could not be completed.",
code: "AI_PROVIDER_ERROR",
});
});
});
Loading
Loading