diff --git a/src/__tests__/inngest/digest-functions.test.ts b/src/__tests__/inngest/digest-functions.test.ts index ece45968..a30d9718 100644 --- a/src/__tests__/inngest/digest-functions.test.ts +++ b/src/__tests__/inngest/digest-functions.test.ts @@ -121,8 +121,7 @@ describe("sendDailyDigest — per-user step isolation", () => { const { sendDailyDigest } = await import("@/inngest/functions"); const step = makeStep(); - // @ts-expect-error — internal test invocation bypasses Inngest runtime types - await expect(sendDailyDigest({ step })).rejects.toThrow("SMTP timeout"); + await expect((sendDailyDigest as any).fn({ step })).rejects.toThrow("SMTP timeout"); // Alice's row MUST have been deleted (email succeeded). expect(dbMock.delete).toHaveBeenCalledTimes(1); @@ -158,13 +157,11 @@ describe("sendDailyDigest — per-user step isolation", () => { // First run — completes successfully. const step = makeStep(); - // @ts-expect-error - await sendDailyDigest({ step }); + await (sendDailyDigest as any).fn({ step }); expect(mockSendDigestEmail).toHaveBeenCalledTimes(1); // Simulate Inngest retry: same step shim (memoised results) → per-user step is a no-op. - // @ts-expect-error - await sendDailyDigest({ step }); + await (sendDailyDigest as any).fn({ step }); // sendDigestEmail must NOT be called again. expect(mockSendDigestEmail).toHaveBeenCalledTimes(1); }); diff --git a/src/app/api/doubts/action/[id]/route.ts b/src/app/api/doubts/action/[id]/route.ts index a32ba08e..97306ea5 100644 --- a/src/app/api/doubts/action/[id]/route.ts +++ b/src/app/api/doubts/action/[id]/route.ts @@ -68,7 +68,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const secureUserIdentifier = email; - const result = await db.transaction(async (tx: typeof db) => { + const result = await db.transaction(async (tx: any) => { const locked = await tx.execute( sql`SELECT ${doubtsTable.id} FROM ${doubtsTable} WHERE ${doubtsTable.id} = ${doubtId} FOR UPDATE` diff --git a/src/app/api/replies/vote/route.test.ts b/src/app/api/replies/vote/route.test.ts new file mode 100644 index 00000000..6a6e3f7c --- /dev/null +++ b/src/app/api/replies/vote/route.test.ts @@ -0,0 +1,199 @@ +import { POST } from './route'; + +const mockCurrentUser = jest.fn(); +const mockSelectResultQueue: any[] = []; +const mockUpdateResultQueue: any[] = []; + +jest.mock('@/inngest/client', () => ({ + inngest: { send: jest.fn().mockResolvedValue({}) }, +})); + +jest.mock('@clerk/nextjs/server', () => ({ + currentUser: () => mockCurrentUser(), +})); + +const mockCreateQuery = (data: any) => ({ + from: () => mockCreateQuery(data), + where: () => mockCreateQuery(data), + limit: () => mockCreateQuery(data), + then: (resolve: any) => Promise.resolve(resolve(data)), +}); + +jest.mock('@/configs/db', () => ({ + db: (() => { + const db = { + select: jest.fn().mockImplementation(() => mockCreateQuery(mockSelectResultQueue.shift() ?? [])), + delete: jest.fn().mockImplementation(() => ({ + where: jest.fn().mockResolvedValue({}), + })), + insert: jest.fn().mockImplementation(() => ({ + values: jest.fn().mockResolvedValue({}), + })), + update: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockImplementation(() => ({ + where: jest.fn().mockImplementation(() => ({ + returning: jest.fn().mockResolvedValue(mockUpdateResultQueue.shift() ?? []), + })), + })), + })), + } as any; + + // Add transaction that runs callback with a tx proxy sharing the same mocks + db.transaction = jest.fn().mockImplementation((callback: (tx: any) => Promise) => { + const tx = { + select: jest.fn().mockImplementation(() => mockCreateQuery(mockSelectResultQueue.shift() ?? [])), + delete: jest.fn().mockImplementation(() => ({ + where: jest.fn().mockResolvedValue({}), + })), + insert: db.insert, + update: db.update, + }; + return callback(tx); + }); + + return db; + })(), +})); + +describe('Reply Vote API Endpoint', () => { + beforeEach(() => { + mockCurrentUser.mockReset(); + mockSelectResultQueue.length = 0; + mockUpdateResultQueue.length = 0; + jest.clearAllMocks(); + }); + + it('uses the authenticated Clerk identity instead of the client userName', async () => { + mockCurrentUser.mockResolvedValue({ + id: 'clerk_user_id', + username: null, + fullName: 'Clerk Teacher', + firstName: 'Clerk', + primaryEmailAddress: { emailAddress: 'teacher@example.com' }, + }); + + mockSelectResultQueue.push( + [], // user block check select + [{ id: 1, replyId: 1, userEmail: 'other@example.com' }], + [] + ); + mockUpdateResultQueue.push([{ id: 1, upvotes: 1 }]); + + const req = new Request('http://localhost/api/replies/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replyId: 1 }), + }); + + const res = await POST(req); + const json = await res?.json(); + const { db } = jest.requireMock('@/configs/db'); + + expect(res?.status).toBe(200); + expect(json.hasUpvoted).toBe(true); + expect(db.insert).toHaveBeenCalled(); + expect(db.insert.mock.results[0].value.values).toHaveBeenCalledWith({ + userEmail: 'teacher@example.com', + replyId: 1, + }); + }); + + it('successfully upvotes a reply, returning 200 status and the incremented upvote counter', async () => { + mockCurrentUser.mockResolvedValue({ + id: 'voter_clerk_id', + username: 'voter', + fullName: 'Voter User', + primaryEmailAddress: { emailAddress: 'voter@example.com' }, + }); + + mockSelectResultQueue.push( + [], // 1. checkUserBlock userBlocksTable query (not blocked) + [{ id: 1, userEmail: 'author@example.com', upvotes: 5 }], // 2. repliesTable query + [] // 3. replyLikesTable query inside transaction + ); + + mockUpdateResultQueue.push([{ id: 1, upvotes: 6, userEmail: 'author@example.com' }]); + + const req = new Request('http://localhost/api/replies/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replyId: 1 }), + }); + + const res = await POST(req); + const json = await res?.json(); + + expect(res?.status).toBe(200); + expect(json.hasUpvoted).toBe(true); + expect(json.upvotes).toBe(6); + + const { inngest } = jest.requireMock('@/inngest/client'); + expect(inngest.send).toHaveBeenCalledWith({ + name: 'karma/answer.upvoted', + data: { + replyAuthorEmail: 'author@example.com', + replyId: 1, + doubtId: undefined, + }, + }); + }); + + it('prevents a user from upvoting their own reply', async () => { + mockCurrentUser.mockResolvedValue({ + id: 'author_clerk_id', + username: 'author', + fullName: 'Author User', + primaryEmailAddress: { emailAddress: 'author@example.com' }, + }); + + mockSelectResultQueue.push( + [], // checkUserBlock (not blocked) + [{ id: 1, userEmail: 'author@example.com', upvotes: 5 }] // repliesTable select + ); + + const req = new Request('http://localhost/api/replies/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replyId: 1 }), + }); + + const res = await POST(req); + expect(res?.status).toBe(403); + const json = await res?.json(); + expect(json.error).toContain('cannot upvote your own reply'); + + const { inngest } = jest.requireMock('@/inngest/client'); + expect(inngest.send).not.toHaveBeenCalled(); + }); + + it('successfully removes an existing upvote, returning 200 status and the decremented upvote counter', async () => { + mockCurrentUser.mockResolvedValue({ + id: 'voter_clerk_id', + username: 'voter', + fullName: 'Voter User', + primaryEmailAddress: { emailAddress: 'voter@example.com' }, + }); + + mockSelectResultQueue.push( + [], // 1. checkUserBlock userBlocksTable query (not blocked) + [{ id: 1, userEmail: 'author@example.com', upvotes: 5 }], // 2. repliesTable query + [{ id: 10, userEmail: 'voter@example.com', replyId: 1 }] // 3. replyLikesTable query inside transaction (existing like) + ); + + mockUpdateResultQueue.push([{ id: 1, upvotes: 4, userEmail: 'author@example.com' }]); + + const req = new Request('http://localhost/api/replies/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ replyId: 1 }), + }); + + const res = await POST(req); + const json = await res?.json(); + const { db } = jest.requireMock('@/configs/db'); + + expect(res?.status).toBe(200); + expect(json.hasUpvoted).toBe(false); + expect(json.upvotes).toBe(4); + }); +}); diff --git a/src/configs/db.tsx b/src/configs/db.tsx index 56bb1c98..75a6f954 100644 --- a/src/configs/db.tsx +++ b/src/configs/db.tsx @@ -1,18 +1,46 @@ -import { drizzle } from 'drizzle-orm/neon-http'; +import { Pool, neonConfig } from '@neondatabase/serverless'; +import { drizzle } from 'drizzle-orm/neon-serverless'; import { getDatabaseUrl } from './database-url'; -let _db: ReturnType | undefined; +// Setup WebSocket constructor for Node environments (required for @neondatabase/serverless Pool) +if (typeof globalThis.WebSocket === 'undefined') { + const ws = require('ws'); + neonConfig.webSocketConstructor = ws; +} -export const db = (() => { - if (process.env.NODE_ENV === 'test') { - return undefined as any; +let pool: Pool; +let dbClient: ReturnType; + +if (process.env.NODE_ENV === 'production') { + pool = new Pool({ + connectionString: getDatabaseUrl(), + max: process.env.DATABASE_POOL_MAX ? parseInt(process.env.DATABASE_POOL_MAX, 10) : 10, + idleTimeoutMillis: process.env.DATABASE_POOL_IDLE_TIMEOUT ? parseInt(process.env.DATABASE_POOL_IDLE_TIMEOUT, 10) : 30000, + }); + pool.on('error', (err: Error) => { + console.error('Neon Database Pool connection error:', err); + }); + dbClient = drizzle({ client: pool }); +} else { + const g = globalThis as any; + if (!g.pool) { + g.pool = new Pool({ + connectionString: getDatabaseUrl(), + max: process.env.DATABASE_POOL_MAX ? parseInt(process.env.DATABASE_POOL_MAX, 10) : 10, + idleTimeoutMillis: process.env.DATABASE_POOL_IDLE_TIMEOUT ? parseInt(process.env.DATABASE_POOL_IDLE_TIMEOUT, 10) : 30000, + }); + g.pool.on('error', (err: Error) => { + console.error('Neon Database Pool connection error:', err); + }); } - if (!_db) { - _db = drizzle(getDatabaseUrl()); + pool = g.pool; + if (!g.dbClient) { + g.dbClient = drizzle({ client: pool }); } - return _db; -})(); + dbClient = g.dbClient; +} -/** Re-export the transaction helper so callers import from one place. */ -export { db as default }; +export const db = dbClient; +/** Re-export the database client so callers import from one place. */ +export { db as default };