How to check an export respects the filters applied

Run one filter against both paths and compare identifiers, not counts. On the target below, node compare.mjs "status=open" "state=open" returned 8 rows on each side, 4 identifiers present on one side only, and one row whose title differs. Equal counts passed a broken export.

Why check this

The grid and the download button are two queries over one dataset. They are written months apart and take their parameters differently, which is where the defect grows.

Run this on any screen that offers a download, and after a change to either query.

The failure it prevents is a report built on the wrong rows: an agent filters to one customer, exports, and sends a file holding every customer.

Prerequisites

server.mjs is the target: one dataset, two paths, two parameter conventions.

import { createServer } from 'node:http';

const LF = String.fromCharCode(10);

// One dataset. The grid and the export are two queries over it.
const ROWS = [
  { id: 'T-001', title: 'Login fails on Safari',   status: 'open',     owner: 'ada',   created: '2026-03-01' },
  { id: 'T-002', title: 'login retry loop',        status: 'open',     owner: 'grace', created: '2026-03-02' },
  { id: 'T-003', title: 'Export timeout',          status: 'closed',   owner: 'ada',   created: '2026-03-03' },
  { id: 'T-004', title: 'Password reset email',    status: 'open',     owner: 'linus', created: '2026-03-04' },
  { id: 'T-005', title: 'LOGIN button misaligned', status: 'open',     owner: 'ada',   created: '2026-03-05' },
  { id: 'T-006', title: 'Import drops rows',       status: 'archived', owner: 'grace', created: '2026-03-06' },
  { id: 'T-007', title: 'Session expires early',   status: 'open',     owner: 'linus', created: '2026-03-07' },
  { id: 'T-008', title: 'Login audit missing',     status: 'open',     owner: 'grace', created: '2026-03-08' },
  { id: 'T-009', title: 'Slow dashboard',          status: 'closed',   owner: 'ada',   created: '2026-03-09' },
  { id: 'T-010', title: 'Logout does nothing',     status: 'open',     owner: 'linus', created: '2026-03-10' },
  { id: 'T-011', title: 'Filter resets on reload', status: 'open',     owner: 'ada',   created: '2026-03-11' },
  { id: 'T-012', title: 'Login rate limit',        status: 'open',     owner: 'grace', created: '2026-03-12' },
  { id: 'T-013', title: 'Search paging',           status: 'archived', owner: 'linus', created: '2026-03-13' },
  { id: 'T-014', title: 'Login theme flicker',     status: 'open',     owner: 'ada',   created: '2026-03-14' },
];

// The export path reads a copy refreshed nightly, so today's edit to T-011 is
// not in it. Same row, same id, one older field.
const VIEW = ROWS.map((r) => ({ ...r }));
VIEW.find((r) => r.id === 'T-011').title = 'Filter resets';

const CAP = 8;       // the export's own row cap
const PER_PAGE = 8;  // the grid's page size

const has = (v) => v !== null && v !== '';

// The grid: status defaults to open, owner is a filter, q is case insensitive,
// to is inclusive, newest first, paged.
function screen(p) {
  const status = has(p.get('status')) ? p.get('status') : 'open';
  const owner = p.get('owner');
  const q = (p.get('q') || '').toLowerCase();
  const from = p.get('from');
  const to = p.get('to');
  const rows = ROWS.filter((r) => (status === 'all' || r.status === status)
    && (!has(owner) || r.owner === owner)
    && (q === '' || r.title.toLowerCase().includes(q))
    && (!has(from) || r.created >= from)
    && (!has(to) || r.created <= to))
    .sort((a, b) => (a.created < b.created ? 1 : -1));
  const page = Number(p.get('page') || 1);
  const perPage = Number(p.get('perPage') || PER_PAGE);
  return { total: rows.length, page, perPage, rows: rows.slice((page - 1) * perPage, page * perPage) };
}

// The export: reads state, not status, and defaults it to all. No owner filter
// at all. q is case sensitive. to is exclusive. No paging, one cap, id order.
function exported(p) {
  const state = has(p.get('state')) ? p.get('state') : 'all';
  const q = p.get('q') || '';
  const from = p.get('from');
  const to = p.get('to');
  return VIEW.filter((r) => (state === 'all' || r.status === state)
    && (q === '' || r.title.includes(q))
    && (!has(from) || r.created >= from)
    && (!has(to) || r.created < to))
    .sort((a, b) => (a.id < b.id ? -1 : 1))
    .slice(0, CAP);
}

const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:8934');
  const p = url.searchParams;
  if (url.pathname === '/tickets') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(screen(p)));
    return;
  }
  if (url.pathname === '/export.csv') {
    const rows = exported(p);
    // The file describes itself from the request, never from what ran above.
    const name = 'tickets-' + (p.get('status') || 'all') + '-' + (p.get('owner') || 'all') + '.csv';
    const note = '# filters: status=' + (p.get('status') || '') + '; owner=' + (p.get('owner') || '')
      + '; q=' + (p.get('q') || '') + '; from=' + (p.get('from') || '') + '; to=' + (p.get('to') || '');
    const body = [note, 'id,title,status,owner,created',
      ...rows.map((r) => [r.id, r.title, r.status, r.owner, r.created].join(','))].join(LF);
    res.writeHead(200, { 'content-type': 'text/csv', 'content-disposition': 'attachment; filename="' + name + '"' });
    res.end(body + LF);
    return;
  }
  res.writeHead(404).end();
});

server.listen(8934, '127.0.0.1', () => console.log('target on http://127.0.0.1:8934 pid ' + process.pid));

screen.mjs prints the grid, one line per row.

// Print the grid response as one line per row.
const r = await (await fetch('http://127.0.0.1:8934/tickets?' + (process.argv[2] || ''))).json();
console.log('total ' + r.total + '  page ' + r.page + '  perPage ' + r.perPage);
for (const t of r.rows) console.log([t.id, t.status.padEnd(8), t.owner.padEnd(5), t.created, t.title].join('  '));

compare.mjs is the check: it takes both queries.

// Fetch the grid and the export for one pair of queries and report three
// levels: counts, identifiers, fields.
const BASE = 'http://127.0.0.1:8934';
const FIELDS = ['title', 'status', 'owner', 'created'];
const LF = String.fromCharCode(10);

const screenQuery = process.argv[2] || '';
const exportQuery = process.argv[3] || '';

const screen = await (await fetch(BASE + '/tickets?' + screenQuery)).json();
const csv = await (await fetch(BASE + '/export.csv?' + exportQuery)).text();

// The metadata line is a comment, not a record.
const lines = csv.split(LF).filter((l) => l !== '' && l[0] !== '#');
const head = lines[0].split(',');
const exportRows = lines.slice(1).map((l) => Object.fromEntries(l.split(',').map((v, i) => [head[i], v])));

const byId = (rows) => new Map(rows.map((r) => [r.id, r]));
const s = byId(screen.rows);
const e = byId(exportRows);
const onlyScreen = [...s.keys()].filter((k) => !e.has(k)).sort();
const onlyExport = [...e.keys()].filter((k) => !s.has(k)).sort();
const both = [...s.keys()].filter((k) => e.has(k)).sort();

const diffs = [];
for (const id of both) {
  for (const f of FIELDS) {
    if (String(s.get(id)[f]) !== String(e.get(id)[f])) {
      diffs.push(id + ' ' + f + ': screen "' + s.get(id)[f] + '" export "' + e.get(id)[f] + '"');
      break;  // first differing field only
    }
  }
}

const line = (k, v) => console.log(k.padEnd(22) + v);
line('screen query', screenQuery || '(none)');
line('export query', exportQuery || '(none)');
line('screen matched', screen.total);
line('screen rows', screen.rows.length);
line('export rows', exportRows.length);
line('counts', screen.rows.length === exportRows.length ? 'equal' : 'different');
line('only on the screen', onlyScreen.length ? onlyScreen.join(' ') : 'none');
line('only in the export', onlyExport.length ? onlyExport.join(' ') : 'none');
line('on both sides', both.length);
line('first differing field', diffs.length ? diffs[0] : 'none');
line('verdict', onlyScreen.length + onlyExport.length + diffs.length === 0 ? 'PASS' : 'FAIL');

Steps

  1. Step 1.

    Start the target and keep the pid it prints.

    node server.mjs
    
    target on http://127.0.0.1:8934 pid 10136

    The pid is yours for the rest of the run.

  2. Step 2.

    Read the grid the way the screen reads it.

    node screen.mjs "status=open"
    
    total 10  page 1  perPage 8
    T-014  open      ada    2026-03-14  Login theme flicker
    T-012  open      grace  2026-03-12  Login rate limit
    T-011  open      ada    2026-03-11  Filter resets on reload
    T-010  open      linus  2026-03-10  Logout does nothing
    T-008  open      grace  2026-03-08  Login audit missing
    T-007  open      linus  2026-03-07  Session expires early
    T-005  open      ada    2026-03-05  LOGIN button misaligned
    T-004  open      linus  2026-03-04  Password reset email

    Ten open tickets match and the page holds eight. That is what the tester sees.

  3. Step 3.

    Ask the export for the same filter, under the name that path expects.

    curl -s "http://127.0.0.1:8934/export.csv?state=open"
    
    # filters: status=; owner=; q=; from=; to=
    id,title,status,owner,created
    T-001,Login fails on Safari,open,ada,2026-03-01
    T-002,login retry loop,open,grace,2026-03-02
    T-004,Password reset email,open,linus,2026-03-04
    T-005,LOGIN button misaligned,open,ada,2026-03-05
    T-007,Session expires early,open,linus,2026-03-07
    T-008,Login audit missing,open,grace,2026-03-08
    T-010,Logout does nothing,open,linus,2026-03-10
    T-011,Filter resets,open,ada,2026-03-11

    Eight data rows again. A test that asserts on the count stops here and passes.

  4. Step 4.

    Compare the two responses by identifier.

    node compare.mjs "status=open" "state=open"
    
    screen query          status=open
    export query          state=open
    screen matched        10
    screen rows           8
    export rows           8
    counts                equal
    only on the screen    T-012 T-014
    only in the export    T-001 T-002
    on both sides         6
    first differing field T-011 title: screen "Filter resets on reload" export "Filter resets"
    verdict               FAIL

    Counts agree and four identifiers sit on one side alone. The screen sorts by date and pages at 8, the export sorts by id and caps at 8, so the cap keeps a different eight. T-011 is on both sides with a different title, which neither the count nor the identifier set reaches.

  5. Step 5.

    Send an owner filter to both paths.

    node compare.mjs "status=all&owner=ada" "state=all&owner=ada"
    
    screen query          status=all&owner=ada
    export query          state=all&owner=ada
    screen matched        6
    screen rows           6
    export rows           8
    counts                different
    only on the screen    T-009 T-011 T-014
    only in the export    T-002 T-004 T-006 T-007 T-008
    on both sides         3
    first differing field none
    verdict               FAIL

    The export has no owner parameter, so it returns the whole table down to its cap. Five identifiers in the file were never on the screen.

  6. Step 6.

    Ask both paths for nothing at all.

    node compare.mjs "" ""
    
    screen query          (none)
    export query          (none)
    screen matched        10
    screen rows           8
    export rows           8
    counts                equal
    only on the screen    T-010 T-011 T-012 T-014
    only in the export    T-001 T-002 T-003 T-006
    on both sides         4
    first differing field none
    verdict               FAIL

    The screen defaults to open, the export to every state. Counts match at eight and the file carries closed T-003 and archived T-006.

  7. Step 7.

    Ask for the second page.

    node compare.mjs "status=all&page=2" "state=all&page=2"
    
    screen query          status=all&page=2
    export query          state=all&page=2
    screen matched        14
    screen rows           6
    export rows           8
    counts                different
    only on the screen    none
    only in the export    T-007 T-008
    on both sides         6
    first differing field none
    verdict               FAIL

    The export reads neither page nor perPage. The screen returned the six oldest rows and the file its own first eight, so T-007 and T-008 came back from page one.

  8. Step 8.

    Set a date range and read its last day.

    node compare.mjs "status=all&from=2026-03-01&to=2026-03-05" "state=all&from=2026-03-01&to=2026-03-05"
    
    screen query          status=all&from=2026-03-01&to=2026-03-05
    export query          state=all&from=2026-03-01&to=2026-03-05
    screen matched        5
    screen rows           5
    export rows           4
    counts                different
    only on the screen    T-005
    only in the export    none
    on both sides         4
    first differing field none
    verdict               FAIL

    The screen treats to as inclusive, the export as exclusive. T-005, created on the last day, is on the screen and not in the file.

  9. Step 9.

    Search with a lower case term.

    node compare.mjs "status=all&q=login" "state=all&q=login"
    
    screen query          status=all&q=login
    export query          state=all&q=login
    screen matched        6
    screen rows           6
    export rows           1
    counts                different
    only on the screen    T-001 T-005 T-008 T-012 T-014
    only in the export    none
    on both sides         1
    first differing field none
    verdict               FAIL

    The screen folds case, the export compares bytes. Five titles spelled Login or LOGIN are absent from the file, which keeps the one spelled login.

  10. Step 10.

    Read what the file says about itself.

    curl -sD - "http://127.0.0.1:8934/export.csv?status=open&owner=ada" | tr -d '\r'
    
    HTTP/1.1 200 OK
    content-type: text/csv
    content-disposition: attachment; filename="tickets-open-ada.csv"
    Date: Sat, 12 Sep 2026 20:27:08 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Transfer-Encoding: chunked
    
    # filters: status=open; owner=ada; q=; from=; to=
    id,title,status,owner,created
    T-001,Login fails on Safari,open,ada,2026-03-01
    T-002,login retry loop,open,grace,2026-03-02
    T-003,Export timeout,closed,ada,2026-03-03
    T-004,Password reset email,open,linus,2026-03-04
    T-005,LOGIN button misaligned,open,ada,2026-03-05
    T-006,Import drops rows,archived,grace,2026-03-06
    T-007,Session expires early,open,linus,2026-03-07
    T-008,Login audit missing,open,grace,2026-03-08

    The filename and the metadata line claim status=open and owner=ada. The rows hold a closed ticket, an archived ticket and three owners. An export that names a filter it did not apply is worse than one that drops it in silence: it gives the reader a reason to stop checking.

  11. Step 11.

    Run a pair the two paths agree on.

    node compare.mjs "status=closed" "state=closed"
    
    screen query          status=closed
    export query          state=closed
    screen matched        2
    screen rows           2
    export rows           2
    counts                equal
    only on the screen    none
    only in the export    none
    on both sides         2
    first differing field none
    verdict               PASS

    Two rows a side, no identifier alone, no field apart. That is the shape of a passing run.

  12. Step 12.

    Stop the target by the pid from step 1 and confirm the port.

    Stop-Process -Id 10136 -Force; (Get-NetTCPConnection -LocalPort 8934 -State Listen -ErrorAction SilentlyContinue | Measure-Object).Count
    
    0

    Nothing is listening on 8934. Stopping by image name would end every Node process on the machine.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | counts equal and identifiers on one side only | The two paths returned the same number of different rows | Stop trusting the count. Step 4 shows 8 against 8 with 4 rows apart. | | only in the export is not empty | The file holds rows the filter excluded | Find which parameter the export path ignores. Step 5 names the owner filter. | | only on the screen is not empty | The file is short of rows the user was shown | Compare the boundary, the case rule and the cap. Steps 8 and 9. | | Counts differ by exactly the page size | The export is not reading page or perPage | Step 7. Ask for page 2 and see page 1 come back. | | A first differing field on a shared identifier | Both paths agree on which rows and disagree on their content | Find the second source. Here the export reads a nightly copy. | | The metadata line or filename names a filter the rows break | The description is built from the request, not from the query | Step 10. File it above the missing filter itself. | | verdict PASS on one query only | One pair agreeing proves nothing about the rest | Run the pair for each filter control on the screen. |

Common mistakes

Sign: The export test asserts the row count, passes on every build, and the file is wrong.Cause: Equal counts are the normal result of two different caps. In step 4 the screen paged 10 matches at 8 per page and the export capped its own 10 at 8. Both returned 8 rows, 4 of the 16 appeared on one side only, and the assertion could not see it.
Sign: The identifier sets match and a downstream report still disagrees with the screen.Cause: Identity is not content. Step 4 found T-011 on both sides with the title Filter resets on reload on the screen and Filter resets in the file, because the export path reads a copy refreshed nightly. Compare the fields of the shared rows or this stays invisible.
Sign: A filter works on the screen and the file ignores it, with no error anywhere.Cause: The export endpoint never reads that parameter. Step 5 sends owner=ada to both paths: the screen returns 6 rows, the export returns its whole table down to the cap, and 5 identifiers in the file were never on the screen. An unknown query parameter is silently dropped.
Sign: The file names the filter in its metadata line, so the tester files no bug.Cause: The description is built from the request string, not from the query that ran. Step 10 returned a file named tickets-open-ada.csv whose first line reads status=open; owner=ada, holding closed T-003, archived T-006 and three owners.

Thresholds

Equal row counts on both sides left 4 of 16 rows on one side only, and 1 shared row with a different title. Counts, identifiers and fields are three separate checks. Source: Measured in step 4, node 22.23.2, local target on 127.0.0.1:8934, 2026-09-12
One inclusive and one exclusive range end moved 1 row of 5. One case folding rule moved 5 rows of 6. Source: Measured in steps 8 and 9 against the same dataset of 14 rows, node 22.23.2, 2026-09-12

What to check next

FAQ

Export ignores filters, what do I check first?

The parameter names. Step 3 sends state=open because the export path never reads status. An unknown parameter raises nothing, so read both handlers first.

Exported file has all rows not filtered, why?

Two causes. The export never reads that parameter, as with the owner filter in step 5. Or it reads its own and defaults it to everything, as in step 6.

How to check export matches the screen?

Fetch both for one filter and compare identifier sets, not counts. compare.mjs prints what sits on one side alone and the first differing field for shared rows.

Do equal row counts prove the export is filtered correctly?

No. Step 4 returned 8 rows a side with 4 on one side alone, because the paths sort differently before they cap.

Does a filter named in the file prove it was applied?

No. The file in step 10 is named tickets-open-ada.csv and its metadata line claims both filters. Its rows hold three owners and two other states.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0Windows PowerShell 5.1Windows 11 build 22631

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny