books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests

This commit is contained in:
2026-10-24 01:06:33 +00:00
parent 2ca2d0d5a4
commit 364c7ee132
65 changed files with 25709 additions and 24 deletions

View File

@@ -0,0 +1,157 @@
// Auth gating for the book tracker: files, reader, progress and notes are owner-only and
// answer 404 to everyone else; the public pages never leak files or notes.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
vi.mock('#lib/server/db/index.js', () => ({ db: {} }));
vi.mock('#lib/server/books/library.js', () => ({
coverSrc: vi.fn(() => null),
getWork: vi.fn(),
workFiles: vi.fn(async () => []),
workNotes: vi.fn(async () => []),
workSessions: vi.fn(async () => []),
saveProgress: vi.fn(async () => ({ sessionId: 7 })),
setStatus: vi.fn(),
setRating: vi.fn(),
setManualProgress: vi.fn(),
logManualSession: vi.fn(),
libraryCards: vi.fn(async () => []),
libraryStats: vi.fn(async () => ({}))
}));
vi.mock('#lib/server/books/notes.js', async (original) => ({
...(await original<typeof import('#lib/server/books/notes.js')>()),
createNote: vi.fn(async () => ({ id: 1 })),
updateNote: vi.fn(async () => ({ id: 1 })),
deleteNote: vi.fn(async () => true)
}));
import * as library from '#lib/server/books/library.js';
import * as notes from '#lib/server/books/notes.js';
import { POST as progress } from './[id=integer]/progress/+server';
import { POST as createNote } from './[id=integer]/notes/+server';
import {
PATCH as patchNote,
DELETE as deleteNote
} from './[id=integer]/notes/[noteId=integer]/+server';
import { GET as file } from './files/[id=integer]/+server';
import { load as readerLoad } from './[id=integer]/read/+page.server';
import { actions, load as detailLoad } from './[id=integer]/+page.server';
const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' };
function event(user: object | null, init: RequestInit = {}, params: Record<string, string> = {}) {
return {
locals: { user, session: null },
params: { id: '1', noteId: '1', ...params },
request: new Request('https://site.test/trackers/books/1', init),
url: new URL('https://site.test/trackers/books/1'),
setHeaders: vi.fn()
} as never;
}
async function status(fn: () => unknown) {
try {
const result = await fn();
return result instanceof Response ? result.status : 200;
} catch (e) {
if (isHttpError(e)) return e.status;
throw e;
}
}
const json = (body: unknown, method = 'POST') => ({
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
const work = {
id: 1,
title: 'Dune',
subtitle: null,
authors: ['Frank Herbert'],
year: 1965,
publisher: null,
isbn13: null,
isbn10: null,
language: 'en',
pageCount: 412,
description: null,
tags: [],
coverPath: null,
coverUrl: null,
openlibraryWorkId: null
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never);
});
describe.each([
['anonymous', null],
['another signed-in user', STRANGER]
])('%s', (_, user) => {
it('gets 404 from every owner-only endpoint', async () => {
expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404);
expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404);
expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404);
expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404);
expect(await status(() => file(event(user)))).toBe(404);
expect(await status(() => readerLoad(event(user)))).toBe(404);
for (const action of Object.values(actions))
expect(
await status(() => action(event(user, { method: 'POST', body: new FormData() })))
).toBe(404);
expect(library.saveProgress).not.toHaveBeenCalled();
expect(notes.createNote).not.toHaveBeenCalled();
expect(library.setStatus).not.toHaveBeenCalled();
});
it('sees the public catalogue page without files, notes or sessions', async () => {
const data = (await detailLoad(event(user))) as Record<string, unknown>;
expect(data.owner).toBe(false);
expect(data).not.toHaveProperty('files');
expect(data).not.toHaveProperty('notes');
expect(data).not.toHaveProperty('sessions');
expect(library.workNotes).not.toHaveBeenCalled();
});
});
describe('owner', () => {
it('saves progress and gets a session id back', async () => {
const res = await progress(
event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 }))
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ sessionId: 7 });
expect(library.saveProgress).toHaveBeenCalledWith(
1,
expect.objectContaining({ percent: 1.23, page: 5 })
);
});
it('rejects invalid progress', async () => {
expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400);
expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400);
expect(library.saveProgress).not.toHaveBeenCalled();
});
it('404s progress for a book that does not exist', async () => {
vi.mocked(library.saveProgress).mockResolvedValueOnce(null);
expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404);
});
it('creates highlights and refuses empty notes', async () => {
expect(
await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' }))))
).toBe(201);
expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400);
});
it('sees notes, files and sessions on the detail page', async () => {
const data = (await detailLoad(event(OWNER))) as Record<string, unknown>;
expect(data.owner).toBe(true);
expect(data).toHaveProperty('notes');
expect(data).toHaveProperty('files');
});
it('gets 404 from the reader when the book has no readable file', async () => {
expect(await status(() => readerLoad(event(OWNER)))).toBe(404);
});
});