mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
ac2ad8aac2
Implements pure CSS card stack animations for BookDate recommendations, including smooth exit and advance transitions. Adds local caching of library cover thumbnails during scans, updates database schema and API to serve cached covers, and enhances BookDate to support 'favorites' scope with a book picker modal. Updates admin settings validation logic for Prowlarr, improves indexer state management, and documents new features and backend changes.
122 lines
4.7 KiB
TypeScript
122 lines
4.7 KiB
TypeScript
/**
|
|
* Component: Library Cover Cache API Tests
|
|
* Documentation: documentation/features/library-thumbnail-cache.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
import { GET } from '@/app/api/cache/library/[filename]/route';
|
|
|
|
const fsMock = vi.hoisted(() => ({
|
|
access: vi.fn(),
|
|
readFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
default: fsMock,
|
|
...fsMock,
|
|
}));
|
|
|
|
describe('GET /api/cache/library/[filename]', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('returns 400 for invalid filename with directory traversal', async () => {
|
|
const request = new NextRequest('http://localhost/api/cache/library/../etc/passwd');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: '../etc/passwd' }) });
|
|
|
|
expect(response.status).toBe(400);
|
|
const json = await response.json();
|
|
expect(json.error).toBe('Invalid filename');
|
|
});
|
|
|
|
it('returns 400 for filename with slashes', async () => {
|
|
const request = new NextRequest('http://localhost/api/cache/library/path/to/file.jpg');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'path/to/file.jpg' }) });
|
|
|
|
expect(response.status).toBe(400);
|
|
const json = await response.json();
|
|
expect(json.error).toBe('Invalid filename');
|
|
});
|
|
|
|
it('returns 404 when file does not exist', async () => {
|
|
fsMock.access.mockRejectedValue(new Error('File not found'));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/missing.jpg');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'missing.jpg' }) });
|
|
|
|
expect(response.status).toBe(404);
|
|
const json = await response.json();
|
|
expect(json.error).toBe('File not found');
|
|
});
|
|
|
|
it('serves JPEG images with correct content type', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockResolvedValue(Buffer.from([0xFF, 0xD8, 0xFF]));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/a1b2c3d4e5f6g7h8.jpg');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'a1b2c3d4e5f6g7h8.jpg' }) });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('Content-Type')).toBe('image/jpeg');
|
|
expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
|
|
});
|
|
|
|
it('serves PNG images with correct content type', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockResolvedValue(Buffer.from([0x89, 0x50, 0x4E, 0x47]));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/hash123456789abc.png');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'hash123456789abc.png' }) });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('Content-Type')).toBe('image/png');
|
|
});
|
|
|
|
it('serves WEBP images with correct content type', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockResolvedValue(Buffer.from('webp data'));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/cover.webp');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'cover.webp' }) });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('Content-Type')).toBe('image/webp');
|
|
});
|
|
|
|
it('returns 500 when file read fails', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockRejectedValue(new Error('Read error'));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/error.jpg');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'error.jpg' }) });
|
|
|
|
expect(response.status).toBe(500);
|
|
const json = await response.json();
|
|
expect(json.error).toBe('Internal server error');
|
|
});
|
|
|
|
it('uses octet-stream for unknown file extensions', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockResolvedValue(Buffer.from('unknown data'));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/file.unknown');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'file.unknown' }) });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('Content-Type')).toBe('application/octet-stream');
|
|
});
|
|
|
|
it('handles GIF images correctly', async () => {
|
|
fsMock.access.mockResolvedValue(undefined);
|
|
fsMock.readFile.mockResolvedValue(Buffer.from('GIF89a'));
|
|
|
|
const request = new NextRequest('http://localhost/api/cache/library/animated.gif');
|
|
const response = await GET(request, { params: Promise.resolve({ filename: 'animated.gif' }) });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('Content-Type')).toBe('image/gif');
|
|
});
|
|
});
|