Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept) - Notes on highlights and bookmarks: synced, merged without loss, in exports and backups - Reader verse panel lists your studies and chunks that cover the verse, and opens them - Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups and the account export; daily prayer reminder through ntfy - TODO.md lists the ideas not built yet Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
87 lines
4.3 KiB
JavaScript
87 lines
4.3 KiB
JavaScript
// @vitest-environment node
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { test, expect } from 'vitest';
|
|
import { cleanMarks } from './readerMarks.js';
|
|
|
|
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
|
|
|
|
test('a non-object body is rejected', () => {
|
|
expect(cleanMarks(null)).toBeNull();
|
|
expect(cleanMarks([])).toBeNull();
|
|
expect(cleanMarks('x')).toBeNull();
|
|
});
|
|
|
|
test('good bookmarks and highlights pass through unchanged', () => {
|
|
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
|
|
expect(cleanMarks(body)).toEqual({ ...body, bookmarkNotes: {} });
|
|
});
|
|
|
|
test('malformed bookmarks are dropped, good ones kept', () => {
|
|
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
|
|
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
|
|
});
|
|
|
|
test('highlights are validated field by field and de-duplicated by id', () => {
|
|
const out = cleanMarks({ highlights: [
|
|
hl(),
|
|
hl({ id: 'h1', verse: 17 }), // duplicate id
|
|
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
|
|
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
|
|
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
|
|
'nope', null,
|
|
hl({ id: 'ok', book: 'gen' }),
|
|
] });
|
|
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
|
|
expect(out.highlights[1].book).toBe('GEN'); // normalised
|
|
});
|
|
|
|
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
|
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
|
|
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
|
});
|
|
|
|
test('the database keeps one row per user and updatedAt only moves forward', async () => {
|
|
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
|
|
const db = await import('./db.js');
|
|
db.initDb();
|
|
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
|
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
|
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 });
|
|
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
|
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
|
expect(t2).toBeGreaterThan(t1);
|
|
const saved = db.getReaderMarks('u1');
|
|
expect(saved.updatedAt).toBe(t2);
|
|
expect(saved.bookmarks).toEqual({});
|
|
expect(saved.highlights).toHaveLength(1);
|
|
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
|
});
|
|
|
|
test('a highlight keeps the translation it was made in and whether it covers the whole verse', async () => {
|
|
const { cleanMarks } = await import('./readerMarks.js');
|
|
const base = { id: 'a', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a', text: 'For God so loved' };
|
|
const out = cleanMarks({ highlights: [
|
|
{ ...base, translation: 'BSB', whole: true },
|
|
{ ...base, id: 'b', translation: 'not a code!!', whole: 'yes' },
|
|
] }).highlights;
|
|
expect(out[0]).toMatchObject({ translation: 'BSB', whole: true });
|
|
expect(out[1].translation).toBeUndefined();
|
|
expect(out[1].whole).toBeUndefined();
|
|
});
|
|
|
|
test('notes on highlights and bookmarks are kept, trimmed, and dropped when empty or malformed', async () => {
|
|
const { cleanMarks } = await import('./readerMarks.js');
|
|
const base = { book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a' };
|
|
const out = cleanMarks({
|
|
bookmarks: { 'JHN-3-16': '#fef08a' },
|
|
bookmarkNotes: { 'JHN-3-16': 'Memorise this', 'JHN-3-17': ' ', 'bad key': 'x', 'ROM-8-1': 5 },
|
|
highlights: [{ ...base, id: 'a', note: 'The heart of the gospel' }, { ...base, id: 'b', note: ' ' }, { ...base, id: 'c', note: 'x'.repeat(900) }],
|
|
});
|
|
expect(out.bookmarkNotes).toEqual({ 'JHN-3-16': 'Memorise this' });
|
|
expect(out.highlights[0].note).toBe('The heart of the gospel');
|
|
expect(out.highlights[1].note).toBeUndefined();
|
|
expect(out.highlights[2].note).toHaveLength(500);
|
|
});
|