pagination.test.js (7259B)
1 // test/pagination.test.js - windows over the long tables 2 // 3 // Driven through a real server, because the parts that go wrong are the 4 // ones that join up: a count taken under different conditions than the 5 // rows, or a next url that drops the filter it was asked for. 6 7 import test from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import { startHarness } from './helpers/harness.js'; 10 import { paginateOffset } from '../src/lib/page.js'; 11 12 async function withUi(options, fn) { 13 const h = await startHarness({ bootstrap: true, ...options }); 14 try { 15 return await fn(h); 16 } finally { 17 await h.stop(); 18 } 19 } 20 21 const get = (h, url, headers = {}) => h.app.inject({ method: 'GET', url, headers }); 22 23 // Jobs are seeded straight into the database. Going through the scheduler 24 // would mean a git sync and a pipeline compile per row, which says nothing 25 // about paging and takes far longer. 26 async function seedJobs(h, count, { visibility = 'public' } = {}) { 27 const now = Date.now(); 28 // Job numbers are unique per project, so a second call in the same test 29 // has to carry on where the first stopped. 30 const base = h.seeded ?? 0; 31 for (let i = 0; i < count; i += 1) { 32 const n = base + i; 33 await h.services.db.run( 34 `INSERT INTO jobs (id, project_id, number, ref, head_sha, trigger_type, actor, 35 title, state, pipeline, visibility, created_at, started_at, finished_at) 36 VALUES ({id}, {project}, {number}, 'refs/heads/main', {sha}, 'push', null, 37 null, 'success', null, {visibility}, {created}, null, null)`, 38 { 39 id: `seed${String(n).padStart(4, '0')}`, 40 project: h.project.id, 41 number: 1000 + n, 42 sha: String(n).padStart(40, '0'), 43 visibility, 44 // Descending created_at, so row order is predictable. 45 created: now - n * 1000, 46 } 47 ); 48 } 49 h.seeded = base + count; 50 } 51 52 // Tasks attributed to the harness worker, for the worker page's history. 53 async function seedWorkerTasks(h, count) { 54 await seedJobs(h, 1); 55 const job = `seed${String((h.seeded ?? 1) - 1).padStart(4, '0')}`; 56 const now = Date.now(); 57 for (let i = 0; i < count; i += 1) { 58 await h.services.db.run( 59 `INSERT INTO tasks (id, job_id, name, base_name, image, requires, spec, state, 60 timeout, worker_token_id, worker_name, claimed_at, created_at) 61 VALUES ({id}, {job}, {name}, 'build', 'alpine', '[]', '{}', 'success', 62 3600, {worker}, 'builder-01', {claimed}, {claimed})`, 63 { 64 id: `wtask${String(i).padStart(4, '0')}`, 65 job, 66 name: `build-${i}`, 67 worker: h.worker.id, 68 claimed: now - i * 1000, 69 } 70 ); 71 } 72 } 73 74 // --- against a real database --- 75 76 test('a window counts under the same conditions as it selects', async () => { 77 await withUi({}, async (h) => { 78 await seedJobs(h, 10, { visibility: 'public' }); 79 await seedJobs(h, 4, { visibility: 'private' }); 80 81 const win = await paginateOffset(h.services.db, { 82 select: 'id', 83 from: 'jobs', 84 where: "visibility = 'public'", 85 order: 'created_at DESC', 86 offset: 0, 87 limit: 5, 88 }); 89 90 assert.equal(win.rows.length, 5); 91 // The private rows must not be counted, or the reader is offered a 92 // page that turns out to be empty. 93 assert.equal(win.total, 10); 94 }); 95 }); 96 97 test('successive windows do not repeat or skip a row', async () => { 98 await withUi({}, async (h) => { 99 await seedJobs(h, 12); 100 101 const seen = []; 102 for (let offset = 0; offset < 15; offset += 5) { 103 const win = await paginateOffset(h.services.db, { 104 select: 'id', 105 from: 'jobs', 106 order: 'created_at DESC', 107 offset, 108 limit: 5, 109 }); 110 seen.push(...win.rows.map((r) => r.id)); 111 } 112 113 assert.equal(seen.length, 12); 114 assert.equal(new Set(seen).size, 12, 'a row appeared in more than one window'); 115 }); 116 }); 117 118 // --- the endpoints --- 119 120 test('a list is capped at the default limit and hands out a next url', async () => { 121 await withUi({}, async (h) => { 122 await seedJobs(h, 55); 123 const { headers } = await h.login(); 124 125 const first = await get(h, `/api/v1/projects/${h.project.id}/jobs`, headers); 126 assert.equal(first.statusCode, 200); 127 128 const body = first.json(); 129 assert.equal(body.data.length, 50); 130 assert.equal(body.pagination.offset, 0); 131 assert.equal(body.pagination.total, 55); 132 assert.ok(body.pagination.next); 133 134 const second = await get(h, new URL(body.pagination.next).pathname + new URL(body.pagination.next).search, headers); 135 const rest = second.json(); 136 assert.equal(rest.data.length, 5); 137 assert.equal(rest.pagination.offset, 50); 138 assert.equal(rest.pagination.next, undefined); 139 }); 140 }); 141 142 test('following next walks every row exactly once', async () => { 143 await withUi({}, async (h) => { 144 await seedJobs(h, 23); 145 const { headers } = await h.login(); 146 147 const seen = []; 148 let url = `/api/v1/projects/${h.project.id}/jobs?limit=5`; 149 while (url) { 150 const body = (await get(h, url, headers)).json(); 151 seen.push(...body.data.map((row) => row.id)); 152 url = body.pagination.next ? new URL(body.pagination.next).pathname + new URL(body.pagination.next).search : null; 153 } 154 155 assert.equal(seen.length, 23); 156 assert.equal(new Set(seen).size, 23, 'a row appeared in more than one window'); 157 }); 158 }); 159 160 test('reading past the end is an empty page rather than an error', async () => { 161 await withUi({}, async (h) => { 162 await seedJobs(h, 3); 163 const { headers } = await h.login(); 164 165 const res = await get(h, `/api/v1/projects/${h.project.id}/jobs?offset=999`, headers); 166 assert.equal(res.statusCode, 200); 167 assert.deepEqual(res.json().data, []); 168 assert.equal(res.json().pagination.total, 3); 169 }); 170 }); 171 172 test('the total behind a page respects who is asking', async () => { 173 await withUi({}, async (h) => { 174 await seedJobs(h, 4, { visibility: 'public' }); 175 await seedJobs(h, 40, { visibility: 'private' }); 176 177 const anon = await get(h, `/api/v1/projects/${h.project.id}/jobs`); 178 assert.equal(anon.json().pagination.total, 4); 179 180 const { headers } = await h.login(); 181 const admin = await get(h, `/api/v1/projects/${h.project.id}/jobs`, headers); 182 assert.equal(admin.json().pagination.total, 44); 183 }); 184 }); 185 186 test('a worker pages its task history', async () => { 187 await withUi({}, async (h) => { 188 const { headers } = await h.login(); 189 await seedWorkerTasks(h, 28); 190 191 const first = await get(h, `/api/v1/workers/${h.worker.id}/tasks?limit=25`, headers); 192 assert.equal(first.statusCode, 200); 193 assert.equal(first.json().data.length, 25); 194 assert.equal(first.json().pagination.total, 28); 195 196 const second = await get(h, `/api/v1/workers/${h.worker.id}/tasks?limit=25&offset=25`, headers); 197 assert.equal(second.json().data.length, 3); 198 }); 199 }); 200 201 test('a limit above the cap is clamped rather than refused', async () => { 202 await withUi({}, async (h) => { 203 await seedJobs(h, 3); 204 const { headers } = await h.login(); 205 206 const res = await get(h, `/api/v1/projects/${h.project.id}/jobs?limit=9999`, headers); 207 assert.equal(res.statusCode, 200); 208 assert.equal(res.json().data.length, 3); 209 }); 210 });