diff --git a/server/db.test.ts b/server/db.test.ts new file mode 100644 index 0000000..b78bba8 --- /dev/null +++ b/server/db.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, setSystemTime } from 'bun:test' +import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign' +import { createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData } from './db' + +afterEach(() => { + setSystemTime() +}) + +describe('db', () => { + it('creates a book with empty data', () => { + const db = openDb(':memory:') + const created = createBook(db, '示例整本') + + expect(created.name).toBe('示例整本') + expect(created.data.designs).toEqual([]) + expect(created.data.schemaVersion).toBe(1) + }) + + it('retrieves a created book by id', () => { + const db = openDb(':memory:') + const created = createBook(db, '示例整本') + + expect(getBook(db, created.id)).toEqual(created) + }) + + it('returns null for a missing book', () => { + const db = openDb(':memory:') + expect(getBook(db, 'missing')).toBeNull() + }) + + it('lists books ordered by most recently updated, with lesson counts', () => { + const db = openDb(':memory:') + setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + const first = createBook(db, '第一本') + setSystemTime(new Date('2026-01-02T00:00:00.000Z')) + const second = createBook(db, '第二本') + + const data = createEmptyBook() + data.designs.push(createEmptyTeachingDesign('1.md')) + setSystemTime(new Date('2026-01-03T00:00:00.000Z')) + saveBookData(db, first.id, data) + + const books = listBooks(db) + + expect(books.map((book) => book.id)).toEqual([first.id, second.id]) + expect(books[0]?.lessonCount).toBe(1) + expect(books[1]?.lessonCount).toBe(0) + }) + + it('saves book data and updates updated_at', () => { + const db = openDb(':memory:') + const created = createBook(db, '示例整本') + const data = createEmptyBook() + data.cover.courseName = 'Web 前端开发' + + setSystemTime(new Date('2026-02-01T00:00:00.000Z')) + const result = saveBookData(db, created.id, data) + + expect(result).toEqual({ id: created.id, name: '示例整本', updatedAt: '2026-02-01T00:00:00.000Z' }) + expect(getBook(db, created.id)?.data.cover.courseName).toBe('Web 前端开发') + }) + + it('returns null when saving data for a missing book', () => { + const db = openDb(':memory:') + expect(saveBookData(db, 'missing', createEmptyBook())).toBeNull() + }) + + it('renames a book without changing updated_at', () => { + const db = openDb(':memory:') + const created = createBook(db, '旧名称') + + const result = renameBook(db, created.id, '新名称') + + expect(result).toEqual({ id: created.id, name: '新名称', updatedAt: created.updatedAt }) + expect(getBook(db, created.id)?.name).toBe('新名称') + }) + + it('returns null when renaming a missing book', () => { + const db = openDb(':memory:') + expect(renameBook(db, 'missing', '新名称')).toBeNull() + }) + + it('deletes a book', () => { + const db = openDb(':memory:') + const created = createBook(db, '示例整本') + + expect(deleteBook(db, created.id)).toBe(true) + expect(getBook(db, created.id)).toBeNull() + }) + + it('returns false when deleting a missing book', () => { + const db = openDb(':memory:') + expect(deleteBook(db, 'missing')).toBe(false) + }) +}) diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..ff22733 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,117 @@ +import { Database } from 'bun:sqlite' +import { createEmptyBook, type TeachingBook } from '../src/domain/teachingDesign' + +export interface BookSummary { + id: string + name: string + updatedAt: string + lessonCount: number +} + +export interface BookRecord { + id: string + name: string + updatedAt: string + data: TeachingBook +} + +export interface BookMeta { + id: string + name: string + updatedAt: string +} + +interface BookRow { + id: string + name: string + data: string + updated_at: string +} + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS books ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) +` + +export function openDb(path: string): Database { + const db = new Database(path) + db.run(SCHEMA) + return db +} + +export function listBooks(db: Database): BookSummary[] { + const rows = db + .query('SELECT id, name, data, updated_at FROM books ORDER BY updated_at DESC') + .all() + + return rows.map((row) => ({ + id: row.id, + name: row.name, + updatedAt: row.updated_at, + lessonCount: (JSON.parse(row.data) as TeachingBook).designs.length, + })) +} + +export function createBook(db: Database, name: string): BookRecord { + const id = crypto.randomUUID() + const now = new Date().toISOString() + const data = createEmptyBook() + data.updatedAt = now + + db.run('INSERT INTO books (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [ + id, + name, + JSON.stringify(data), + now, + now, + ]) + + return { id, name, updatedAt: now, data } +} + +export function getBook(db: Database, id: string): BookRecord | null { + const row = db + .query('SELECT id, name, data, updated_at FROM books WHERE id = ?') + .get(id) + if (!row) return null + + return { + id: row.id, + name: row.name, + updatedAt: row.updated_at, + data: JSON.parse(row.data) as TeachingBook, + } +} + +export function saveBookData(db: Database, id: string, data: TeachingBook): BookMeta | null { + const existing = db + .query<{ name: string }, [string]>('SELECT name FROM books WHERE id = ?') + .get(id) + if (!existing) return null + + const now = new Date().toISOString() + db.run('UPDATE books SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(data), now, id]) + + return { id, name: existing.name, updatedAt: now } +} + +export function renameBook(db: Database, id: string, name: string): BookMeta | null { + const existing = db + .query<{ updated_at: string }, [string]>('SELECT updated_at FROM books WHERE id = ?') + .get(id) + if (!existing) return null + + db.run('UPDATE books SET name = ? WHERE id = ?', [name, id]) + + return { id, name, updatedAt: existing.updated_at } +} + +export function deleteBook(db: Database, id: string): boolean { + const result = db.run('DELETE FROM books WHERE id = ?', [id]) + return result.changes > 0 +}