import type { Document } from 'mongodb'; import { describe, expect, it } from 'vitest'; import type { SortOrder } from '@openvidu-dashboard/typings'; import { buildPage, decodeCursor, encodeCursor, pageQuery } from '../../src/api/cursor.js'; interface Room { _id: string; createdAt: Date; } type Operator = '$gt' | '$gte' | '$lt' | '$lte'; function compareValues(a: Date | string, b: Date | string): number { return a instanceof Date && b instanceof Date ? a.getTime() - b.getTime() : String(a).localeCompare(String(b)); } function satisfies(value: Date | string, condition: Document): boolean { const checks: Record boolean> = { $gt: (diff) => diff > 0, $gte: (diff) => diff >= 0, $lt: (diff) => diff < 0, $lte: (diff) => diff <= 0 }; return Object.entries(condition).every(([op, bound]) => checks[op as Operator](compareValues(value, bound as Date | string)) ); } /** Evaluates the subset of the query language that `pageQuery` produces. */ function matches(room: Room, filter: Document): boolean { return Object.entries(filter).every(([key, condition]) => { if (key === '$nor') { return !(condition as Document[]).some((clause) => matches(room, clause)); } const value = room[key as keyof Room]; return condition instanceof Date || typeof condition === 'string' ? compareValues(value, condition) === 0 : satisfies(value, condition as Document); }); } /** Applies a keyset page query to an array, the way MongoDB would with the same filter and sort. */ function run(rooms: Room[], order: SortOrder, limit: number, cursor?: string) { const decoded = cursor === undefined ? undefined : decodeCursor(cursor, 'createdAt', order); if (typeof decoded === 'string') { throw new Error(decoded); } const query = pageQuery('createdAt', order, decoded); const direction = (query.sort as Record).createdAt ?? 1; const compare = (a: Room, b: Room) => (a.createdAt.getTime() - b.createdAt.getTime() || a._id.localeCompare(b._id)) * direction; const fetched = rooms .filter((room) => matches(room, query.filter)) .sort(compare) .slice(0, limit + 1); return buildPage( fetched, limit, query, decoded !== undefined, (room) => ({ value: room.createdAt, id: room._id }), { sort: 'createdAt', order } ); } function rooms(count: number, offset = 0): Room[] { return Array.from({ length: count }, (_, i) => ({ _id: `RM_${String(i + offset).padStart(3, '0')}`, createdAt: new Date(Date.UTC(2026, 9, 13, 10, 0, i + offset)) })); } describe('cursor pagination', () => { it('walks forward and back without gaps or duplicates', () => { const all = rooms(60); const newestFirst = [...all].reverse().map((room) => room._id); const first = run(all, 'desc', 25); expect(first.items.map((room) => room._id)).toEqual(newestFirst.slice(0, 25)); expect(first.prevCursor).toBeNull(); const second = run(all, 'desc', 25, first.nextCursor ?? undefined); expect(second.items.map((room) => room._id)).toEqual(newestFirst.slice(25, 50)); const back = run(all, 'desc', 25, second.prevCursor ?? undefined); expect(back.items.map((room) => room._id)).toEqual(newestFirst.slice(0, 25)); expect(back.prevCursor).toBeNull(); const third = run(all, 'desc', 25, second.nextCursor ?? undefined); expect(third.items.map((room) => room._id)).toEqual(newestFirst.slice(50)); expect(third.nextCursor).toBeNull(); }); it('keeps the next page anchored when items are inserted', () => { const all = rooms(60); const first = run(all, 'asc', 25); const withNew = [...all, ...rooms(5, 100)]; const second = run(withNew, 'asc', 25, first.nextCursor ?? undefined); expect(second.items[0]?._id).toBe('RM_025'); }); it('breaks ties on the identifier', () => { const tie = new Date('2026-10-13T10:00:00Z'); const same = ['RM_a', 'RM_b', 'RM_c', 'RM_d'].map((_id) => ({ _id, createdAt: tie })); const first = run(same, 'asc', 2); const second = run(same, 'asc', 2, first.nextCursor ?? undefined); expect([...first.items, ...second.items].map((room) => room._id)).toEqual(['RM_a', 'RM_b', 'RM_c', 'RM_d']); }); it('narrows the range to the cursor and filters only the ties on the identifier', () => { const value = new Date('2026-10-13T10:00:00Z'); const from = new Date('2026-10-12T00:00:00Z'); const to = new Date('2026-10-14T00:00:00Z'); const cursor = { value, id: 'RM_5', sort: 'createdAt', order: 'desc' as const }; expect(pageQuery('createdAt', 'desc', { ...cursor, direction: 'next' }, { from, to }).filter).toEqual({ createdAt: { $gte: from, $lt: to, $lte: value }, $nor: [{ createdAt: value, _id: { $gte: 'RM_5' } }] }); expect(pageQuery('createdAt', 'desc', { ...cursor, direction: 'prev' }, { from, to })).toMatchObject({ filter: { createdAt: { $gte: value, $lt: to }, $nor: [{ createdAt: value, _id: { $lte: 'RM_5' } }] }, sort: { createdAt: 1, _id: 1 }, reversed: true }); expect(pageQuery('createdAt', 'asc', undefined, { from }).filter).toEqual({ createdAt: { $gte: from } }); expect(pageQuery('startedAt', 'desc').filter).toEqual({}); }); it('rejects a malformed cursor or one issued for another sort', () => { const cursor = encodeCursor({ value: new Date('2026-10-13T10:00:00Z'), id: 'RM_1', sort: 'createdAt', order: 'desc', direction: 'next' }); const decoded = decodeCursor(cursor, 'createdAt', 'desc'); expect(typeof decoded === 'object' && decoded.value).toEqual(new Date('2026-10-13T10:00:00Z')); expect(decodeCursor(cursor, 'name', 'desc')).toBe('was issued for another sort or order'); expect(decodeCursor(cursor, 'createdAt', 'asc')).toBe('was issued for another sort or order'); expect(decodeCursor('not-a-cursor', 'createdAt', 'desc')).toBe('is not a valid cursor'); expect(decodeCursor(Buffer.from('[1,2]').toString('base64url'), 'createdAt', 'desc')).toBe( 'is not a valid cursor' ); }); });