mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add notification toasts and surface persistence failures instead of failing silently
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSnippet, type Snippet } from '@core/snippet';
|
||||
|
||||
/**
|
||||
* The persistence subscribers are the subtlest glue in the app: a debounced
|
||||
* auto-save and a reference-diff write-through that decides what to upsert vs.
|
||||
* delete. They run against a mocked IndexedDB adapter so we assert the *wiring*
|
||||
* (when each adapter call fires, with what, and that a rejection surfaces) with
|
||||
* no real database.
|
||||
*/
|
||||
|
||||
// Mock the adapter, but keep the real StorageQuotaError so the error-mapping
|
||||
// path (instanceof check) behaves exactly as in production.
|
||||
vi.mock('../infrastructure/snippet-store', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../infrastructure/snippet-store')>();
|
||||
return { ...actual, saveSnippet: vi.fn(), deleteSnippet: vi.fn() };
|
||||
});
|
||||
|
||||
import { saveSnippet, deleteSnippet, StorageQuotaError } from '../infrastructure/snippet-store';
|
||||
import { useNotificationStore } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { AUTOSAVE_DEBOUNCE_MS, wirePersistence } from './persistence';
|
||||
|
||||
const save = vi.mocked(saveSnippet);
|
||||
const del = vi.mocked(deleteSnippet);
|
||||
|
||||
let teardown: () => void = () => {};
|
||||
|
||||
/** Flush pending microtasks (e.g. a `.catch` on a rejected adapter promise). */
|
||||
const flush = () => Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
function snippetWith(id: string, spec: string): Snippet {
|
||||
return createSnippet({ id, spec });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
save.mockReset().mockResolvedValue(undefined);
|
||||
del.mockReset().mockResolvedValue(undefined);
|
||||
useNotificationStore.getState().clear();
|
||||
useSnippetStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
teardown();
|
||||
teardown = () => {};
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('wireDraftAutoSave', () => {
|
||||
it('persists a settled, valid edit after the debounce', async () => {
|
||||
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
|
||||
useSnippetStore.getState().updateDraft('{"a":2}');
|
||||
expect(save).not.toHaveBeenCalled(); // nothing yet — waiting for the pause
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save.mock.calls[0][0]).toMatchObject({ id: 'a', draftSpec: '{"a":2}' });
|
||||
});
|
||||
|
||||
it('does not persist a half-typed, unparseable buffer', async () => {
|
||||
useSnippetStore.getState().hydrate([snippetWith('a', '{"a":1}')]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
|
||||
useSnippetStore.getState().updateDraft('{ "a":');
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireWriteThrough', () => {
|
||||
it('persists a newly created snippet', () => {
|
||||
useSnippetStore.getState().hydrate([]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
|
||||
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save.mock.calls[0][0]).toMatchObject({ id: 'n' });
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a removed snippet without re-saving the survivors', () => {
|
||||
useSnippetStore.getState().hydrate([snippetWith('a', '{}'), snippetWith('b', '{}')]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
|
||||
useSnippetStore.getState().removeSnippet('a');
|
||||
|
||||
expect(del).toHaveBeenCalledTimes(1);
|
||||
expect(del).toHaveBeenCalledWith('a');
|
||||
// 'b' kept its object reference through the filter, so it is not rewritten.
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores state changes that do not touch the snippets array', () => {
|
||||
useSnippetStore.getState().hydrate([snippetWith('a', '{}')]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
|
||||
useSnippetStore.getState().setEditorView('published');
|
||||
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(del).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a failed save as an error notification instead of losing it silently', async () => {
|
||||
useSnippetStore.getState().hydrate([]);
|
||||
teardown = wirePersistence();
|
||||
save.mockClear();
|
||||
save.mockRejectedValueOnce(new StorageQuotaError());
|
||||
|
||||
useSnippetStore.getState().createSnippet({ id: 'n', spec: '{}' });
|
||||
await flush();
|
||||
|
||||
const notes = useNotificationStore.getState().notifications;
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toMatchObject({ kind: 'error', title: 'Storage full' });
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@
|
||||
*/
|
||||
|
||||
import { deleteSnippet, saveSnippet } from '../infrastructure/snippet-store';
|
||||
import { storageErrorNotification } from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** Delay before a settled edit is committed to the stored draft (spec §03B). */
|
||||
@@ -40,12 +42,20 @@ function wireWriteThrough(): Unsubscribe {
|
||||
const prev = prevSnippets;
|
||||
prevSnippets = next;
|
||||
|
||||
// A failed write must surface, not vanish — these promises are otherwise
|
||||
// fire-and-forget, so a rejection becomes a toast rather than silent loss
|
||||
// (spec §10 → "told when a save fails rather than losing data silently").
|
||||
for (const old of prev) {
|
||||
if (!next.some((n) => n.id === old.id)) void deleteSnippet(old.id);
|
||||
if (!next.some((n) => n.id === old.id)) {
|
||||
deleteSnippet(old.id).catch((err) => notify(storageErrorNotification('delete', err)));
|
||||
}
|
||||
}
|
||||
for (const n of next) {
|
||||
const old = prev.find((p) => p.id === n.id);
|
||||
if (old !== n) void saveSnippet(n); // new, or a changed reference
|
||||
if (old !== n) {
|
||||
// new, or a changed reference
|
||||
saveSnippet(n).catch((err) => notify(storageErrorNotification('save', err)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
* the UI renders immediately and fills in when hydration completes.
|
||||
*/
|
||||
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { createSnippet, type Snippet } from '@core/snippet';
|
||||
import { loadSnippets, saveSnippet } from '../infrastructure/snippet-store';
|
||||
import { storageErrorNotification } from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { wirePersistence } from './persistence';
|
||||
|
||||
@@ -18,11 +20,31 @@ export async function initApp(): Promise<void> {
|
||||
if (started) return; // idempotent — guard against double-invocation (StrictMode)
|
||||
started = true;
|
||||
|
||||
let snippets = await loadSnippets();
|
||||
// If storage can't be opened (private-browsing, blocked storage), don't fail
|
||||
// startup silently: warn, then run this session in memory so the app is still
|
||||
// usable. `storageOk` gates the seed-save below — there's no point trying to
|
||||
// persist a seed into storage we just failed to read.
|
||||
let snippets: Snippet[] = [];
|
||||
let storageOk = true;
|
||||
try {
|
||||
snippets = await loadSnippets();
|
||||
} catch (err) {
|
||||
storageOk = false;
|
||||
notify(storageErrorNotification('load', err));
|
||||
}
|
||||
|
||||
if (snippets.length === 0) {
|
||||
const sample = createSnippet();
|
||||
await saveSnippet(sample); // ensure the seed survives even before the first edit
|
||||
snippets = [sample];
|
||||
if (storageOk) {
|
||||
// Ensure the seed survives even before the first edit; a failure here is
|
||||
// surfaced, not swallowed.
|
||||
try {
|
||||
await saveSnippet(sample);
|
||||
} catch (err) {
|
||||
notify(storageErrorNotification('save', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useSnippetStore.getState().hydrate(snippets);
|
||||
|
||||
Reference in New Issue
Block a user