Skip to content

Commit 3c982aa

Browse files
Merge PR #1001
2 parents 88666c2 + bcb885a commit 3c982aa

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { createErrorsRouter } from "./errors.js";
3030
import { config } from "../config/index.js";
3131
import { createBillingRateLimitMiddleware } from "../middleware/rateLimit.js";
3232
import { createAuditRouter } from "./audit.js";
33+
import { createInvoicesRouter } from "./invoices.js";
3334
import type { AuditService } from "../services/auditService.js";
3435

3536
const openApiPath = path.join(process.cwd(), "docs/openapi.json");
@@ -56,6 +57,7 @@ export function createApiRouter(deps: ApiRouterDeps = {}): Router {
5657
router.use("/spike", createSpikeRouter());
5758
router.use("/errors", createErrorsRouter({ auditService: deps.auditService }));
5859
router.use("/audit", createAuditRouter({ auditService: deps.auditService }));
60+
router.use("/invoices", createInvoicesRouter());
5961

6062
router.use(
6163
"/apis",

src/routes/invoices.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import express from 'express';
2+
import request from 'supertest';
3+
4+
// Mock the requireAuth middleware to pass a developerId
5+
jest.mock('../middleware/requireAuth.js', () => ({
6+
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
7+
(req as any).developerId = 'dev-user-123';
8+
next();
9+
}
10+
}));
11+
12+
import { createInvoicesRouter } from './invoices.js';
13+
import { errorHandler } from '../middleware/errorHandler.js';
14+
import prisma from '../lib/prisma.js';
15+
16+
// Mock prisma.invoice.findMany
17+
jest.mock('../lib/prisma.js', () => ({
18+
__esModule: true,
19+
default: {
20+
invoice: {
21+
findMany: jest.fn(),
22+
}
23+
}
24+
}));
25+
26+
describe('GET /api/invoices cursor pagination', () => {
27+
let app: express.Express;
28+
let findManyMock: jest.Mock;
29+
30+
beforeEach(() => {
31+
findManyMock = prisma.invoice.findMany as jest.Mock;
32+
findManyMock.mockReset();
33+
34+
app = express();
35+
app.use(express.json());
36+
app.use('/api/invoices', createInvoicesRouter());
37+
app.use(errorHandler);
38+
});
39+
40+
afterEach(() => {
41+
jest.clearAllMocks();
42+
});
43+
44+
it('returns paginated data without cursor', async () => {
45+
const mockInvoices = [
46+
{ id: 'uuid-1', created_at: new Date('2026-07-28T10:00:00Z') },
47+
{ id: 'uuid-2', created_at: new Date('2026-07-28T09:00:00Z') },
48+
];
49+
findManyMock.mockResolvedValue(mockInvoices);
50+
51+
const res = await request(app).get('/api/invoices?limit=2');
52+
53+
expect(res.status).toBe(200);
54+
expect(res.body.data).toHaveLength(2);
55+
expect(res.body.meta.hasMore).toBe(false);
56+
expect(res.body.meta.nextCursor).toBeNull();
57+
58+
expect(findManyMock).toHaveBeenCalledWith(expect.objectContaining({
59+
take: 3, // limit + 1
60+
where: { user_id: 'dev-user-123' },
61+
orderBy: [{ created_at: 'desc' }, { id: 'desc' }],
62+
cursor: undefined,
63+
}));
64+
});
65+
66+
it('generates nextCursor when hasMore is true', async () => {
67+
const mockInvoices = [
68+
{ id: 'uuid-1', created_at: new Date('2026-07-28T10:00:00Z') },
69+
{ id: 'uuid-2', created_at: new Date('2026-07-28T09:00:00Z') },
70+
{ id: 'uuid-3', created_at: new Date('2026-07-28T08:00:00Z') },
71+
];
72+
findManyMock.mockResolvedValue(mockInvoices);
73+
74+
const res = await request(app).get('/api/invoices?limit=2');
75+
76+
expect(res.status).toBe(200);
77+
expect(res.body.data).toHaveLength(2);
78+
expect(res.body.meta.hasMore).toBe(true);
79+
80+
// decoded cursor should contain the last item of the current page (uuid-2)
81+
const decodedCursor = JSON.parse(Buffer.from(res.body.meta.nextCursor, 'base64').toString('utf-8'));
82+
expect(decodedCursor.id).toBe('uuid-2');
83+
expect(decodedCursor.created_at).toBe('2026-07-28T09:00:00.000Z');
84+
});
85+
86+
it('queries using cursor when provided', async () => {
87+
const cursorData = {
88+
id: 'uuid-2',
89+
created_at: '2026-07-28T09:00:00.000Z'
90+
};
91+
const cursorBase64 = Buffer.from(JSON.stringify(cursorData)).toString('base64');
92+
findManyMock.mockResolvedValue([]);
93+
94+
const res = await request(app).get(`/api/invoices?limit=10&cursor=${cursorBase64}`);
95+
96+
expect(res.status).toBe(200);
97+
expect(findManyMock).toHaveBeenCalledWith(expect.objectContaining({
98+
cursor: { id: 'uuid-2' } // Should pass only id to prisma cursor
99+
}));
100+
});
101+
102+
it('rejects invalid cursor format (not base64 json)', async () => {
103+
const res = await request(app).get(`/api/invoices?limit=10&cursor=invalid_base64`);
104+
expect(res.status).toBe(400);
105+
expect(res.body.message).toContain('Invalid cursor format');
106+
expect(findManyMock).not.toHaveBeenCalled();
107+
});
108+
109+
it('rejects valid base64 but invalid schema payload', async () => {
110+
const cursorData = { id: 'not-a-uuid' }; // missing created_at and invalid uuid
111+
const cursorBase64 = Buffer.from(JSON.stringify(cursorData)).toString('base64');
112+
113+
const res = await request(app).get(`/api/invoices?cursor=${cursorBase64}`);
114+
expect(res.status).toBe(400);
115+
expect(res.body.message).toContain('Invalid cursor format');
116+
});
117+
118+
it('rejects invalid limit parameter', async () => {
119+
const res = await request(app).get('/api/invoices?limit=500');
120+
expect(res.status).toBe(400);
121+
expect(res.body.message).toContain('limit must be between 1 and 100');
122+
});
123+
124+
it('handles database errors gracefully', async () => {
125+
findManyMock.mockRejectedValue(new Error('DB connection failed'));
126+
const res = await request(app).get('/api/invoices');
127+
expect(res.status).toBe(500); // Standard error handler should catch it
128+
});
129+
});

src/routes/invoices.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Router } from 'express';
2+
import { z } from 'zod';
3+
import prisma from '../lib/prisma.js';
4+
import { requireAuth } from '../middleware/requireAuth.js';
5+
import { BadRequestError } from '../errors/index.js';
6+
7+
const cursorSchema = z.object({
8+
id: z.string().uuid(),
9+
created_at: z.string().datetime(),
10+
});
11+
12+
export function createInvoicesRouter(): Router {
13+
const router = Router();
14+
15+
router.get('/', requireAuth, async (req, res, next) => {
16+
try {
17+
const limit = parseInt(req.query.limit as string, 10) || 20;
18+
if (limit < 1 || limit > 100) {
19+
throw new BadRequestError('limit must be between 1 and 100');
20+
}
21+
22+
let cursorObj: { id: string; created_at: Date } | undefined;
23+
if (req.query.cursor && typeof req.query.cursor === 'string') {
24+
try {
25+
const decoded = Buffer.from(req.query.cursor, 'base64').toString('utf-8');
26+
const parsed = cursorSchema.parse(JSON.parse(decoded));
27+
cursorObj = { id: parsed.id, created_at: new Date(parsed.created_at) };
28+
} catch (e) {
29+
throw new BadRequestError('Invalid cursor format');
30+
}
31+
}
32+
33+
// Prisma keyset pagination uses the unique identifier `id` as the cursor
34+
// but correctly orders by `created_at` and `id` if specified in `orderBy`.
35+
const invoices = await prisma.invoice.findMany({
36+
where: { user_id: req.developerId },
37+
take: limit + 1,
38+
orderBy: [
39+
{ created_at: 'desc' },
40+
{ id: 'desc' }
41+
],
42+
cursor: cursorObj ? { id: cursorObj.id } : undefined,
43+
});
44+
45+
const hasMore = invoices.length > limit;
46+
const data = hasMore ? invoices.slice(0, limit) : invoices;
47+
48+
let nextCursor: string | null = null;
49+
if (hasMore) {
50+
const lastItem = data[data.length - 1];
51+
const cursorData = {
52+
created_at: lastItem.created_at.toISOString(),
53+
id: lastItem.id,
54+
};
55+
nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64');
56+
}
57+
58+
res.json({
59+
data,
60+
meta: {
61+
limit,
62+
hasMore,
63+
nextCursor,
64+
}
65+
});
66+
} catch (error) {
67+
next(error);
68+
}
69+
});
70+
71+
return router;
72+
}
73+
74+
export default createInvoicesRouter;

0 commit comments

Comments
 (0)