From 813e560c8df4a1a87af74b16c6776dedf667299d Mon Sep 17 00:00:00 2001 From: Bidifortune Date: Thu, 28 May 2026 16:09:35 +0100 Subject: [PATCH] feat(backend): standardize pagination and cursor validation across list endpoints (#641) - Create shared pagination helpers in src/utils/pagination.ts - Add validateCursor, validateLimit, encodeCursor, PaginationError utilities - Add cursorPaginationSchema for Zod validation - Update subscription-service to use shared cursor validation - Update subscriptions, merchants, audit, gift-card-ledger routes to use helpers - Add comprehensive tests for pagination utilities - Update SUBSCRIPTION_API.md with cursor pagination documentation Error codes: INVALID_CURSOR, MALFORMED_CURSOR, INVALID_LIMIT --- backend/SUBSCRIPTION_API.md | 90 +++++++++- backend/src/routes/audit.ts | 15 +- backend/src/routes/gift-card-ledger.ts | 14 +- backend/src/routes/merchants.ts | 12 +- backend/src/routes/subscriptions.ts | 51 +++--- backend/src/schemas/common.ts | 6 + backend/src/services/subscription-service.ts | 25 +-- backend/src/utils/pagination.ts | 95 +++++++++++ backend/tests/pagination.test.ts | 167 +++++++++++++++++++ backend/tests/subscription-service.test.ts | 55 +++--- backend/tsconfig.json | 4 +- 11 files changed, 454 insertions(+), 80 deletions(-) create mode 100644 backend/src/utils/pagination.ts create mode 100644 backend/tests/pagination.test.ts diff --git a/backend/SUBSCRIPTION_API.md b/backend/SUBSCRIPTION_API.md index 25efda67..60658fed 100644 --- a/backend/SUBSCRIPTION_API.md +++ b/backend/SUBSCRIPTION_API.md @@ -30,8 +30,74 @@ Authorization: Bearer **Query Parameters:** - `status` (optional): Filter by status (`active`, `cancelled`, `paused`, `trial`) - `category` (optional): Filter by category -- `limit` (optional): Number of results (default: all) -- `offset` (optional): Pagination offset +- `limit` (optional): Number of results per page (default: 20, max: 100) +- `cursor` (optional): Pagination cursor for fetching next page + +#### Cursor-Based Pagination + +The subscriptions endpoint supports cursor-based pagination, which is more efficient for large datasets and provides consistent results when data changes between requests. + +**How it works:** + +1. **First Request**: Fetch the first page without a cursor + ```http + GET /api/subscriptions?limit=10 + ``` + +2. **Response includes `nextCursor`**: If more results exist, the response includes a `nextCursor` value + ```json + { + "success": true, + "data": [...], + "pagination": { + "total": 100, + "limit": 10, + "hasMore": true, + "nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ==" + } + } + ``` + +3. **Next Page Request**: Use the `nextCursor` value to fetch the next page + ```http + GET /api/subscriptions?limit=10&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ== + ``` + +**Cursor Semantics:** + +- **Format**: Base64-encoded JSON containing a `created_at` timestamp +- **Expiration**: Cursors do not expire but may become invalid if the underlying data is deleted +- **Ordering**: Results are ordered by `created_at` descending (newest first) +- **Limit Range**: Must be between 1 and 100 (inclusive) + +**Error Responses:** + +- **Invalid Cursor (400)**: + ```json + { + "success": false, + "error": "Invalid pagination cursor", + "code": "INVALID_CURSOR" + } + ``` + +- **Malformed Cursor (400)**: + ```json + { + "success": false, + "error": "Invalid cursor: missing created_at field", + "code": "MALFORMED_CURSOR" + } + ``` + +- **Invalid Limit (400)**: + ```json + { + "success": false, + "error": "Limit must be between 1 and 100", + "code": "INVALID_LIMIT" + } + ``` **Response:** ```json @@ -48,9 +114,10 @@ Authorization: Bearer } ], "pagination": { - "total": 10, + "total": 100, "limit": 20, - "offset": 0 + "hasMore": true, + "nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNC0wMS0wMlQwMDowMDo1MC4wMFoifQ==" } } ``` @@ -230,9 +297,22 @@ Content-Type: application/json } ``` +### Pagination Error Response + +```json +{ + "success": false, + "error": "Invalid limit: must be between 1 and 100", + "code": "INVALID_LIMIT" +} +``` + ### Status Codes -- `400`: Bad Request (validation error) +- `400`: Bad Request (validation error, including pagination errors) + - `INVALID_CURSOR`: The provided cursor is malformed or invalid + - `MALFORMED_CURSOR`: The cursor is not properly formatted + - `INVALID_LIMIT`: The limit parameter is out of range or not a valid integer - `401`: Unauthorized (missing/invalid token) - `403`: Forbidden (ownership validation failed) - `404`: Not Found diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index ae4cb20f..d5fa1ce9 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -6,6 +6,7 @@ import { requireRole } from '../middleware/rbac'; import { validate } from '../middleware/validate'; import logger from '../config/logger'; import { auditBatchSchema, auditQuerySchema } from '../schemas/audit'; +import { PaginationError } from '../utils/pagination'; const router: Router = Router(); @@ -98,11 +99,19 @@ router.get( hasMore: offset + limit < total, }, }); - } catch (error) { + } catch (error: any) { + if (error.name === 'PaginationError') { + res.status(400).json({ + success: false, + error: error.message, + code: error.code, + }); + return; + } logger.error('Error in GET /api/admin/audit:', error); res.status(500).json({ - error: 'Internal server error', - message: error instanceof Error ? error.message : 'Unknown error', + success: false, + error: error instanceof Error ? error.message : 'Internal server error', }); } }, diff --git a/backend/src/routes/gift-card-ledger.ts b/backend/src/routes/gift-card-ledger.ts index e2784629..ecab4c8d 100644 --- a/backend/src/routes/gift-card-ledger.ts +++ b/backend/src/routes/gift-card-ledger.ts @@ -4,6 +4,7 @@ import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { giftCardLedgerService } from '../services/gift-card-ledger-service'; import { validateRequest } from '../utils/validation'; import { BadRequestError } from '../errors'; +import { validateLimit } from '../utils/pagination'; const router = Router(); router.use(authenticate); @@ -27,9 +28,16 @@ router.get('/balance', async (req: AuthenticatedRequest, res: Response) => { /** GET /api/gift-card-ledger/history */ router.get('/history', async (req: AuthenticatedRequest, res: Response) => { - const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); - const history = await giftCardLedgerService.getHistory(req.user!.id, limit); - res.json({ success: true, data: history }); + try { + const limit = validateLimit(req.query.limit, 100, 50); + const history = await giftCardLedgerService.getHistory(req.user!.id, limit); + res.json({ success: true, data: history }); + } catch (error: any) { + if (error.name === 'PaginationError') { + throw new BadRequestError(error.message); + } + throw error; + } }); /** POST /api/gift-card-ledger/top-up */ diff --git a/backend/src/routes/merchants.ts b/backend/src/routes/merchants.ts index f30a4aca..aca14161 100644 --- a/backend/src/routes/merchants.ts +++ b/backend/src/routes/merchants.ts @@ -4,6 +4,8 @@ import { validate } from '../middleware/validate'; import logger from '../config/logger'; import { adminAuth } from '../middleware/admin'; import { createMerchantSchema, updateMerchantSchema, merchantQuerySchema } from '../schemas/merchant'; +import { validateRequest } from '../utils/validation'; +import { PaginationError } from '../utils/pagination'; const router: Router = Router(); @@ -29,7 +31,15 @@ router.get( data: result.merchants, pagination: { total: result.total, limit, offset }, }); - } catch (error) { + } catch (error: any) { + if (error.name === 'PaginationError') { + res.status(400).json({ + success: false, + error: error.message, + code: error.code, + }); + return; + } logger.error('List merchants error:', error); res.status(500).json({ success: false, diff --git a/backend/src/routes/subscriptions.ts b/backend/src/routes/subscriptions.ts index e8f8b715..e11c4c20 100644 --- a/backend/src/routes/subscriptions.ts +++ b/backend/src/routes/subscriptions.ts @@ -11,6 +11,7 @@ import { SUPPORTED_CURRENCIES } from '../constants/currencies'; import logger from '../config/logger'; import { BadRequestError } from '../errors'; import { validateRequest } from '../utils/validation'; +import { cursorPaginationSchema } from '../schemas/common'; const router = Router(); @@ -109,30 +110,36 @@ router.use(authenticate); * List user's subscriptions */ router.get('/', async (req: AuthenticatedRequest, res: Response) => { - const { status, category, limit, cursor } = req.query; - - const limitNum = limit ? parseInt(limit as string, 10) : 20; - if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) { - throw new BadRequestError('Limit must be a number between 1 and 100'); - } + try { + const { status, category, cursor } = req.query; + const pagination = validateRequest(cursorPaginationSchema, { + limit: req.query.limit, + cursor: req.query.cursor, + }); - const result = await subscriptionService.listSubscriptions(req.user!.id, { - status: status as any, - category: category as string, - limit: limitNum, - cursor: cursor as string, - }); + const result = await subscriptionService.listSubscriptions(req.user!.id, { + status: status as any, + category: category as string, + limit: pagination.limit, + cursor: pagination.cursor, + }); - res.json({ - success: true, - data: result.subscriptions, - pagination: { - total: result.total, - limit: limitNum, - hasMore: result.hasMore, - nextCursor: result.nextCursor ?? null, - }, - }); + res.json({ + success: true, + data: result.subscriptions, + pagination: { + total: result.total, + limit: pagination.limit, + hasMore: result.hasMore, + nextCursor: result.nextCursor ?? null, + }, + }); + } catch (error: any) { + if (error.name === 'PaginationError') { + throw new BadRequestError(error.message); + } + throw error; + } }); /** diff --git a/backend/src/schemas/common.ts b/backend/src/schemas/common.ts index 6058ca6d..b9a00a10 100644 --- a/backend/src/schemas/common.ts +++ b/backend/src/schemas/common.ts @@ -30,3 +30,9 @@ export const paginationQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), }); + +/** Reusable cursor-based pagination schema. */ +export const cursorPaginationSchema = z.object({ + limit: z.coerce.number().int().min(1, 'Limit must be at least 1').max(100, 'Limit must not exceed 100').default(20), + cursor: z.string().optional(), +}); diff --git a/backend/src/services/subscription-service.ts b/backend/src/services/subscription-service.ts index 4dde2545..895b38de 100644 --- a/backend/src/services/subscription-service.ts +++ b/backend/src/services/subscription-service.ts @@ -7,6 +7,7 @@ import { referralService } from "./referral-service"; import logger from "../config/logger"; import { DatabaseTransaction } from "../utils/transaction"; import SERVICE_CATEGORIES from "../../services/service-categories"; +import { validateCursor, encodeCursor } from "../utils/pagination"; import type { Subscription, SubscriptionCreateInput, @@ -592,6 +593,8 @@ export class SubscriptionService { ): Promise { const limit = Math.min(options.limit ?? 20, 100); + const validatedCursor = validateCursor(options.cursor); + let query = supabase .from("subscriptions") .select("*", { count: "exact" }) @@ -607,23 +610,13 @@ export class SubscriptionService { query = query.eq("category", options.category); } - if (options.cursor) { - try { - const decoded = JSON.parse( - Buffer.from(options.cursor, "base64").toString("utf-8"), - ); - if (!decoded.created_at) { - throw new Error("Invalid cursor: missing created_at"); - } - query = query.lt("created_at", decoded.created_at); - } catch { - throw new Error("Invalid pagination cursor"); - } + if (validatedCursor) { + query = query.lt("created_at", validatedCursor.createdAt); } const { data: rows, error, count } = await query; - if (error) { +if (error) { throw new Error(`Failed to fetch subscriptions: ${error.message}`); } @@ -633,11 +626,7 @@ export class SubscriptionService { // Build next cursor from the last item in the page const nextCursor = hasMore && subscriptions.length > 0 - ? Buffer.from( - JSON.stringify({ - created_at: subscriptions[subscriptions.length - 1].created_at, - }), - ).toString("base64") + ? encodeCursor({ createdAt: subscriptions[subscriptions.length - 1].created_at }) : null; return { diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts new file mode 100644 index 00000000..686243bc --- /dev/null +++ b/backend/src/utils/pagination.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export const cursorPaginationSchema = z.object({ + limit: z.coerce.number().int().min(1, 'Limit must be at least 1').max(MAX_LIMIT, `Limit must not exceed ${MAX_LIMIT}`).default(DEFAULT_LIMIT), + cursor: z.string().optional(), +}); + +export type CursorPaginationInput = z.infer; + +export interface ValidatedCursor { + createdAt: string; +} + +export class PaginationError extends Error { + constructor( + message: string, + public readonly code: 'INVALID_CURSOR' | 'INVALID_LIMIT' | 'MALFORMED_CURSOR', + ) { + super(message); + this.name = 'PaginationError'; + } +} + +export function validateCursor(cursor: string | undefined): ValidatedCursor | null { + if (!cursor) { + return null; + } + + try { + const decoded = Buffer.from(cursor, 'base64').toString('utf-8'); + const parsed = JSON.parse(decoded); + + if (!parsed || typeof parsed !== 'object') { + throw new PaginationError('Cursor must decode to a valid object', 'MALFORMED_CURSOR'); + } + + const createdAt = parsed.createdAt ?? parsed.created_at; + if (!createdAt || typeof createdAt !== 'string') { + throw new PaginationError('Invalid cursor: missing created_at field', 'MALFORMED_CURSOR'); + } + + return { createdAt }; + } catch (error) { + if (error instanceof PaginationError) { + throw error; + } + throw new PaginationError('Invalid pagination cursor', 'INVALID_CURSOR'); + } +} + +export function validateLimit(limit: unknown, max: number = MAX_LIMIT, defaultVal: number = DEFAULT_LIMIT): number { + if (limit === undefined || limit === null || limit === '') { + return defaultVal; + } + + const parsed = typeof limit === 'string' ? parseInt(limit, 10) : Number(limit); + + if (isNaN(parsed) || !Number.isInteger(parsed)) { + throw new PaginationError('Limit must be a valid integer', 'INVALID_LIMIT'); + } + + if (parsed < 1) { + throw new PaginationError(`Limit must be at least 1`, 'INVALID_LIMIT'); + } + + if (parsed > max) { + throw new PaginationError(`Limit must not exceed ${max}`, 'INVALID_LIMIT'); + } + + return parsed; +} + +export function encodeCursor(data: { createdAt: string }): string { + return Buffer.from(JSON.stringify(data)).toString('base64'); +} + +export function createPaginatedResponse( + items: T[], + total: number, + limit: number, + nextCursor: string | null, +) { + return { + items, + pagination: { + total, + limit, + hasMore: nextCursor !== null, + nextCursor, + }, + }; +} \ No newline at end of file diff --git a/backend/tests/pagination.test.ts b/backend/tests/pagination.test.ts new file mode 100644 index 00000000..b33120b5 --- /dev/null +++ b/backend/tests/pagination.test.ts @@ -0,0 +1,167 @@ +import { + validateCursor, + validateLimit, + encodeCursor, + PaginationError, + cursorPaginationSchema, +} from '../src/utils/pagination'; + +describe('Pagination Utilities', () => { + describe('validateCursor', () => { + it('should return null for undefined cursor', () => { + expect(validateCursor(undefined)).toBeNull(); + }); + + it('should return null for empty string cursor', () => { + expect(validateCursor('')).toBeNull(); + }); + + it('should return null for null cursor', () => { + expect(validateCursor(null as any)).toBeNull(); + }); + + it('should decode a valid base64 cursor with createdAt', () => { + const cursor = encodeCursor({ createdAt: '2024-01-01T00:00:00Z' }); + const result = validateCursor(cursor); + expect(result).toEqual({ createdAt: '2024-01-01T00:00:00Z' }); + }); + + it('should decode a valid base64 cursor with created_at', () => { + const encoded = Buffer.from(JSON.stringify({ created_at: '2024-01-01T00:00:00Z' })).toString('base64'); + const result = validateCursor(encoded); + expect(result).toEqual({ createdAt: '2024-01-01T00:00:00Z' }); + }); + + it('should throw INVALID_CURSOR for malformed base64', () => { + expect(() => validateCursor('not-valid-base64!!!')).toThrow(PaginationError); + try { + validateCursor('not-valid-base64!!!'); + } catch (error: any) { + expect(error.code).toBe('INVALID_CURSOR'); + } + }); + + it('should throw MALFORMED_CURSOR for non-object decoded value', () => { + const encoded = Buffer.from(JSON.stringify('just a string')).toString('base64'); + expect(() => validateCursor(encoded)).toThrow(PaginationError); + try { + validateCursor(encoded); + } catch (error: any) { + expect(error.code).toBe('MALFORMED_CURSOR'); + } + }); + + it('should throw MALFORMED_CURSOR for missing created_at field', () => { + const encoded = Buffer.from(JSON.stringify({ other: 'field' })).toString('base64'); + expect(() => validateCursor(encoded)).toThrow(PaginationError); + try { + validateCursor(encoded); + } catch (error: any) { + expect(error.code).toBe('MALFORMED_CURSOR'); + } + }); + + it('should throw MALFORMED_CURSOR for non-string created_at', () => { + const encoded = Buffer.from(JSON.stringify({ created_at: 123 })).toString('base64'); + expect(() => validateCursor(encoded)).toThrow(PaginationError); + try { + validateCursor(encoded); + } catch (error: any) { + expect(error.code).toBe('MALFORMED_CURSOR'); + } + }); + }); + + describe('validateLimit', () => { + it('should return default value when limit is undefined', () => { + expect(validateLimit(undefined)).toBe(20); + }); + + it('should return default value when limit is null', () => { + expect(validateLimit(null)).toBe(20); + }); + + it('should return default value when limit is empty string', () => { + expect(validateLimit('')).toBe(20); + }); + + it('should return parsed integer when limit is string', () => { + expect(validateLimit('50')).toBe(50); + }); + + it('should return parsed integer when limit is number', () => { + expect(validateLimit(50)).toBe(50); + }); + + it('should throw INVALID_LIMIT for non-numeric string', () => { + expect(() => validateLimit('abc')).toThrow(PaginationError); + }); + + it('should throw INVALID_LIMIT for limit below 1', () => { + expect(() => validateLimit(0)).toThrow(PaginationError); + expect(() => validateLimit(-5)).toThrow(PaginationError); + }); + + it('should throw INVALID_LIMIT for limit above max', () => { + expect(() => validateLimit(101)).toThrow(PaginationError); + }); + + it('should cap limit at max value', () => { + expect(validateLimit(100)).toBe(100); + }); + + it('should accept custom max value', () => { + expect(validateLimit(1000, 1000, 50)).toBe(1000); + }); + + it('should throw INVALID_LIMIT for limit above custom max', () => { + expect(() => validateLimit(1001, 1000, 50)).toThrow(PaginationError); + }); + + it('should accept custom default value', () => { + expect(validateLimit(undefined, 100, 50)).toBe(50); + }); + }); + + describe('encodeCursor', () => { + it('should encode cursor correctly', () => { + const cursor = encodeCursor({ createdAt: '2024-01-01T00:00:00Z' }); + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); + expect(decoded).toEqual({ createdAt: '2024-01-01T00:00:00Z' }); + }); + + it('should produce consistent output for same input', () => { + const cursor1 = encodeCursor({ createdAt: '2024-01-01T00:00:00Z' }); + const cursor2 = encodeCursor({ createdAt: '2024-01-01T00:00:00Z' }); + expect(cursor1).toBe(cursor2); + }); + }); + + describe('cursorPaginationSchema', () => { + it('should validate valid pagination input', () => { + const result = cursorPaginationSchema.parse({ limit: 20 }); + expect(result.limit).toBe(20); + expect(result.cursor).toBeUndefined(); + }); + + it('should apply default limit', () => { + const result = cursorPaginationSchema.parse({}); + expect(result.limit).toBe(20); + }); + + it('should accept cursor parameter', () => { + const cursor = encodeCursor({ createdAt: '2024-01-01T00:00:00Z' }); + const result = cursorPaginationSchema.parse({ limit: 10, cursor }); + expect(result.limit).toBe(10); + expect(result.cursor).toBe(cursor); + }); + + it('should reject limit below 1', () => { + expect(() => cursorPaginationSchema.parse({ limit: 0 })).toThrow(); + }); + + it('should reject limit above 100', () => { + expect(() => cursorPaginationSchema.parse({ limit: 101 })).toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/backend/tests/subscription-service.test.ts b/backend/tests/subscription-service.test.ts index a83c6850..80f7e770 100644 --- a/backend/tests/subscription-service.test.ts +++ b/backend/tests/subscription-service.test.ts @@ -646,8 +646,8 @@ describe('SubscriptionService', () => { describe('listSubscriptions()', () => { it('should list all subscriptions for a user', async () => { const mockSubscriptions = [ - { id: 'sub-1', name: 'Netflix', status: 'active' }, - { id: 'sub-2', name: 'Spotify', status: 'active' }, + { id: 'sub-1', name: 'Netflix', status: 'active', created_at: '2024-01-01T00:00:00Z' }, + { id: 'sub-2', name: 'Spotify', status: 'active', created_at: '2024-01-02T00:00:00Z' }, ]; const mockQuery = { @@ -655,9 +655,6 @@ describe('SubscriptionService', () => { eq: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - range: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - single: jest.fn().mockReturnThis(), then: jest.fn().mockImplementation((resolve: any) => resolve({ data: mockSubscriptions, error: null, @@ -675,7 +672,7 @@ describe('SubscriptionService', () => { it('should filter subscriptions by status', async () => { const mockSubscriptions = [ - { id: 'sub-1', name: 'Netflix', status: 'active' }, + { id: 'sub-1', name: 'Netflix', status: 'active', created_at: '2024-01-01T00:00:00Z' }, ]; const mockQuery: any = { @@ -705,7 +702,7 @@ describe('SubscriptionService', () => { it('should filter subscriptions by category', async () => { const mockSubscriptions = [ - { id: 'sub-1', name: 'Netflix', category: 'entertainment' }, + { id: 'sub-1', name: 'Netflix', category: 'entertainment', created_at: '2024-01-01T00:00:00Z' }, ]; const mockQuery: any = { @@ -732,9 +729,9 @@ describe('SubscriptionService', () => { expect(result.subscriptions).toEqual(mockSubscriptions); }); - it('should handle pagination with limit and offset', async () => { + it('should handle pagination with cursor-based approach', async () => { const mockSubscriptions = [ - { id: 'sub-5', name: 'Service 5' }, + { id: 'sub-5', name: 'Service 5', created_at: '2024-01-05T00:00:00Z' }, ]; const mockQuery = { @@ -742,9 +739,7 @@ describe('SubscriptionService', () => { eq: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - range: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - single: jest.fn().mockReturnThis(), + lt: jest.fn().mockReturnThis(), then: jest.fn().mockImplementation((resolve: any) => resolve({ data: mockSubscriptions, error: null, @@ -756,10 +751,10 @@ describe('SubscriptionService', () => { const result = await subscriptionService.listSubscriptions('user-123', { limit: 10, - offset: 40, + cursor: Buffer.from(JSON.stringify({ created_at: '2024-01-01T00:00:00Z' })).toString('base64'), }); - expect(mockQuery.range).toHaveBeenCalledWith(40, 49); + expect(mockQuery.lt).toHaveBeenCalledWith('created_at', '2024-01-01T00:00:00Z'); expect(result.subscriptions.length).toBe(1); }); @@ -769,9 +764,6 @@ describe('SubscriptionService', () => { eq: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - range: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - single: jest.fn().mockReturnThis(), then: jest.fn().mockImplementation((resolve: any) => resolve({ data: [], error: null, @@ -793,28 +785,39 @@ describe('SubscriptionService', () => { eq: jest.fn().mockReturnThis(), order: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - range: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - single: jest.fn().mockReturnThis(), then: jest.fn().mockImplementation((resolve: any) => resolve({ data: null, error: { message: 'Database error' }, })), }; - - const mockQuery2 = { - delete: jest.fn().mockReturnThis(), + + (supabase.from as jest.Mock).mockReturnValue(mockQuery); + + await expect( + subscriptionService.listSubscriptions('user-123') + ).rejects.toThrow(); + }); + + it('should throw error for invalid cursor', async () => { + const mockQuery = { + select: jest.fn().mockReturnThis(), eq: jest.fn().mockReturnThis(), + order: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), then: jest.fn().mockImplementation((resolve: any) => resolve({ + data: [], error: null, + count: 0, })), }; - + (supabase.from as jest.Mock).mockReturnValue(mockQuery); await expect( - subscriptionService.listSubscriptions('user-123') - ).rejects.toThrow(); + subscriptionService.listSubscriptions('user-123', { + cursor: 'invalid-cursor', + }) + ).rejects.toThrow('Invalid pagination cursor'); }); }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 1c2b70fd..968d7648 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "Node16", + "module": "commonjs", "lib": [ "ES2022" ], @@ -11,7 +11,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleResolution": "node16", + "moduleResolution": "node", "declaration": true, "declarationMap": true, "sourceMap": true,