mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 15:42:01 +03:00
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
import { logger } from "./logger";
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: vi.fn(),
|
|
}));
|
|
|
|
describe('storage', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.stubGlobal('window', {});
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
});
|
|
vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('isTauri', () => {
|
|
it('returns false when not in Tauri', () => {
|
|
expect(isTauri()).toBe(false);
|
|
});
|
|
|
|
it('returns true when in Tauri', () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
expect(isTauri()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('loadSettings', () => {
|
|
it('loads from localStorage when not in Tauri', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ precision: 5 }));
|
|
const settings = await loadSettings();
|
|
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.settings');
|
|
expect(settings.precision).toBe(5);
|
|
expect(settings.theme).toBe(defaultSettingsData.theme);
|
|
});
|
|
|
|
it('loads via invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify({ precision: 8 }));
|
|
const settings = await loadSettings();
|
|
expect(invoke).toHaveBeenCalledWith('load_file', { name: 'settings.json' });
|
|
expect(settings.precision).toBe(8);
|
|
expect(settings.theme).toBe(defaultSettingsData.theme);
|
|
});
|
|
|
|
it('falls back to defaultSettingsData if localStorage throws', async () => {
|
|
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
const settings = await loadSettings();
|
|
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
expect(settings).toEqual(defaultSettingsData);
|
|
});
|
|
|
|
it('falls back to defaultSettingsData if invoke throws', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
const settings = await loadSettings();
|
|
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
expect(settings).toEqual(defaultSettingsData);
|
|
});
|
|
|
|
it('falls back to defaultSettingsData if localStorage returns null', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
|
const settings = await loadSettings();
|
|
expect(settings).toEqual(defaultSettingsData);
|
|
});
|
|
|
|
it('falls back to defaultSettingsData if JSON is invalid', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
|
const settings = await loadSettings();
|
|
expect(logger.warn).toHaveBeenCalledWith('loadSettings failed', expect.any(Error));
|
|
expect(settings).toEqual(defaultSettingsData);
|
|
});
|
|
});
|
|
|
|
describe('saveSettings', () => {
|
|
it('saves to localStorage when not in Tauri', async () => {
|
|
const settings = { ...defaultSettingsData, precision: 10 };
|
|
await saveSettings(settings);
|
|
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.settings', JSON.stringify(settings, null, 2));
|
|
});
|
|
|
|
it('saves via invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
const settings = { ...defaultSettingsData, precision: 12 };
|
|
await saveSettings(settings);
|
|
expect(invoke).toHaveBeenCalledWith('save_file', { name: 'settings.json', contents: JSON.stringify(settings, null, 2) });
|
|
});
|
|
});
|
|
|
|
describe('loadAppData', () => {
|
|
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
|
|
|
it('loads from localStorage when not in Tauri', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(mockAppData));
|
|
const data = await loadAppData('');
|
|
expect(localStorage.getItem).toHaveBeenCalledWith('summarum.documents');
|
|
expect(data).toEqual(mockAppData);
|
|
});
|
|
|
|
it('loads via invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
|
const data = await loadAppData('');
|
|
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: null });
|
|
expect(data).toEqual(mockAppData);
|
|
});
|
|
|
|
it('loads via invoke when in Tauri with specific dataDir', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue(JSON.stringify(mockAppData));
|
|
const data = await loadAppData('custom_dir');
|
|
expect(invoke).toHaveBeenCalledWith('load_documents', { dir: 'custom_dir' });
|
|
expect(data).toEqual(mockAppData);
|
|
});
|
|
|
|
it('returns null if localStorage throws', async () => {
|
|
vi.mocked(localStorage.getItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
const data = await loadAppData('');
|
|
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
expect(data).toBeNull();
|
|
});
|
|
|
|
it('returns null if invoke throws', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
const data = await loadAppData('');
|
|
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
expect(data).toBeNull();
|
|
});
|
|
|
|
it('returns null if localStorage returns null', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue(null);
|
|
const data = await loadAppData('');
|
|
expect(data).toBeNull();
|
|
});
|
|
|
|
it('returns null if JSON is invalid', async () => {
|
|
vi.mocked(localStorage.getItem).mockReturnValue("invalid json");
|
|
const data = await loadAppData('');
|
|
expect(logger.warn).toHaveBeenCalledWith('loadAppData failed', expect.any(Error));
|
|
expect(data).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('saveAppData & flushAppData', () => {
|
|
const mockAppData = { docs: [], activeId: '1', contents: {} };
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('debounces saves and flushes correctly when not in Tauri', async () => {
|
|
setDataDir('');
|
|
saveAppData(mockAppData);
|
|
|
|
// Should not save immediately
|
|
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
|
|
// Fast forward past debounce time
|
|
vi.advanceTimersByTime(400);
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
|
});
|
|
|
|
it('debounces saves and flushes correctly when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
|
|
setDataDir('custom_dir');
|
|
saveAppData(mockAppData);
|
|
|
|
// Should not save immediately
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
|
|
// Fast forward past debounce time
|
|
vi.runAllTimers();
|
|
|
|
// storage.ts's invoke wrapper does a dynamic import() before calling
|
|
// invoke(), which resolves over real event-loop ticks (module
|
|
// transform/IO) even with fake timers active — poll with real timers
|
|
// until it settles instead of guessing a fixed number of ticks.
|
|
vi.useRealTimers();
|
|
await vi.waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith('save_documents', { dir: 'custom_dir', contents: JSON.stringify(mockAppData) });
|
|
});
|
|
});
|
|
|
|
it('flushAppData clears timer and saves immediately', async () => {
|
|
setDataDir('');
|
|
saveAppData(mockAppData);
|
|
|
|
await flushAppData();
|
|
|
|
expect(localStorage.setItem).toHaveBeenCalledWith('summarum.documents', JSON.stringify(mockAppData));
|
|
|
|
// Advancing timers should not trigger another save
|
|
vi.mocked(localStorage.setItem).mockClear();
|
|
vi.advanceTimersByTime(400);
|
|
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('flushAppData does nothing if no pending data', async () => {
|
|
await flushAppData();
|
|
expect(localStorage.setItem).not.toHaveBeenCalled();
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles localStorage throw gracefully in flushAppData', async () => {
|
|
setDataDir('');
|
|
saveAppData(mockAppData);
|
|
vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error('localStorage error'); });
|
|
|
|
await flushAppData();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
|
});
|
|
|
|
it('handles invoke throw gracefully in flushAppData', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
setDataDir('');
|
|
saveAppData(mockAppData);
|
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
|
|
await flushAppData();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith('saveAppData failed', expect.any(Error));
|
|
});
|
|
});
|
|
|
|
describe('backup and utility functions', () => {
|
|
describe('runBackups', () => {
|
|
it('does nothing when not in Tauri', async () => {
|
|
await runBackups('dir', 10);
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
await runBackups('dir', 10);
|
|
expect(invoke).toHaveBeenCalledWith('run_backups', { dir: 'dir', retentionDays: 10 });
|
|
});
|
|
|
|
it('handles invoke throw gracefully', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
await runBackups('dir', 10);
|
|
expect(logger.warn).toHaveBeenCalledWith('runBackups failed', expect.any(Error));
|
|
});
|
|
});
|
|
|
|
describe('backupDeletedSheet', () => {
|
|
it('does nothing when not in Tauri', async () => {
|
|
await backupDeletedSheet('dir', 'title', 'contents');
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does nothing if contents are empty', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
await backupDeletedSheet('dir', 'title', ' ');
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls invoke when in Tauri with valid contents', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
await backupDeletedSheet('dir', 'title', 'contents');
|
|
expect(invoke).toHaveBeenCalledWith('backup_deleted_sheet', { dir: 'dir', title: 'title', contents: 'contents' });
|
|
});
|
|
|
|
it('handles invoke throw gracefully', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockRejectedValue(new Error('invoke error'));
|
|
await backupDeletedSheet('dir', 'title', 'contents');
|
|
expect(logger.warn).toHaveBeenCalledWith('backupDeletedSheet failed', expect.any(Error));
|
|
});
|
|
});
|
|
|
|
describe('openBackupsFolder', () => {
|
|
it('does nothing when not in Tauri', async () => {
|
|
await openBackupsFolder('dir');
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
await openBackupsFolder('dir');
|
|
expect(invoke).toHaveBeenCalledWith('open_backups_folder', { dir: 'dir' });
|
|
});
|
|
});
|
|
|
|
describe('chooseFolder', () => {
|
|
it('returns null when not in Tauri', async () => {
|
|
expect(await chooseFolder()).toBeNull();
|
|
});
|
|
|
|
it('returns path when in Tauri and folder is chosen', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue('chosen_path');
|
|
expect(await chooseFolder()).toBe('chosen_path');
|
|
expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
|
|
});
|
|
|
|
it('returns null when in Tauri and folder choice is cancelled', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue(null);
|
|
expect(await chooseFolder()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('dataDirHasDocuments', () => {
|
|
it('returns false when not in Tauri', async () => {
|
|
expect(await dataDirHasDocuments('dir')).toBe(false);
|
|
});
|
|
|
|
it('calls invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
vi.mocked(invoke).mockResolvedValue(true);
|
|
expect(await dataDirHasDocuments('dir')).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith('data_dir_has_documents', { dir: 'dir' });
|
|
});
|
|
});
|
|
|
|
describe('migrateDataDir', () => {
|
|
it('does nothing when not in Tauri', async () => {
|
|
await migrateDataDir('old', 'new', 'move');
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
await migrateDataDir('old', 'new', 'move');
|
|
expect(invoke).toHaveBeenCalledWith('migrate_data_dir', { oldDir: 'old', newDir: 'new', strategy: 'move' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fetchRates', () => {
|
|
it('uses invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
const mockRates = { date: '2024-01-01', rates: { EUR: 0.85 }, fetchedAt: 12345 };
|
|
vi.mocked(invoke).mockResolvedValue(mockRates);
|
|
|
|
const result = await fetchRates(true);
|
|
expect(invoke).toHaveBeenCalledWith('fetch_rates', { force: true });
|
|
expect(result).toEqual(mockRates);
|
|
});
|
|
|
|
it('uses fetch when not in Tauri', async () => {
|
|
const mockResponse = { result: 'success', time_last_update_utc: '2024-01-01', rates: { EUR: 0.85 } };
|
|
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
|
|
const result = await fetchRates();
|
|
expect(fetch).toHaveBeenCalledWith('https://open.er-api.com/v6/latest/USD');
|
|
expect(result?.date).toBe('2024-01-01');
|
|
expect(result?.rates).toEqual({ EUR: 0.85 });
|
|
expect(result?.fetchedAt).toBeTypeOf('number');
|
|
});
|
|
|
|
it('returns null if fetch fails', async () => {
|
|
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
|
const result = await fetchRates();
|
|
expect(logger.warn).toHaveBeenCalledWith('fetchRates failed', expect.any(Error));
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null if API result is not success', async () => {
|
|
const mockResponse = { result: 'error', 'error-type': 'unsupported-code' };
|
|
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
const result = await fetchRates();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('fetchHistoricalRates', () => {
|
|
it('uses invoke when in Tauri', async () => {
|
|
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
|
|
const mockRates = { EUR: 0.85 };
|
|
vi.mocked(invoke).mockResolvedValue(mockRates);
|
|
|
|
const result = await fetchHistoricalRates('2024-01-01');
|
|
expect(invoke).toHaveBeenCalledWith('fetch_historical_rates', { date: '2024-01-01' });
|
|
expect(result).toEqual(mockRates);
|
|
});
|
|
|
|
it('uses fetch when not in Tauri and formats rates correctly', async () => {
|
|
const mockResponse = { rates: { EUR: 0.85, GBP: 0.75, JPY: -1 } }; // negative value should be ignored
|
|
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
|
|
const result = await fetchHistoricalRates('2024-01-01');
|
|
expect(fetch).toHaveBeenCalledWith('https://api.frankfurter.dev/v1/2024-01-01?from=USD');
|
|
expect(result).toEqual({ USD: 1, EUR: 0.85, GBP: 0.75 }); // USD: 1 added, negative ignored
|
|
});
|
|
|
|
it('returns null if fetch fails', async () => {
|
|
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
|
const result = await fetchHistoricalRates('2024-01-01');
|
|
expect(logger.warn).toHaveBeenCalledWith('fetchHistoricalRates failed', '2024-01-01', expect.any(Error));
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('returns null if rates missing in response', async () => {
|
|
const mockResponse = { amount: 1, base: 'USD', date: '2024-01-01' }; // missing rates
|
|
vi.mocked(fetch).mockResolvedValue({ json: vi.fn().mockResolvedValue(mockResponse) } as unknown as Response);
|
|
const result = await fetchHistoricalRates('2024-01-01');
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|