Files
readest/apps/readest-app/src/__tests__/services/app-service.test.ts
T
Huang Xin acf2b165f3 fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720) (#4730)
* fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720)

resolveFilePath joined `${prefix}/${path}` unconditionally. For base 'None'
(in-place / external books, whose filePath lives outside Books/<hash>/) the
prefix is empty, so an already-absolute source path became `/C:\Users\...`
on Windows (and `//Users/...` on POSIX). The native upload guard added in
#4639 (transfer_file.rs `ensure_path_allowed`) then rejected that malformed
path as "permission denied: path not in filesystem scope", so uploading a
folder-imported book failed on Windows.

Return the path verbatim when the prefix is empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(library): use btn-contrast for the Import-from-folder confirm button (#4720)

Aligns the dialog's confirm CTA with the sibling import dialogs
(ImportFromUrlDialog, FailedImportsDialog), which already use the
theme-neutral, e-ink-correct btn-contrast instead of the colored
btn-primary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:59 +02:00

393 lines
14 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { BaseDir, FileSystem, ResolvedPath } from '@/types/system';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
// Mock all service dependencies
vi.mock('@/services/settingsService', () => ({
getDefaultViewSettings: vi.fn().mockReturnValue({ theme: 'light' }),
loadSettings: vi.fn().mockResolvedValue({
localBooksDir: 'books-dir',
migrationVersion: 99999999,
}),
saveSettings: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/libraryService', () => ({
loadLibraryBooks: vi.fn().mockResolvedValue([{ title: 'Book1' }]),
saveLibraryBooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/bookService', () => ({
getCoverImageUrl: vi.fn().mockReturnValue('cover-url'),
getCoverImageBlobUrl: vi.fn().mockResolvedValue('blob:cover'),
getCachedImageUrl: vi.fn().mockResolvedValue('cached:url'),
generateCoverImageUrl: vi.fn().mockResolvedValue('generated:url'),
updateCoverImage: vi.fn().mockResolvedValue(undefined),
importBook: vi.fn().mockResolvedValue(null),
exportBook: vi.fn().mockResolvedValue(true),
refreshBookMetadata: vi.fn().mockResolvedValue(true),
isBookAvailable: vi.fn().mockResolvedValue(true),
getBookFileSize: vi.fn().mockResolvedValue(1024),
loadBookContent: vi.fn().mockResolvedValue({ html: '<p>test</p>' }),
loadBookConfig: vi.fn().mockResolvedValue({ updatedAt: 0 }),
fetchBookDetails: vi.fn().mockResolvedValue({ title: 'Test' }),
saveBookConfig: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/cloudService', () => ({
deleteBook: vi.fn().mockResolvedValue(undefined),
uploadFileToCloud: vi.fn().mockResolvedValue('url'),
uploadBook: vi.fn().mockResolvedValue(undefined),
downloadCloudFile: vi.fn().mockResolvedValue(undefined),
downloadBookCovers: vi.fn().mockResolvedValue(undefined),
downloadBook: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/fontService', () => ({
importFont: vi.fn().mockResolvedValue({ name: 'Font', path: '/f' }),
deleteFont: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/imageService', () => ({
importImage: vi.fn().mockResolvedValue({ id: 'img', url: '/img' }),
deleteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/utils/book', () => ({
getLibraryFilename: vi.fn().mockReturnValue('library.json'),
getLibraryBackupFilename: vi.fn().mockReturnValue('library_backup.json'),
}));
vi.mock('@/utils/misc', async (importOriginal) => {
const original = await importOriginal<Record<string, unknown>>();
return {
...original,
getOSPlatform: vi.fn().mockReturnValue('macos'),
};
});
import { BaseAppService } from '@/services/appService';
import * as Settings from '@/services/settingsService';
import * as BookSvc from '@/services/bookService';
// Concrete test implementation of BaseAppService
class TestAppService extends BaseAppService {
fs: FileSystem;
constructor(fs: FileSystem) {
super();
this.fs = fs;
}
resolvePath(fp: string, base: BaseDir): ResolvedPath {
return this.fs.resolvePath(fp, base);
}
async init() {
await this.loadSettings();
}
async setCustomRootDir(_dir: string) {}
async selectDirectory(): Promise<string> {
return '/test/dir';
}
async selectFiles(): Promise<string[]> {
return ['/test/file.epub'];
}
async saveFile(): Promise<boolean> {
return true;
}
async saveImageToGallery(): Promise<boolean> {
return true;
}
async ask(): Promise<boolean> {
return true;
}
async openDatabase(
_schema: SchemaType,
_path: string,
_base: BaseDir,
_opts?: DatabaseOpts,
): Promise<DatabaseService> {
return {} as DatabaseService;
}
}
function createMockFs(): FileSystem {
return {
resolvePath: vi.fn().mockReturnValue({
baseDir: 0,
basePrefix: async () => '/base',
fp: 'path',
base: 'Books' as BaseDir,
}),
getURL: vi.fn().mockReturnValue('url'),
getBlobURL: vi.fn().mockResolvedValue('blob:url'),
getImageURL: vi.fn().mockResolvedValue('image:url'),
openFile: vi.fn().mockResolvedValue(new File(['content'], 'test.epub')),
copyFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue('content'),
writeFile: vi.fn().mockResolvedValue(undefined),
removeFile: vi.fn().mockResolvedValue(undefined),
readDir: vi.fn().mockResolvedValue([{ path: 'a.txt', size: 10 }]),
createDir: vi.fn().mockResolvedValue(undefined),
removeDir: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
stats: vi.fn().mockResolvedValue({
isFile: true,
isDirectory: false,
size: 100,
mtime: null,
atime: null,
birthtime: null,
}),
getPrefix: vi.fn().mockResolvedValue('/base/books'),
};
}
describe('BaseAppService', () => {
let service: TestAppService;
let mockFs: FileSystem;
beforeEach(() => {
vi.clearAllMocks();
mockFs = createMockFs();
service = new TestAppService(mockFs);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('default properties', () => {
test('has correct default platform flags', () => {
expect(service.appPlatform).toBe('tauri');
expect(service.isMobile).toBe(false);
expect(service.isMacOSApp).toBe(false);
expect(service.isAndroidApp).toBe(false);
expect(service.isIOSApp).toBe(false);
expect(service.isDesktopApp).toBe(false);
expect(service.hasTrafficLight).toBe(false);
expect(service.hasWindow).toBe(false);
expect(service.hasHaptics).toBe(false);
expect(service.hasUpdater).toBe(false);
expect(service.isEink).toBe(false);
expect(service.localBooksDir).toBe('');
});
});
describe('prepareBooksDir', () => {
test('sets localBooksDir from fs.getPrefix', async () => {
await service.prepareBooksDir();
expect(mockFs.getPrefix).toHaveBeenCalledWith('Books');
expect(service.localBooksDir).toBe('/base/books');
});
});
describe('file operations', () => {
test('openFile delegates to fs', async () => {
await service.openFile('test.epub', 'Books');
expect(mockFs.openFile).toHaveBeenCalledWith('test.epub', 'Books');
});
test('copyFile delegates to fs', async () => {
await service.copyFile('src.epub', 'None', 'dst.epub', 'Books');
expect(mockFs.copyFile).toHaveBeenCalledWith('src.epub', 'None', 'dst.epub', 'Books');
});
test('readFile delegates to fs', async () => {
const result = await service.readFile('test.txt', 'Data', 'text');
expect(mockFs.readFile).toHaveBeenCalledWith('test.txt', 'Data', 'text');
expect(result).toBe('content');
});
test('writeFile delegates to fs', async () => {
await service.writeFile('test.txt', 'Data', 'hello');
expect(mockFs.writeFile).toHaveBeenCalledWith('test.txt', 'Data', 'hello');
});
test('createDir delegates to fs', async () => {
await service.createDir('newdir', 'Books');
expect(mockFs.createDir).toHaveBeenCalledWith('newdir', 'Books', true);
});
test('createDir respects recursive parameter', async () => {
await service.createDir('newdir', 'Books', false);
expect(mockFs.createDir).toHaveBeenCalledWith('newdir', 'Books', false);
});
test('deleteFile delegates to fs.removeFile', async () => {
await service.deleteFile('old.txt', 'Data');
expect(mockFs.removeFile).toHaveBeenCalledWith('old.txt', 'Data');
});
test('deleteDir delegates to fs.removeDir', async () => {
await service.deleteDir('olddir', 'Books');
expect(mockFs.removeDir).toHaveBeenCalledWith('olddir', 'Books', true);
});
test('deleteDir respects recursive parameter', async () => {
await service.deleteDir('olddir', 'Books', false);
expect(mockFs.removeDir).toHaveBeenCalledWith('olddir', 'Books', false);
});
test('exists delegates to fs', async () => {
const result = await service.exists('test.txt', 'Data');
expect(mockFs.exists).toHaveBeenCalledWith('test.txt', 'Data');
expect(result).toBe(true);
});
test('readDirectory delegates to fs.readDir', async () => {
const result = await service.readDirectory('dir', 'Books');
expect(mockFs.readDir).toHaveBeenCalledWith('dir', 'Books');
expect(result).toEqual([{ path: 'a.txt', size: 10 }]);
});
test('getImageURL delegates to fs', async () => {
const result = await service.getImageURL('/img.png');
expect(mockFs.getImageURL).toHaveBeenCalledWith('/img.png');
expect(result).toBe('image:url');
});
});
describe('resolveFilePath', () => {
test('combines prefix with path', async () => {
const result = await service.resolveFilePath('test.json', 'Data');
expect(result).toBe('/base/books/test.json');
});
test('returns just prefix when path is empty', async () => {
const result = await service.resolveFilePath('', 'Data');
expect(result).toBe('/base/books');
});
// base 'None' carries a complete, absolute source path (in-place /
// external books point `book.filePath` outside Books/<hash>/). Its prefix
// is empty, so the path must be returned verbatim. Prepending a separator
// produced `/C:\Users\...` on Windows (and `//Users/...` on POSIX), which
// the native upload guard (transfer_file.rs `ensure_path_allowed`) rejects
// as "path not in filesystem scope" — issue #4720.
test('returns an absolute path unchanged when the prefix is empty (base None)', async () => {
vi.mocked(mockFs.getPrefix).mockResolvedValue('');
const windowsPath = "C:\\Users\\me\\Documents\\books\\Alice's Adventures.epub";
expect(await service.resolveFilePath(windowsPath, 'None')).toBe(windowsPath);
const posixPath = '/Users/me/Documents/books/Alice.epub';
expect(await service.resolveFilePath(posixPath, 'None')).toBe(posixPath);
});
});
describe('settings', () => {
test('loadSettings delegates and updates localBooksDir', async () => {
const settings = await service.loadSettings();
expect(Settings.loadSettings).toHaveBeenCalled();
expect(settings.localBooksDir).toBe('books-dir');
expect(service.localBooksDir).toBe('books-dir');
});
test('saveSettings delegates to settingsService', async () => {
const settings = { localBooksDir: 'dir' } as Parameters<typeof service.saveSettings>[0];
await service.saveSettings(settings);
expect(Settings.saveSettings).toHaveBeenCalledWith(mockFs, settings);
});
test('getDefaultViewSettings delegates', () => {
const result = service.getDefaultViewSettings();
expect(Settings.getDefaultViewSettings).toHaveBeenCalled();
expect(result).toEqual({ theme: 'light' });
});
});
describe('cover image operations', () => {
const mockBook = { hash: 'h1', title: 'Book' } as Parameters<
typeof service.getCoverImageUrl
>[0];
test('getCoverImageUrl delegates', () => {
const result = service.getCoverImageUrl(mockBook);
expect(BookSvc.getCoverImageUrl).toHaveBeenCalled();
expect(result).toBe('cover-url');
});
test('getCoverImageBlobUrl delegates', async () => {
const result = await service.getCoverImageBlobUrl(mockBook);
expect(BookSvc.getCoverImageBlobUrl).toHaveBeenCalled();
expect(result).toBe('blob:cover');
});
test('getCachedImageUrl delegates', async () => {
const result = await service.getCachedImageUrl('http://img.com/x.png');
expect(BookSvc.getCachedImageUrl).toHaveBeenCalled();
expect(result).toBe('cached:url');
});
test('generateCoverImageUrl delegates', async () => {
const result = await service.generateCoverImageUrl(mockBook);
expect(BookSvc.generateCoverImageUrl).toHaveBeenCalled();
expect(result).toBe('generated:url');
});
});
describe('library operations', () => {
test('loadLibraryBooks delegates', async () => {
const result = await service.loadLibraryBooks();
expect(result).toEqual([{ title: 'Book1' }]);
});
test('saveLibraryBooks delegates', async () => {
await service.saveLibraryBooks([]);
});
});
describe('runMigrations', () => {
test('runs migration when version is lower', async () => {
vi.mocked(mockFs.exists).mockResolvedValue(true);
vi.mocked(mockFs.readFile).mockResolvedValue('library data');
// Access protected method through init -> loadSettings -> runMigrations flow
vi.mocked(Settings.loadSettings).mockResolvedValue({
localBooksDir: 'dir',
migrationVersion: 0,
} as ReturnType<typeof Settings.loadSettings> extends Promise<infer T> ? T : never);
// Create a service that calls runMigrations in init
class MigratingService extends TestAppService {
override async init() {
await this.loadSettings();
await this.runMigrations(0);
}
}
const svc = new MigratingService(mockFs);
await svc.init();
// Migration should have checked for the old backup file
expect(mockFs.exists).toHaveBeenCalledWith('library_backup.json', 'Books');
});
test('skips migration when version is current', async () => {
class MigratingService extends TestAppService {
override async init() {
await this.runMigrations(99999999);
}
}
const svc = new MigratingService(mockFs);
await svc.init();
// Should not have tried to read the backup file
expect(mockFs.readFile).not.toHaveBeenCalledWith('library_backup.json', 'Books', 'text');
});
});
});