mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): add jump rail navigation to book listings (#318)
Add server-backed jump buckets so large library, collection, and smart-scope listings can jump directly to sorted sections without loading every row up front. Closes #163
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
import type { BookCard, BooksPage, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
|
||||
|
||||
import { refreshPrimaryAuthorSortNamesForBooks } from '../src/db/book-author-sort-key';
|
||||
import { bookAuthors, bookMetadata, books, bookSeries, authors } from '../src/db/schema';
|
||||
import {
|
||||
authHeader,
|
||||
closeMetadataWriteE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createMetadataWriteE2EContext,
|
||||
createUserAndLogin,
|
||||
type MetadataWriteE2EContext,
|
||||
} from './e2e/metadata-write/metadata-write-harness';
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 120_000;
|
||||
|
||||
type SeededBook = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
authorSortName: string | null;
|
||||
publishedYear: number | null;
|
||||
seriesName: string | null;
|
||||
};
|
||||
|
||||
// Mirrors the SQL letter bucket expression: empty -> null, A-Z -> letter, else '#'.
|
||||
function letterOf(value: string | null): string | null {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (trimmed === '') return null;
|
||||
const first = trimmed[0]!.toUpperCase();
|
||||
return /^[A-Z]$/.test(first) ? first : '#';
|
||||
}
|
||||
|
||||
function expectedBucketKey(book: SeededBook, field: 'title' | 'author' | 'publishedYear', collapsed: boolean): string | null {
|
||||
if (field === 'publishedYear') return book.publishedYear !== null ? String(book.publishedYear) : null;
|
||||
if (field === 'author') return letterOf(book.authorSortName);
|
||||
if (collapsed) return letterOf(book.seriesName ?? book.title);
|
||||
return letterOf(book.title);
|
||||
}
|
||||
|
||||
describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
let ctx!: MetadataWriteE2EContext;
|
||||
let libraryId!: number;
|
||||
const seededById = new Map<number, SeededBook>();
|
||||
|
||||
async function seedBook(input: {
|
||||
title: string | null;
|
||||
publishedYear?: number | null;
|
||||
seriesId?: number | null;
|
||||
seriesName?: string | null;
|
||||
seriesIndex?: number | null;
|
||||
authorIds?: number[];
|
||||
addedAt: Date;
|
||||
libraryFolderId: number;
|
||||
}): Promise<number> {
|
||||
const [book] = await ctx.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId,
|
||||
libraryFolderId: input.libraryFolderId,
|
||||
folderPath: `/seed/${Math.random().toString(36).slice(2)}`,
|
||||
status: 'present',
|
||||
addedAt: input.addedAt,
|
||||
})
|
||||
.returning({ id: books.id });
|
||||
|
||||
await ctx.db.insert(bookMetadata).values({
|
||||
bookId: book!.id,
|
||||
title: input.title,
|
||||
publishedYear: input.publishedYear ?? null,
|
||||
seriesId: input.seriesId ?? null,
|
||||
seriesName: input.seriesName ?? null,
|
||||
seriesIndex: input.seriesIndex ?? null,
|
||||
});
|
||||
|
||||
for (const [order, authorId] of (input.authorIds ?? []).entries()) {
|
||||
await ctx.db.insert(bookAuthors).values({ bookId: book!.id, authorId, displayOrder: order });
|
||||
}
|
||||
|
||||
return book!.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createMetadataWriteE2EContext();
|
||||
const library = await createLibraryWithFolder(ctx, { name: 'jump-buckets-invariant' });
|
||||
libraryId = library.libraryId;
|
||||
|
||||
const [duneSaga] = await ctx.db.insert(bookSeries).values({ name: 'Dune Saga', normalizedName: 'dune saga' }).returning({ id: bookSeries.id });
|
||||
const [zetaSeries] = await ctx.db
|
||||
.insert(bookSeries)
|
||||
.values({ name: 'zeta chronicles', normalizedName: 'zeta chronicles' })
|
||||
.returning({ id: bookSeries.id });
|
||||
|
||||
const authorRows = await ctx.db
|
||||
.insert(authors)
|
||||
.values([
|
||||
{ name: 'Isaac Asimov', sortName: 'Asimov, Isaac' },
|
||||
{ name: 'Roger Zelazny', sortName: 'Zelazny, Roger' },
|
||||
{ name: 'banana namesort', sortName: null },
|
||||
{ name: '99 Cent Press', sortName: '99 Cent Press' },
|
||||
])
|
||||
.returning({ id: authors.id, name: authors.name, sortName: authors.sortName });
|
||||
const [asimov, zelazny, banana, cheap] = authorRows;
|
||||
|
||||
const seedSpecs: Array<{
|
||||
title: string | null;
|
||||
publishedYear?: number | null;
|
||||
series?: { id: number; name: string; index: number };
|
||||
author?: { id: number; sortName: string | null; name: string };
|
||||
addedAt: string;
|
||||
}> = [
|
||||
{
|
||||
title: 'Alpha Centauri',
|
||||
publishedYear: 1991,
|
||||
author: { id: asimov!.id, sortName: 'Asimov, Isaac', name: 'Isaac Asimov' },
|
||||
addedAt: '2024-01-01',
|
||||
},
|
||||
{ title: 'apple pie', publishedYear: 2005, author: { id: banana!.id, sortName: null, name: 'banana namesort' }, addedAt: '2024-01-02' },
|
||||
{ title: '1984', publishedYear: 1949, author: { id: zelazny!.id, sortName: 'Zelazny, Roger', name: 'Roger Zelazny' }, addedAt: '2024-01-03' },
|
||||
{ title: 'Émile of the North', publishedYear: 1949, addedAt: '2024-01-04' },
|
||||
{
|
||||
title: 'Same Title',
|
||||
publishedYear: 2001,
|
||||
author: { id: asimov!.id, sortName: 'Asimov, Isaac', name: 'Isaac Asimov' },
|
||||
addedAt: '2024-01-05',
|
||||
},
|
||||
{
|
||||
title: 'Same Title',
|
||||
publishedYear: 2002,
|
||||
author: { id: cheap!.id, sortName: '99 Cent Press', name: '99 Cent Press' },
|
||||
addedAt: '2024-01-06',
|
||||
},
|
||||
{ title: 'Same Title', publishedYear: 2003, addedAt: '2024-01-07' },
|
||||
{ title: 'Beta Waves', publishedYear: null, author: { id: banana!.id, sortName: null, name: 'banana namesort' }, addedAt: '2024-01-08' },
|
||||
{ title: null, publishedYear: 1980, addedAt: '2024-01-09' },
|
||||
{
|
||||
title: 'zebra crossing',
|
||||
publishedYear: 2010,
|
||||
author: { id: zelazny!.id, sortName: 'Zelazny, Roger', name: 'Roger Zelazny' },
|
||||
addedAt: '2024-01-10',
|
||||
},
|
||||
{
|
||||
title: 'Dune',
|
||||
publishedYear: 1965,
|
||||
series: { id: duneSaga!.id, name: 'Dune Saga', index: 1 },
|
||||
author: { id: asimov!.id, sortName: 'Asimov, Isaac', name: 'Isaac Asimov' },
|
||||
addedAt: '2024-01-11',
|
||||
},
|
||||
{
|
||||
title: 'Dune Messiah',
|
||||
publishedYear: 1969,
|
||||
series: { id: duneSaga!.id, name: 'Dune Saga', index: 2 },
|
||||
author: { id: asimov!.id, sortName: 'Asimov, Isaac', name: 'Isaac Asimov' },
|
||||
addedAt: '2024-01-12',
|
||||
},
|
||||
{
|
||||
title: 'Children of Dune',
|
||||
publishedYear: 1976,
|
||||
series: { id: duneSaga!.id, name: 'Dune Saga', index: 3 },
|
||||
author: { id: zelazny!.id, sortName: 'Zelazny, Roger', name: 'Roger Zelazny' },
|
||||
addedAt: '2024-01-13',
|
||||
},
|
||||
{
|
||||
title: 'Awakening',
|
||||
publishedYear: 2015,
|
||||
series: { id: zetaSeries!.id, name: 'zeta chronicles', index: 1 },
|
||||
author: { id: zelazny!.id, sortName: 'Zelazny, Roger', name: 'Roger Zelazny' },
|
||||
addedAt: '2024-01-14',
|
||||
},
|
||||
{
|
||||
title: 'Burning',
|
||||
publishedYear: 2016,
|
||||
series: { id: zetaSeries!.id, name: 'zeta chronicles', index: 2 },
|
||||
author: { id: banana!.id, sortName: null, name: 'banana namesort' },
|
||||
addedAt: '2024-01-15',
|
||||
},
|
||||
{
|
||||
title: 'Quantum Garden',
|
||||
publishedYear: 2016,
|
||||
author: { id: cheap!.id, sortName: '99 Cent Press', name: '99 Cent Press' },
|
||||
addedAt: '2024-01-16',
|
||||
},
|
||||
{ title: 'quail hunting', publishedYear: 1991, addedAt: '2024-01-17' },
|
||||
];
|
||||
|
||||
const allIds: number[] = [];
|
||||
for (const spec of seedSpecs) {
|
||||
const id = await seedBook({
|
||||
title: spec.title,
|
||||
publishedYear: spec.publishedYear ?? null,
|
||||
seriesId: spec.series?.id ?? null,
|
||||
seriesName: spec.series?.name ?? null,
|
||||
seriesIndex: spec.series?.index ?? null,
|
||||
authorIds: spec.author ? [spec.author.id] : [],
|
||||
addedAt: new Date(`${spec.addedAt}T00:00:00.000Z`),
|
||||
libraryFolderId: library.libraryFolderId,
|
||||
});
|
||||
allIds.push(id);
|
||||
seededById.set(id, {
|
||||
id,
|
||||
title: spec.title,
|
||||
authorSortName: spec.author ? (spec.author.sortName ?? spec.author.name) : null,
|
||||
publishedYear: spec.publishedYear ?? null,
|
||||
seriesName: spec.series?.name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
await refreshPrimaryAuthorSortNamesForBooks(ctx.db, allIds);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMetadataWriteE2EContext(ctx);
|
||||
});
|
||||
|
||||
async function fetchBuckets(sort: SortSpec[], collapseSeries: boolean): Promise<JumpBucketsResponse> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { sort, pagination: { page: 0, size: 50 }, ...(collapseSeries ? { collapseSeries: true } : {}) },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.json() as JumpBucketsResponse;
|
||||
}
|
||||
|
||||
async function fetchListing(sort: SortSpec[], collapseSeries: boolean): Promise<BooksPage> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${libraryId}/books`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { sort, pagination: { page: 0, size: 200 }, ...(collapseSeries ? { collapseSeries: true } : {}) },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.json() as BooksPage;
|
||||
}
|
||||
|
||||
const fields = ['title', 'author', 'publishedYear'] as const;
|
||||
const dirs = ['asc', 'desc'] as const;
|
||||
const collapseModes = [false, true] as const;
|
||||
|
||||
for (const field of fields) {
|
||||
for (const dir of dirs) {
|
||||
for (const collapsed of collapseModes) {
|
||||
it(`anchors ${field} ${dir} collapse=${collapsed} buckets to the exact listing offsets`, async () => {
|
||||
const sort: SortSpec[] = [{ field, dir }];
|
||||
const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, collapsed), fetchListing(sort, collapsed)]);
|
||||
|
||||
expect(listing.items.length).toBe(listing.total);
|
||||
expect(bucketsResponse.total).toBe(listing.total);
|
||||
expect(bucketsResponse.buckets.length).toBeGreaterThan(1);
|
||||
|
||||
let previousIndex = -1;
|
||||
for (const bucket of bucketsResponse.buckets) {
|
||||
expect(bucket.index).toBeGreaterThan(previousIndex);
|
||||
previousIndex = bucket.index;
|
||||
expect(bucket.index).toBeLessThan(listing.total);
|
||||
|
||||
const itemAtAnchor = listing.items[bucket.index] as BookCard;
|
||||
const seeded = seededById.get(itemAtAnchor.id);
|
||||
expect(seeded, `listing item ${itemAtAnchor.id} at index ${bucket.index} must be a seeded book`).toBeDefined();
|
||||
expect(expectedBucketKey(seeded!, field, collapsed), `bucket ${bucket.key} -> book ${itemAtAnchor.id} (${seeded!.title})`).toBe(
|
||||
bucket.key,
|
||||
);
|
||||
|
||||
if (bucket.index > 0) {
|
||||
const itemBefore = listing.items[bucket.index - 1] as BookCard;
|
||||
const seededBefore = seededById.get(itemBefore.id);
|
||||
expect(seededBefore).toBeDefined();
|
||||
expect(
|
||||
expectedBucketKey(seededBefore!, field, collapsed),
|
||||
`item before bucket ${bucket.key} must belong to a different bucket`,
|
||||
).not.toBe(bucket.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('rejects ineligible sorts with 400', async () => {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: { sort: [{ field: 'addedAt', dir: 'desc' }], pagination: { page: 0, size: 50 } },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('denies access to users without library access, matching the listing endpoint', async () => {
|
||||
const limitedUser = await createUserAndLogin(ctx);
|
||||
const payload = { sort: [{ field: 'title', dir: 'asc' }], pagination: { page: 0, size: 50 } };
|
||||
|
||||
const [bucketsResponse, listingResponse] = await Promise.all([
|
||||
ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${libraryId}/books/jump-buckets`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload,
|
||||
}),
|
||||
ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${libraryId}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(bucketsResponse.statusCode).toBeGreaterThanOrEqual(403);
|
||||
expect(bucketsResponse.statusCode).toBe(listingResponse.statusCode);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user