|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | +import { describe, expect, it } from 'vitest' |
| 5 | +import { |
| 6 | + decodeOffsetCursor, |
| 7 | + encodeOffsetCursor, |
| 8 | + offsetCursorScope, |
| 9 | +} from '@/app/api/v2/lib/response' |
| 10 | + |
| 11 | +/** |
| 12 | + * An offset names a position in one exact sequence. Replay it against a |
| 13 | + * differently filtered or sorted sequence and it names a different row — |
| 14 | + * silently skipping rows, repeating them, or landing past the end and returning |
| 15 | + * an empty page while `nextCursor` implied more. |
| 16 | + * |
| 17 | + * The keyset cursor has always been protected from this by its sort stamp |
| 18 | + * (`decodeSortedCursor`). These assertions hold the offset cursor — used by |
| 19 | + * `GET /skills` and `GET /knowledge/{id}/documents` — to the same rule. |
| 20 | + */ |
| 21 | +describe('offset cursor scope', () => { |
| 22 | + const base = { workspaceId: 'ws-1', search: undefined, sortBy: 'name', sortOrder: 'asc' } |
| 23 | + |
| 24 | + it('resumes a cursor replayed under the same query state', () => { |
| 25 | + const scope = offsetCursorScope(base) |
| 26 | + expect(decodeOffsetCursor(encodeOffsetCursor(scope, 40), scope)).toBe(40) |
| 27 | + }) |
| 28 | + |
| 29 | + it('rejects a cursor replayed under a different sort', () => { |
| 30 | + const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) |
| 31 | + |
| 32 | + expect(() => |
| 33 | + decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortBy: 'createdAt' })) |
| 34 | + ).toThrow(/does not match the requested/) |
| 35 | + expect(() => |
| 36 | + decodeOffsetCursor(cursor, offsetCursorScope({ ...base, sortOrder: 'desc' })) |
| 37 | + ).toThrow(/does not match the requested/) |
| 38 | + }) |
| 39 | + |
| 40 | + it('rejects a cursor replayed under a different filter', () => { |
| 41 | + const cursor = encodeOffsetCursor(offsetCursorScope(base), 40) |
| 42 | + |
| 43 | + expect(() => |
| 44 | + decodeOffsetCursor(cursor, offsetCursorScope({ ...base, search: 'deploy' })) |
| 45 | + ).toThrow(/does not match the requested/) |
| 46 | + expect(() => |
| 47 | + decodeOffsetCursor(cursor, offsetCursorScope({ ...base, workspaceId: 'ws-2' })) |
| 48 | + ).toThrow(/does not match the requested/) |
| 49 | + }) |
| 50 | + |
| 51 | + it('treats an absent cursor as page one', () => { |
| 52 | + expect(decodeOffsetCursor(undefined, offsetCursorScope(base))).toBe(0) |
| 53 | + }) |
| 54 | + |
| 55 | + it('rejects a cursor that is not valid base64-JSON', () => { |
| 56 | + expect(() => decodeOffsetCursor('not-a-cursor', offsetCursorScope(base))).toThrow() |
| 57 | + }) |
| 58 | + |
| 59 | + it('rejects an offset that is not a non-negative integer', () => { |
| 60 | + const scope = offsetCursorScope(base) |
| 61 | + expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, -1), scope)).toThrow('Invalid cursor') |
| 62 | + expect(() => decodeOffsetCursor(encodeOffsetCursor(scope, 1.5), scope)).toThrow( |
| 63 | + 'Invalid cursor' |
| 64 | + ) |
| 65 | + }) |
| 66 | + |
| 67 | + /** |
| 68 | + * `limit` selects how much of the sequence to return, not what the sequence |
| 69 | + * is, so paging with a different page size must keep working. |
| 70 | + */ |
| 71 | + it('is unaffected by the page size', () => { |
| 72 | + expect(offsetCursorScope({ ...base, sortBy: 'name' })).toBe(offsetCursorScope(base)) |
| 73 | + }) |
| 74 | + |
| 75 | + it('does not depend on the order the parts are written', () => { |
| 76 | + expect(offsetCursorScope({ sortBy: 'name', workspaceId: 'ws-1', sortOrder: 'asc' })).toBe( |
| 77 | + offsetCursorScope({ sortOrder: 'asc', workspaceId: 'ws-1', sortBy: 'name' }) |
| 78 | + ) |
| 79 | + }) |
| 80 | +}) |
0 commit comments