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
9 changes: 3 additions & 6 deletions src/__tests__/inngest/digest-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/doubts/action/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
199 changes: 199 additions & 0 deletions src/app/api/replies/vote/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => {
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);
});
});
50 changes: 39 additions & 11 deletions src/configs/db.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof drizzle> | 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<typeof drizzle>;

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 };
Loading