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.
This commit is contained in:
Paul Nothaft
2026-09-01 18:47:20 +02:00
parent 4c2eeab2f4
commit da6e34d6a3
7 changed files with 380 additions and 84 deletions
@@ -29,15 +29,32 @@ const { bootCrmDb, seedMinimal, buildRouteApp } = require('../integration/helper
describe('GET /admin/archives query params (#I.01)', () => {
let db; let cleanup; let app; let token;
// name, type, archived_at, photo sizes
// 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]],
['Bravo Birthday', 'birthday', '2026-02-05T10:00:00.000Z', [100]],
['Charlie Wedding', 'wedding', '2026-03-05T10:00:00.000Z', [500, 400]],
['Delta Corporate', 'corporate', '2026-04-05T10:00:00.000Z', [200]],
['Echo WEDDING Gala', 'wedding', '2026-05-05T10:00:00.000Z', [50]],
['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')
@@ -71,7 +88,7 @@ describe('GET /admin/archives query params (#I.01)', () => {
);
let i = 0;
for (const [eventName, eventType, archivedAt, sizes] of fixtures) {
for (const [eventName, eventType, archivedAt, sizes, archiveSize] of fixtures) {
const slug = `arch-${i++}`;
const ev = await db('events').insert({
slug,
@@ -88,6 +105,8 @@ describe('GET /admin/archives query params (#I.01)', () => {
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];
@@ -132,8 +151,9 @@ describe('GET /admin/archives query params (#I.01)', () => {
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(5);
expect(body.pagination.total).toBe(9);
});
test('search filters in SQL and the total describes the filtered set', async () => {
@@ -166,7 +186,7 @@ describe('GET /admin/archives query params (#I.01)', () => {
expect(filtered.pagination.total).toBe(3);
const all = await list({ type: 'all' });
expect(all.pagination.total).toBe(5);
expect(all.pagination.total).toBe(9);
});
test('search and type filter combine', async () => {
@@ -180,30 +200,165 @@ describe('GET /admin/archives query params (#I.01)', () => {
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']);
expect(names(page3)).toEqual(['Echo WEDDING Gala', 'GalaXNight']);
});
test('sortBy=size orders by archived content size, largest first', async () => {
test('sortBy=size orders by the zip size — the number the Size column shows', async () => {
const body = await list({ sortBy: 'size' });
expect(names(body)).toEqual([
'Charlie Wedding', // 900
'Alpha Wedding', // 300
'Delta Corporate', // 200
'Bravo Birthday', // 100
'Echo WEDDING Gala' // 50
// 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(5);
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(5);
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();
});
});
@@ -0,0 +1,63 @@
/**
* `events.archive_size` — the archive zip's own byte size, recorded once at
* archive time.
*
* The archives list showed a Size column produced by a per-row `fs.stat` run
* AFTER pagination, so the number was only ever known for the 20 rows on
* screen. That made a server-side "sort by size" impossible without statting
* every archive on every request, and the sort fell back to
* `SUM(photos.size_bytes)` — the archived *content* size, a different number
* from the one in the column. Rows whose zip is missing or whose compression
* ratio differs sorted by a value the admin was not looking at.
*
* With the size on the row, the sorted number and the displayed number are the
* same number, and the list stops touching the filesystem at all.
*
* bigInteger, not integer: a real wedding archive crosses int4's 2.1 GB
* ceiling routinely — that limit is why the restore path had to move off
* adm-zip. Read it back through `Number()`, the pg driver hands bigints out as
* strings.
*/
const fs = require('fs').promises;
const path = require('path');
exports.up = async function (knex) {
if (!(await knex.schema.hasColumn('events', 'archive_size'))) {
await knex.schema.alterTable('events', (table) => {
table.bigInteger('archive_size');
});
}
// Backfill deliberately OUTSIDE the column guard: a run that died after the
// alterTable but partway through the stats would leave the column present
// and half the rows empty, and the re-run would skip both. `whereNull` makes
// this idempotent and self-healing.
//
// Same storage-root resolution the routes use, so the migration reads the
// files the app writes.
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const rows = await knex('events')
.whereNotNull('archive_path')
.whereNull('archive_size')
.select('id', 'archive_path');
for (const row of rows) {
try {
const stats = await fs.stat(path.join(storagePath, row.archive_path));
await knex('events').where('id', row.id).update({ archive_size: stats.size });
} catch (_) {
// Zip gone, or on a storage backend this process cannot stat (S3). Left
// NULL, which the list renders and orders as 0 — exactly what the per-row
// fs.stat this replaces already did for a file it could not read.
}
}
};
exports.down = async function (knex) {
if (await knex.schema.hasColumn('events', 'archive_size')) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('archive_size');
});
}
};
+49 -27
View File
@@ -22,18 +22,23 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
const type = typeof req.query.type === 'string' ? req.query.type.trim() : '';
const sortBy = ['date', 'name', 'size'].includes(req.query.sortBy) ? req.query.sortBy : 'date';
// A literal % or _ typed into the search box has to match itself rather
// than act as a wildcard. Escape the escape character first, then the two
// wildcards. utils/sqlSecurity.js's escapeLikePattern() is not usable
// here: it also doubles single quotes, which corrupts a BOUND value —
// "Sarah's Birthday" would be searched for as "Sarah''s Birthday".
const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&');
// Search and type filtering run in SQL so both the returned rows and
// the total count cover the whole archive table, not just the page the
// client happens to be on. Values are bound, never interpolated.
// % and _ are wildcards to LIKE but literal characters to the client-side
// `includes()` this replaced, so searching for "100%" would otherwise match
// every archive and report a nonsense total. 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
// there while working on Postgres.
const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&');
const applyFilters = (query) => {
if (search) {
// The ESCAPE clause is explicit because the two engines disagree
// without it: Postgres treats a backslash in a LIKE pattern as an
// escape by default, SQLite has no default escape character at all and
// would match the backslash literally. Naming it makes the escaping
// above mean the same thing on both.
query.whereRaw(
'LOWER(events.event_name) LIKE ? ESCAPE \'\\\'',
[`%${escapeLike(search.toLowerCase())}%`]
@@ -45,11 +50,24 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
return query;
};
// Get total count (of the filtered set, so pagination stays truthful)
// Totals for the filtered set, so pagination AND the stat cards describe
// the whole result rather than the page. Unjoined: joining photos here
// would multiply archive_size by the event's photo count.
const totalCount = await applyFilters(
db('events').where('events.is_archived', formatBoolean(true))
)
.count('events.id as count')
.sum({ archive_size: 'events.archive_size' })
.first();
// Photo total needs the join, so it is its own query for the same reason.
// count(photos.id) rather than count(*): a left-joined event with no
// photos must contribute 0, not 1.
const photoTotal = await applyFilters(
db('events').where('events.is_archived', formatBoolean(true))
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.count('photos.id as count')
.first();
// Get archived events
@@ -67,29 +85,20 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
if (sortBy === 'name') {
archivesQuery.orderBy('events.event_name', 'asc');
} else if (sortBy === 'size') {
// The zip's on-disk size is only known after the per-row fs.stat below,
// so a global size sort has to use the archived content size instead.
archivesQuery.orderByRaw('COALESCE(SUM(photos.size_bytes), 0) desc');
// events.archive_size — the same number the Size column renders. It was
// the archived *content* size (SUM(photos.size_bytes)) until the column
// existed, which meant the list could be ordered by a value that is not
// on screen. NULL is an archive whose zip could not be measured (missing
// file, or a storage backend the backfill could not stat); it sorts as 0,
// i.e. last, which is also what it displays as.
archivesQuery.orderByRaw('COALESCE(events.archive_size, 0) desc');
} else {
archivesQuery.orderBy('events.archived_at', 'desc');
}
const archives = await archivesQuery.limit(limit).offset(offset);
// Check if archive files exist and get their sizes
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => {
let archiveFileSize = 0;
if (archive.archive_path) {
try {
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveFileSize = stats.size;
} catch (error) {
logger.error(`Archive file not found: ${archive.archive_path}`);
}
}
const archivesWithFileInfo = archives.map((archive) => {
return {
id: archive.id,
slug: archive.slug,
@@ -101,10 +110,11 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null,
photoCount: archive.photo_count || 0,
originalSize: archive.total_size || 0,
archiveSize: archiveFileSize,
// Number(): bigInteger comes back from the pg driver as a string.
archiveSize: Number(archive.archive_size) || 0,
archivePath: archive.archive_path
};
}));
});
res.json({
archives: archivesWithFileInfo,
@@ -113,6 +123,14 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
limit,
total: totalCount.count,
totalPages: Math.ceil(totalCount.count / limit)
},
// Aggregates over the FILTERED set — the stat cards used to sum the rows
// the client could see, so every figure was page-scoped while the footer
// beside them reported the real total.
totals: {
archives: Number(totalCount.count) || 0,
photos: Number(photoTotal.count) || 0,
archiveSize: Number(totalCount.archive_size) || 0
}
});
} catch (error) {
@@ -533,6 +551,10 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
is_archived: false,
is_active: true,
archive_path: null,
// Cleared with the path it measures — a restored event has no zip, and
// a stale size would be re-shown verbatim if it is archived again
// before the new archive finishes writing.
archive_size: null,
archived_at: null,
expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs
});
+4
View File
@@ -166,6 +166,10 @@ async function archiveEvent(event) {
await db('events').where('id', event.id).update({
is_archived: true,
archive_path: archiveRelKey,
// The zip's own byte size — the same number the completion email
// reports below. Persisted so the archives list can sort and display it
// without statting every archive on every request.
archive_size: totalBytes,
archived_at: new Date(),
});
+42 -37
View File
@@ -68,9 +68,11 @@ export const ArchivesPage: React.FC = () => {
const archives = archivesData?.archives || [];
const getTotalSize = () => {
return archives.reduce((sum, archive) => sum + archive.archiveSize, 0);
};
// Server-side aggregates over the whole filtered set. Summing `archives`
// here only ever described the 20 rows of the current page, so "Storage
// used" on an 802-archive install was off by roughly 40x while the footer
// right below it reported the real total.
const totals = archivesData?.totals ?? { archives: 0, photos: 0, archiveSize: 0 };
// Mutations
const restoreMutation = useMutationWithToast({
@@ -136,7 +138,7 @@ export const ArchivesPage: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalArchives')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{totals.archives}</p>
</div>
<Archive className="w-8 h-8 text-accent" />
</div>
@@ -146,7 +148,7 @@ export const ArchivesPage: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.storageUsed')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archiveService.formatBytes(getTotalSize())}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archiveService.formatBytes(totals.archiveSize)}</p>
</div>
<HardDrive className="w-8 h-8 text-blue-600" />
</div>
@@ -157,10 +159,7 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{(() => {
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
return total === 0 ? '0' : total.toLocaleString();
})()}
{totals.photos === 0 ? '0' : totals.photos.toLocaleString()}
</p>
</div>
<FileArchive className="w-8 h-8 text-green-600" />
@@ -172,8 +171,8 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.avgArchiveSize')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{archives.length > 0
? archiveService.formatBytes(getTotalSize() / archives.length)
{totals.archives > 0
? archiveService.formatBytes(totals.archiveSize / totals.archives)
: '0 Bytes'
}
</p>
@@ -340,39 +339,45 @@ export const ArchivesPage: React.FC = () => {
</div>
</Card>
{/* Pagination */}
{archivesData?.pagination && archivesData.pagination.totalPages > 1 && (
{/* Pagination. The count is shown for any non-empty result — it used to
be inside the totalPages > 1 guard, so a search that narrowed to a
single page lost the "Showing X of Y" line along with the controls,
which is exactly when the count is worth reading. Only the page
controls are conditional now. */}
{archivesData?.pagination && archivesData.pagination.total > 0 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-neutral-600">
{t('archives.showing', {
{t('archives.showing', {
from: ((currentPage - 1) * archivesData.pagination.limit) + 1,
to: Math.min(currentPage * archivesData.pagination.limit, archivesData.pagination.total),
total: archivesData.pagination.total
})}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
leftIcon={<ChevronLeft className="w-4 h-4" />}
>
{t('common.previous')}
</Button>
<span className="px-3 text-sm">
{t('archives.page', { current: currentPage, total: archivesData.pagination.totalPages })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
disabled={currentPage === archivesData.pagination.totalPages}
rightIcon={<ChevronRight className="w-4 h-4" />}
>
{t('common.next')}
</Button>
</div>
{archivesData.pagination.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
leftIcon={<ChevronLeft className="w-4 h-4" />}
>
{t('common.previous')}
</Button>
<span className="px-3 text-sm">
{t('archives.page', { current: currentPage, total: archivesData.pagination.totalPages })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
disabled={currentPage === archivesData.pagination.totalPages}
rightIcon={<ChevronRight className="w-4 h-4" />}
>
{t('common.next')}
</Button>
</div>
)}
</div>
)}
@@ -55,9 +55,14 @@ vi.mock('../../../services/archive.service', () => ({
import { ArchivesPage } from '../ArchivesPage';
const page = (archives: unknown[], total: number) => ({
type Totals = { archives: number; photos: number; archiveSize: number };
const page = (archives: unknown[], total: number, totals?: Totals) => ({
archives,
pagination: { page: 1, limit: 20, total, totalPages: Math.ceil(total / 20) },
// The aggregates are computed server-side over the filtered set; the page
// only renders them.
totals: totals ?? { archives: total, photos: total * 3, archiveSize: total * 100 },
});
const archive = (id: number, eventName: string, eventType = 'wedding') => ({
@@ -133,4 +138,39 @@ describe('ArchivesPage server-side query (QA I.01)', () => {
await userEvent.selectOptions(screen.getByDisplayValue('archives.allTypes'), 'corporate');
await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'corporate', 'date'));
});
it('reads the stat cards from the server totals, not from the loaded page', async () => {
// One row on screen, 802 in the dataset. Summing the rendered rows — what
// the cards used to do — would report 1 archive and 100 bytes.
getArchives.mockResolvedValue(
page([archive(1, 'Alpha Wedding')], 802, { archives: 802, photos: 12345, archiveSize: 999000 })
);
renderPage();
expect(await screen.findByText('802')).toBeInTheDocument();
// Grouped by the runtime's locale, so ask it rather than hardcoding.
expect(screen.getByText((12345).toLocaleString())).toBeInTheDocument();
expect(screen.getByText('999000 B')).toBeInTheDocument();
// Average is over the whole set too, not over the page.
expect(screen.getByText(`${999000 / 802} B`)).toBeInTheDocument();
});
it('keeps the "showing X of Y" count on a single-page result', async () => {
// The count used to live inside the `totalPages > 1` guard, so a search
// that narrowed to one page hid the number that says how many matched.
getArchives.mockResolvedValue(page([archive(1, 'Alpha Wedding')], 1));
renderPage();
expect(await screen.findByText('archives.showing')).toBeInTheDocument();
// Page controls stay hidden — there is only one page.
expect(screen.queryByText('common.next')).not.toBeInTheDocument();
});
it('shows no count at all when nothing matched', async () => {
getArchives.mockResolvedValue(page([], 0));
renderPage();
expect(await screen.findByText('archives.noArchivesFound')).toBeInTheDocument();
expect(screen.queryByText('archives.showing')).not.toBeInTheDocument();
});
});
+7
View File
@@ -43,6 +43,13 @@ export interface ArchivesResponse {
total: number;
totalPages: number;
};
// Aggregates over the whole filtered set, not the page — the stat cards
// summed the loaded rows, so they described 20 archives out of 802.
totals?: {
archives: number;
photos: number;
archiveSize: number;
};
}
export const archiveService = {