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
- Node 22.23.2 for the three programs below. No dependencies.
- curl 8.21.0 for the two raw reads.
- Port 8934 free before you start. The target binds 127.0.0.1 only and prints its pid. Stop it by that pid, never by image name.
- Row counts alone are in How to count rows in a csv file.
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
- Step 1.
Start the target and keep the pid it prints.
node server.mjstarget on http://127.0.0.1:8934 pid 10136The pid is yours for the rest of the run.
- 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 emailTen open tickets match and the page holds eight. That is what the tester sees.
- 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-11Eight data rows again. A test that asserts on the count stops here and passes.
- 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 FAILCounts 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.
- 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 FAILThe 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.
- 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 FAILThe screen defaults to open, the export to every state. Counts match at eight and the file carries closed T-003 and archived T-006.
- 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 FAILThe export reads neither
pagenorperPage. 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. - 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 FAILThe screen treats
toas inclusive, the export as exclusive. T-005, created on the last day, is on the screen and not in the file. - 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 FAILThe screen folds case, the export compares bytes. Five titles spelled
LoginorLOGINare absent from the file, which keeps the one spelledlogin. - 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-08The filename and the metadata line claim
status=openandowner=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. - 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 PASSTwo rows a side, no identifier alone, no field apart. That is the shape of a passing run.
- 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).Count0Nothing 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
Thresholds
What to check next
- How to compare two csv files: the same comparison between two files.
- How to count rows in a csv file: what one side's count is worth alone.
- How to test a large data export: the cap in step 4, on a bigger file.
- How to check duplicate rows on import: two rows under one identifier.
- How to test csv import validation: what the file meets going back in.
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.
Related on this site
intermediate12 minpublished updated Maks Verny