conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 });