Files
picpeak/backend/__tests__/routes/adminArchivesQuery.test.js
T
Paul Nothaft da6e34d6a3 fix(archives): sort and total on real archive sizes, escape LIKE wildcards
Closes the three trade-offs the server-side archives query deliberately
accepted.

C1 -- the sorted number and the displayed number are now the same one.
There was no archive_size column, so the Size column came from a per-row
fs.stat done after pagination while the sort fell back to summed photo bytes:
the list could be ordered by a number the user was not looking at. Adds
events.archive_size (bigInteger -- int4's 2.1GB ceiling is the same limit
that forced the restore path off adm-zip), written at archive time from
archive.pointer(), which is the exact byte count the completion email already
reports. The route now sorts and displays that column and no longer touches
the filesystem. The migration backfills by stat-ing every archive_path where
the column is null, outside the column guard so a half-finished run
self-heals; unstatable rows (missing zip, S3-backed storage) stay null, order
last via COALESCE and display 0 -- exactly what the old fs.stat produced for
a file it could not read. Restore nulls it alongside archive_path.
Accepted: the list no longer notices a zip deleted out of band and shows the
last recorded size. The detail route still stats the real file.

C2 -- escape \ % _ in the bound value plus an explicit ESCAPE '\'. The
ESCAPE clause is load-bearing rather than decorative: SQLite has no default
LIKE escape character, so without it the escaped pattern matches literal
backslashes and the search silently returns nothing on SQLite while working
on Postgres. The value stays bound; no interpolation.

C3 -- the four stat cards aggregated only the current page, so every total
was wrong for any dataset past page one. The list response now carries
totals { archives, photos, archiveSize } computed with the same applyFilters()
closure as pagination.total, so cards and footer cannot drift. Two aggregate
queries: archive_size sums on the unjoined events query (joining photos
multiplies it by photo count) and photos count on the joined one, both read
back through Number() for pg's bigint-as-string. The "Showing X of Y" line
moved out of the totalPages > 1 guard so it survives a single-page result,
now gated on total > 0 so a zero-result search does not render
"Showing 1 to 0 of 0"; only the page controls stay conditional.

Test fixtures deliberately order zip sizes differently from summed photo
bytes, so the sort test can only pass on the right column.

Refs testplan REPORT.md C1, C2, C3.
2026-09-02 09:43:10 +02:00

365 lines
14 KiB
JavaScript

/**
* The archives list must resolve search / type filter / sort in SQL.
*
* Before this, GET /admin/archives ignored every query param except page and
* limit: the UI fetched one 20-row page and filtered it in JavaScript while
* the pagination footer kept reporting the unfiltered server-side total. An
* archive that matched the search but lived on another page came back as a
* false "0 results". These tests pin the params the route now honours, and
* — the part that actually made the bug visible — that `pagination.total`
* describes the *filtered* set.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-archquery-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'archquery-test-secret';
const request = require('supertest');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helpers/crmDb');
describe('GET /admin/archives query params (#I.01)', () => {
let db; let cleanup; let app; let token;
// name, type, archived_at, photo sizes, archive_size (the zip's own bytes)
//
// The zip sizes are deliberately in a DIFFERENT order from the summed photo
// bytes, because that is the only way a test can tell which of the two the
// route sorted by. Echo's zip size is null — an archive whose file could not
// be measured.
const fixtures = [
['Alpha Wedding', 'wedding', '2026-01-05T10:00:00.000Z', [300], 100],
['Bravo Birthday', 'birthday', '2026-02-05T10:00:00.000Z', [100], 900],
['Charlie Wedding', 'wedding', '2026-03-05T10:00:00.000Z', [500, 400], 50],
['Delta Corporate', 'corporate', '2026-04-05T10:00:00.000Z', [200], 400],
['Echo WEDDING Gala', 'wedding', '2026-05-05T10:00:00.000Z', [50], null],
// Four rows that exist purely to pin LIKE-metacharacter handling: each
// metacharacter name is paired with a name that a wildcard reading of it
// would also match. Dated in 2025 so they sit at the tail of the default
// newest-first ordering. None of them contains "wedding".
['Summer 100% Sale', 'party', '2025-01-05T10:00:00.000Z', [10], 1],
['Summer 100X Sale', 'party', '2025-02-05T10:00:00.000Z', [10], 2],
['Gala_Night', 'party', '2025-03-05T10:00:00.000Z', [10], 3],
['GalaXNight', 'party', '2025-04-05T10:00:00.000Z', [10], 4],
];
// Every fixture's photo bytes and zip bytes, for the aggregate assertions.
const ALL_PHOTOS = 10;
const ALL_ARCHIVE_BYTES = 100 + 900 + 50 + 400 + 0 + 1 + 2 + 3 + 4;
const list = async (query) => {
const res = await request(app)
.get('/admin/archives')
.query(query)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
return res.body;
};
const names = (body) => body.archives.map((a) => a.eventName);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'super_admin' }).first();
const inserted = await db('admin_users').insert({
username: 'arch-admin',
email: 'arch-admin@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
const adminId = inserted[0]?.id ?? inserted[0];
token = jwt.sign(
{ id: adminId, username: 'arch-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
let i = 0;
for (const [eventName, eventType, archivedAt, sizes, archiveSize] of fixtures) {
const slug = `arch-${i++}`;
const ev = await db('events').insert({
slug,
event_type: eventType,
event_name: eventName,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 0,
is_archived: 1,
is_draft: 0,
archived_at: archivedAt,
archive_path: `events/archived/${slug}.zip`,
archive_size: archiveSize,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
let p = 0;
for (const size of sizes) {
await db('photos').insert({
event_id: eventId,
filename: `${slug}-${p++}.jpg`,
path: `events/archived/${slug}.jpg`,
type: 'individual',
size_bytes: size,
uploaded_at: new Date().toISOString(),
});
}
}
// A live event that must never surface in the archives list.
await db('events').insert({
slug: 'not-archived',
event_type: 'wedding',
event_name: 'Alpha Live Wedding',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'tok-not-archived',
share_link: '/gallery/not-archived/tok-not-archived',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
});
app = buildRouteApp('/admin/archives', require('../../src/routes/adminArchives'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('no params: every archive, newest first, unfiltered total', async () => {
const body = await list({});
expect(names(body)).toEqual([
'Echo WEDDING Gala', 'Delta Corporate', 'Charlie Wedding', 'Bravo Birthday', 'Alpha Wedding',
'GalaXNight', 'Gala_Night', 'Summer 100X Sale', 'Summer 100% Sale',
]);
expect(body.pagination.total).toBe(9);
});
test('search filters in SQL and the total describes the filtered set', async () => {
const body = await list({ search: 'wedding' });
// Case-insensitive, matches "Echo WEDDING Gala" too, and never the
// non-archived "Alpha Live Wedding".
expect(names(body).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(body.pagination.total).toBe(3);
expect(body.pagination.totalPages).toBe(1);
});
test('search reaches rows that are not on page 1 — the actual bug', async () => {
// limit=2 puts "Alpha Wedding" (oldest) on page 3 of the unfiltered list.
// Client-side filtering of page 1 returned nothing for this query.
const body = await list({ search: 'alpha', limit: 2, page: 1 });
expect(names(body)).toEqual(['Alpha Wedding']);
expect(body.pagination.total).toBe(1);
});
test('search with no match returns an empty page and a zero total', async () => {
const body = await list({ search: 'zzz-nothing' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
expect(body.pagination.totalPages).toBe(0);
});
test('type filter narrows the rows and the total; "all" is a no-op', async () => {
const filtered = await list({ type: 'wedding' });
expect(names(filtered).sort()).toEqual(['Alpha Wedding', 'Charlie Wedding', 'Echo WEDDING Gala']);
expect(filtered.pagination.total).toBe(3);
const all = await list({ type: 'all' });
expect(all.pagination.total).toBe(9);
});
test('search and type filter combine', async () => {
const body = await list({ search: 'wedding', type: 'birthday' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
});
test('sortBy=name orders across the whole set, not just the page', async () => {
const page1 = await list({ sortBy: 'name', limit: 2, page: 1 });
expect(names(page1)).toEqual(['Alpha Wedding', 'Bravo Birthday']);
const page3 = await list({ sortBy: 'name', limit: 2, page: 3 });
expect(names(page3)).toEqual(['Echo WEDDING Gala', 'GalaXNight']);
});
test('sortBy=size orders by the zip size — the number the Size column shows', async () => {
const body = await list({ sortBy: 'size' });
// Ordering by the summed photo bytes (what this did before
// events.archive_size existed) would have produced Charlie / Alpha /
// Delta / Bravo / Echo — a different list from the one on screen.
expect(names(body).slice(0, 5)).toEqual([
'Bravo Birthday', // zip 900, photos 100
'Delta Corporate', // zip 400, photos 200
'Alpha Wedding', // zip 100, photos 300
'Charlie Wedding', // zip 50, photos 900
'GalaXNight', // zip 4
]);
// The unmeasured zip sorts last, as the 0 it displays.
expect(names(body).at(-1)).toBe('Echo WEDDING Gala');
});
test('archiveSize is the stored zip size, and 0 when it was never measured', async () => {
const body = await list({ search: 'wedding' });
const bySlug = Object.fromEntries(body.archives.map((a) => [a.eventName, a.archiveSize]));
expect(bySlug['Alpha Wedding']).toBe(100);
expect(bySlug['Charlie Wedding']).toBe(50);
expect(bySlug['Echo WEDDING Gala']).toBe(0);
});
test('an unknown sortBy falls back to the date ordering', async () => {
const body = await list({ sortBy: 'events.id; drop table events' });
expect(names(body)[0]).toBe('Echo WEDDING Gala');
expect(body.pagination.total).toBe(9);
});
test('quotes in the search are bound as a value, not injected as SQL', async () => {
const body = await list({ search: '\'; DROP TABLE events; --' });
expect(body.archives).toEqual([]);
// The table is still there.
expect((await list({})).pagination.total).toBe(9);
});
// --- LIKE metacharacters (C2) -------------------------------------------
// `%` and `_` are wildcards in a LIKE pattern. An admin typing them into a
// search box means them literally, so they are escaped in the bound value
// and the pattern carries an explicit ESCAPE clause.
test('a literal % matches only a literal %', async () => {
const body = await list({ search: '100%' });
expect(names(body)).toEqual(['Summer 100% Sale']);
// Unescaped, "100%" is the prefix wildcard "100" and would also have
// matched "Summer 100X Sale".
expect(body.pagination.total).toBe(1);
});
test('a bare % is not a match-everything wildcard', async () => {
const body = await list({ search: '%' });
expect(names(body)).toEqual(['Summer 100% Sale']);
expect(body.pagination.total).toBe(1);
});
test('a literal _ matches only a literal _', async () => {
const body = await list({ search: 'gala_night' });
expect(names(body)).toEqual(['Gala_Night']);
// Unescaped, "_" is any single character and would also have matched
// "GalaXNight".
expect(body.pagination.total).toBe(1);
});
test('a backslash in the search is matched literally, not eaten as an escape', async () => {
// Nothing in the fixtures contains a backslash. Without escaping the
// escape character itself, "\%" would reach SQL as an escaped percent and
// match every row on the engine that honours it.
const body = await list({ search: '\\%' });
expect(body.archives).toEqual([]);
expect(body.pagination.total).toBe(0);
});
// --- Aggregates (C3) -----------------------------------------------------
test('totals describe the whole filtered set, not the page', async () => {
const body = await list({ limit: 2, page: 1 });
expect(body.archives).toHaveLength(2);
expect(body.totals).toEqual({
archives: 9,
photos: ALL_PHOTOS,
archiveSize: ALL_ARCHIVE_BYTES,
});
// Same numbers from the last page — the stat cards must not move as the
// admin pages through the list.
const last = await list({ limit: 2, page: 5 });
expect(last.totals).toEqual(body.totals);
});
test('totals respect the active search and type filter', async () => {
const body = await list({ type: 'wedding' });
expect(body.totals).toEqual({
archives: 3,
photos: 4, // Alpha 1 + Charlie 2 + Echo 1
archiveSize: 100 + 50 + 0, // Echo's zip was never measured
});
const none = await list({ search: 'zzz-nothing' });
expect(none.totals).toEqual({ archives: 0, photos: 0, archiveSize: 0 });
});
// --- Migration backfill (C1) ---------------------------------------------
test('migration 197 backfills archive_size from the zip on disk', async () => {
const migration = require('../../migrations/core/197_add_event_archive_size');
const storagePath = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storagePath, 'events/archived'), { recursive: true });
await fs.promises.writeFile(path.join(storagePath, 'events/archived/backfill.zip'), Buffer.alloc(4096));
const base = {
event_type: 'wedding',
event_name: 'Backfill Me',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 0,
is_archived: 1,
is_draft: 0,
archived_at: '2026-07-05T10:00:00.000Z',
archive_size: null,
created_at: new Date().toISOString(),
};
await db('events').insert([
{
...base,
slug: 'backfill-present',
share_token: 'tok-backfill-present',
share_link: '/gallery/backfill-present/tok-backfill-present',
archive_path: 'events/archived/backfill.zip',
},
{
...base,
slug: 'backfill-missing',
share_token: 'tok-backfill-missing',
share_link: '/gallery/backfill-missing/tok-backfill-missing',
archive_path: 'events/archived/gone.zip',
},
]);
// Re-runnable by design: the column already exists, only the empty rows
// are touched.
await migration.up(db);
const present = await db('events').where('slug', 'backfill-present').first();
const missing = await db('events').where('slug', 'backfill-missing').first();
expect(Number(present.archive_size)).toBe(4096);
// A zip that cannot be statted stays NULL rather than claiming 0 bytes.
expect(missing.archive_size).toBeNull();
// A second run must not disturb the value it already wrote.
await migration.up(db);
expect(Number((await db('events').where('slug', 'backfill-present').first()).archive_size)).toBe(4096);
await db('events').whereIn('slug', ['backfill-present', 'backfill-missing']).del();
});
});