Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices (union on first contact, newer server copy when this device is clean). Theme, default translation, verse layout, commentary source and reading position sync too, each with its own timestamp so the newest change to each setting wins. The Reader never moves under you while it is open. Signing out clears this device's marks and forgets sync timestamps. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
297f2c7e84
commit
760c1e568b
20 files changed
+976
-18
No files matched your search
@@ -966,3 +966,175 @@ describe('Reading plan with more days than chapters', () => {
|
||||
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bookmarks and highlights follow the account
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Bookmark and highlight sync', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over });
|
||||
|
||||
// A fake API that keeps the account's marks, like the real server does.
|
||||
function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) {
|
||||
const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] };
|
||||
const fallback = buildFetchMock();
|
||||
const fetchMock = vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||
if (route === 'PUT /api/prefs') {
|
||||
const patch = JSON.parse(opts.body).prefs;
|
||||
server.prefPuts.push(patch);
|
||||
server.prefs = { ...server.prefs, ...patch };
|
||||
return json(200, { prefs: server.prefs });
|
||||
}
|
||||
if (route === 'PUT /api/reader/marks') {
|
||||
const body = JSON.parse(opts.body);
|
||||
server.puts.push(body);
|
||||
server.bookmarks = body.bookmarks;
|
||||
server.highlights = body.highlights;
|
||||
server.updatedAt = Math.max(Date.now(), server.updatedAt + 1);
|
||||
return json(200, { ok: true, updatedAt: server.updatedAt });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return server;
|
||||
}
|
||||
const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null');
|
||||
|
||||
test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => {
|
||||
stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||
expect(local('readerTextHighlights')).toEqual([hl()]);
|
||||
});
|
||||
|
||||
test('marks already on this device are saved to an account that has none yet', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
const server = stubServer();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' });
|
||||
});
|
||||
|
||||
test('marks on both sides are merged, and the merge is saved back', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||
expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||
});
|
||||
|
||||
test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||
localStorage.setItem('reader-marks-synced-at', '100');
|
||||
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({}));
|
||||
expect(server.puts).toHaveLength(0); // nothing to send back
|
||||
});
|
||||
|
||||
test('bookmarking a verse in the Reader saves it to the account', async () => {
|
||||
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 });
|
||||
localStorage.setItem('reader-marks-synced-at', '500');
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
await u.click(await screen.findByText(/Paul, a servant of God/));
|
||||
await u.click(await screen.findByRole('button', { name: /bookmark verse/i }));
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||
expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']);
|
||||
});
|
||||
|
||||
test('signing out clears this device\'s copy so the next person starts clean', async () => {
|
||||
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||
await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull());
|
||||
expect(local('reader-bookmarks')).toEqual({});
|
||||
expect(local('readerTextHighlights')).toEqual([]);
|
||||
expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preference sync', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
function stubPrefsServer(prefs = {}) {
|
||||
const server = { prefs, puts: [] };
|
||||
const fallback = buildFetchMock();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||
if (route === 'PUT /api/prefs') {
|
||||
const patch = JSON.parse(opts.body).prefs;
|
||||
server.puts.push(patch);
|
||||
server.prefs = { ...server.prefs, ...patch };
|
||||
return json(200, { prefs: server.prefs });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
return server;
|
||||
}
|
||||
|
||||
test('signing in on a new device brings your theme, translation and place in the Reader', async () => {
|
||||
stubPrefsServer({
|
||||
theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 },
|
||||
position: { v: { book: 'JHN', chapter: 3 }, at: 900 },
|
||||
});
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark'));
|
||||
expect(localStorage.getItem('reader-translation')).toBe('NASB');
|
||||
expect(localStorage.getItem('reader-layout')).toBe('verses');
|
||||
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('changing the theme is saved to the account', async () => {
|
||||
const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys
|
||||
server.puts.length = 0;
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(screen.getByRole('button', { name: /^sepia$/i }));
|
||||
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||
expect(server.puts[0].theme.v).toBe('sepia');
|
||||
expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed
|
||||
});
|
||||
|
||||
test('the commentary you pick is shared between the Reader and the Study stage', async () => {
|
||||
stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } });
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
|
||||
});
|
||||
|
||||
test('signing out forgets the sync timestamps', async () => {
|
||||
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull());
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user