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:
157
src/routes/trackers/books/auth.test.ts
Normal file
157
src/routes/trackers/books/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user