diff --git a/src/__tests__/api/doubts-check-duplicate.test.ts b/src/__tests__/api/doubts-check-duplicate.test.ts index 0c054a91..039b1228 100644 --- a/src/__tests__/api/doubts-check-duplicate.test.ts +++ b/src/__tests__/api/doubts-check-duplicate.test.ts @@ -133,6 +133,42 @@ describe("Doubt check-duplicate API endpoint", () => { await expect(res.json()).resolves.toMatchObject({ error: "Unauthorized" }); }); + it("excludes hidden doubts from candidate query", async () => { + let capturedWhereArg: any = null; + const trackingQuery: any = { + from: () => trackingQuery, + where: (arg: any) => { + capturedWhereArg = arg; + return trackingQuery; + }, + orderBy: () => trackingQuery, + limit: () => trackingQuery, + then: (resolve: any) => Promise.resolve(resolve([])), + }; + dbSelectMock.mockReturnValue(trackingQuery); + + const req = new Request("http://localhost/api/doubts/check-duplicate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "How does photosynthesis convert light into energy?", + }), + }); + + const res = await POST(req); + + expect(res.status).toBe(200); + expect(capturedWhereArg).not.toBeNull(); + const hasIsHidden = (expr: any): boolean => { + if (!expr || typeof expr !== "object") return false; + if (expr.name === "isHidden") return true; + if (expr.queryChunks) + return expr.queryChunks.some((c: any) => hasIsHidden(c)); + return false; + }; + expect(hasIsHidden(capturedWhereArg)).toBe(true); + }); + it("succeeds for authenticated classroom duplicate checks", async () => { const requireMembershipMock = requireMembership as jest.MockedFunction; requireMembershipMock.mockResolvedValue({ role: "student" }); diff --git a/src/__tests__/api/doubts-similarity.test.ts b/src/__tests__/api/doubts-similarity.test.ts index 06cbe880..12822d8d 100644 --- a/src/__tests__/api/doubts-similarity.test.ts +++ b/src/__tests__/api/doubts-similarity.test.ts @@ -1,3 +1,18 @@ +process.env.GROQ_API_KEY = "mock-groq-key"; + +jest.mock("@/lib/ai/groq-client", () => ({ + groq: { + embeddings: { + create: jest.fn(), + }, + chat: { + completions: { + create: jest.fn(), + }, + }, + }, +})); + import { currentUser } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; @@ -170,6 +185,42 @@ describe("Doubt similarity API endpoint", () => { }); }); + it("excludes hidden doubts from candidate query", async () => { + let capturedWhereArg: any = null; + const trackingQuery: any = { + from: () => trackingQuery, + where: (arg: any) => { + capturedWhereArg = arg; + return trackingQuery; + }, + orderBy: () => trackingQuery, + limit: () => trackingQuery, + then: (resolve: any) => Promise.resolve(resolve([])), + }; + dbSelectMock.mockReturnValue(trackingQuery); + + const req = new Request("http://localhost/api/doubts/check-similarity", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: "How does photosynthesis convert light into energy?", + }), + }); + + const res = await POST(req); + + expect(res.status).toBe(200); + expect(capturedWhereArg).not.toBeNull(); + const hasIsHidden = (expr: any): boolean => { + if (!expr || typeof expr !== "object") return false; + if (expr.name === "isHidden") return true; + if (expr.queryChunks) + return expr.queryChunks.some((c: any) => hasIsHidden(c)); + return false; + }; + expect(hasIsHidden(capturedWhereArg)).toBe(true); + }); + it("requires authentication for classroom similarity checks", async () => { const req = new Request("http://localhost/api/doubts/check-similarity", { method: "POST", diff --git a/src/__tests__/lib/embeddings.test.ts b/src/__tests__/lib/embeddings.test.ts index d209a6bd..e035761a 100644 --- a/src/__tests__/lib/embeddings.test.ts +++ b/src/__tests__/lib/embeddings.test.ts @@ -56,4 +56,33 @@ describe("Embeddings & Vector Duplicate Utilities", () => { const duplicates = await findSemanticDuplicates({ content: "What is momentum?" }); expect(duplicates).toEqual([]); }); + + it("findSemanticDuplicates excludes hidden doubts from candidate query", async () => { + const mockVector = new Array(1536).fill(0.1); + (groq.embeddings.create as jest.Mock).mockResolvedValueOnce({ + data: [{ embedding: mockVector }], + }); + + const selectMock = db.select as jest.Mock; + const whereMock = jest.fn().mockReturnThis(); + selectMock.mockReturnValue({ + from: jest.fn().mockReturnThis(), + where: whereMock, + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockResolvedValue([]), + }); + + await findSemanticDuplicates({ content: "What is momentum?" }); + + expect(whereMock).toHaveBeenCalledTimes(1); + const whereArg = whereMock.mock.calls[0][0]; + const hasIsHidden = (expr: any): boolean => { + if (!expr || typeof expr !== "object") return false; + if (expr.name === "isHidden") return true; + if (expr.queryChunks) + return expr.queryChunks.some((c: any) => hasIsHidden(c)); + return false; + }; + expect(hasIsHidden(whereArg)).toBe(true); + }); }); diff --git a/src/app/api/doubts/check-duplicate/route.ts b/src/app/api/doubts/check-duplicate/route.ts index eff6c02b..22385ac2 100644 --- a/src/app/api/doubts/check-duplicate/route.ts +++ b/src/app/api/doubts/check-duplicate/route.ts @@ -95,6 +95,7 @@ export async function POST(req: Request) { : isNull(doubtsTable.classroomId), eq(doubtsTable.type, "community"), isNull(doubtsTable.deletedAt), + eq(doubtsTable.isHidden, false), ), ) .orderBy(desc(doubtsTable.createdAt)) diff --git a/src/app/api/doubts/check-similarity/route.ts b/src/app/api/doubts/check-similarity/route.ts index f1fffd61..69f76afb 100644 --- a/src/app/api/doubts/check-similarity/route.ts +++ b/src/app/api/doubts/check-similarity/route.ts @@ -102,6 +102,7 @@ export async function POST(req: Request) { : isNull(doubtsTable.classroomId), eq(doubtsTable.type, "community"), isNull(doubtsTable.deletedAt), + eq(doubtsTable.isHidden, false), ), ) .orderBy(desc(doubtsTable.createdAt)) diff --git a/src/lib/ai/embeddings.ts b/src/lib/ai/embeddings.ts index 1c068007..309b524a 100644 --- a/src/lib/ai/embeddings.ts +++ b/src/lib/ai/embeddings.ts @@ -104,6 +104,7 @@ export async function findSemanticDuplicates(params: { : isNull(doubtsTable.classroomId), eq(doubtsTable.type, type), isNull(doubtsTable.deletedAt), + eq(doubtsTable.isHidden, false), // exclude null embeddings sql`${doubtsTable.embedding} IS NOT NULL`, );