From d49601c54e0c8f1aad8ac63079a930f36661e5bb Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 23 Mar 2026 11:30:48 -0400 Subject: [PATCH] feat: add offset pagination and X-Prev-Last-Date header to GET /sessions Made-with: Cursor --- backend/routes/sessions.js | 22 ++++++++ tests/api/session-archive.test.js | 91 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index bc70cd3..a00cd0a 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -23,6 +23,9 @@ router.get('/', (req, res) => { try { const filter = req.query.filter || 'default'; const limitParam = req.query.limit || 'all'; + const offsetParam = req.query.offset || '0'; + let offset = parseInt(offsetParam, 10); + if (isNaN(offset) || offset < 0) offset = 0; let whereClause = ''; if (filter === 'default') { @@ -45,6 +48,11 @@ router.get('/', (req, res) => { } } + let offsetClause = ''; + if (offset > 0) { + offsetClause = `OFFSET ${offset}`; + } + const sessions = db.prepare(` SELECT s.id, @@ -60,6 +68,7 @@ router.get('/', (req, res) => { GROUP BY s.id ORDER BY s.created_at DESC ${limitClause} + ${offsetClause} `).all(); const result = sessions.map(({ notes, ...session }) => { @@ -69,6 +78,19 @@ router.get('/', (req, res) => { const absoluteTotal = db.prepare('SELECT COUNT(*) as total FROM sessions').get(); + if (offset > 0) { + const prevRow = db.prepare(` + SELECT s.created_at + FROM sessions s + ${whereClause} + ORDER BY s.created_at DESC + LIMIT 1 OFFSET ${offset - 1} + `).get(); + if (prevRow) { + res.set('X-Prev-Last-Date', prevRow.created_at); + } + } + res.set('X-Total-Count', String(countRow.total)); res.set('X-Absolute-Total', String(absoluteTotal.total)); res.json(result); diff --git a/tests/api/session-archive.test.js b/tests/api/session-archive.test.js index e76a235..0a7b742 100644 --- a/tests/api/session-archive.test.js +++ b/tests/api/session-archive.test.js @@ -92,6 +92,97 @@ describe('GET /api/sessions — filter and limit', () => { expect(res.status).toBe(200); expect(res.body).toHaveLength(8); }); + + test('offset skips the first N sessions', async () => { + for (let i = 0; i < 5; i++) { + seedSession({ is_active: 0, notes: null }); + } + + const allRes = await request(app).get('/api/sessions?filter=all&limit=all'); + const offsetRes = await request(app).get('/api/sessions?filter=all&limit=2&offset=2'); + expect(offsetRes.status).toBe(200); + expect(offsetRes.body).toHaveLength(2); + expect(offsetRes.body[0].id).toBe(allRes.body[2].id); + expect(offsetRes.body[1].id).toBe(allRes.body[3].id); + }); + + test('offset defaults to 0 when not provided', async () => { + for (let i = 0; i < 3; i++) { + seedSession({ is_active: 0, notes: null }); + } + + const res = await request(app).get('/api/sessions?filter=all&limit=2'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); + + test('negative offset is clamped to 0', async () => { + seedSession({ is_active: 0, notes: null }); + + const res = await request(app).get('/api/sessions?filter=all&offset=-5'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + }); + + test('non-numeric offset is clamped to 0', async () => { + seedSession({ is_active: 0, notes: null }); + + const res = await request(app).get('/api/sessions?filter=all&offset=abc'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + }); + + test('offset past end returns empty array', async () => { + seedSession({ is_active: 0, notes: null }); + + const res = await request(app).get('/api/sessions?filter=all&limit=5&offset=100'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(0); + expect(res.headers['x-total-count']).toBe('1'); + }); + + test('X-Prev-Last-Date header is set with correct value when offset > 0', async () => { + for (let i = 0; i < 5; i++) { + seedSession({ is_active: 0, notes: null }); + } + + const allRes = await request(app).get('/api/sessions?filter=all&limit=all'); + const res = await request(app).get('/api/sessions?filter=all&limit=2&offset=2'); + expect(res.headers['x-prev-last-date']).toBe(allRes.body[1].created_at); + }); + + test('X-Prev-Last-Date header is absent when offset is 0', async () => { + seedSession({ is_active: 0, notes: null }); + + const res = await request(app).get('/api/sessions?filter=all&limit=2'); + expect(res.headers['x-prev-last-date']).toBeUndefined(); + }); + + test('X-Total-Count is unaffected by offset', async () => { + for (let i = 0; i < 10; i++) { + seedSession({ is_active: 0, notes: null }); + } + + const res = await request(app).get('/api/sessions?filter=all&limit=3&offset=6'); + expect(res.headers['x-total-count']).toBe('10'); + expect(res.body).toHaveLength(3); + }); + + test('offset works with filter=default', async () => { + for (let i = 0; i < 5; i++) { + seedSession({ is_active: 0, notes: null }); + } + const archived = seedSession({ is_active: 0, notes: null }); + require('../helpers/test-utils').db.prepare( + 'UPDATE sessions SET archived = 1 WHERE id = ?' + ).run(archived.id); + + const res = await request(app).get('/api/sessions?filter=default&limit=2&offset=2'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.headers['x-total-count']).toBe('5'); + res.body.forEach(s => expect(s.archived).toBe(0)); + }); }); describe('POST /api/sessions/:id/archive', () => {