Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147import { describe, it, expect, beforeEach, afterEach } from "bun:test";import { Database as DatabaseSync } from "bun:sqlite";import { Kysely, SqliteDialect, sql } from "kysely";
import { wrapBunSqliteForKysely } from "../bun-sqlite-kysely";import { migrateToLatest, type Database, type DatabaseSchema } from "../db";import { ftsMatchQuery, isUsefulFtsQuery } from "./ftsQuery";
/** * Built by hand rather than via `createDb`, matching the other DB suites: * `createDb` reads `env`, and another test file in the shared process mocks * that module, which turns its PRAGMA values into `undefined`. */async function createTestDb(): Promise<Database> { const sqlite = new DatabaseSync(":memory:"); sqlite.exec("PRAGMA journal_mode = WAL"); const db = new Kysely<DatabaseSchema>({ dialect: new SqliteDialect({ database: wrapBunSqliteForKysely(sqlite) }), }); await migrateToLatest(db, sqlite); return db;}
describe("ftsMatchQuery", () => { it("wraps input as a phrase with a trailing prefix", () => { // Phrase keeps the words adjacent, `*` keeps type-ahead working. expect(ftsMatchQuery("the girl")).toBe('"the girl"*'); expect(ftsMatchQuery("harry pot")).toBe('"harry pot"*'); });
it("collapses surrounding and internal whitespace", () => { expect(ftsMatchQuery(" dune messiah ")).toBe('"dune messiah"*'); });
it("doubles embedded quotes rather than emitting invalid syntax", () => { // An unescaped quote is an FTS5 syntax error, which throws instead of // returning nothing. expect(ftsMatchQuery('the "great" gatsby')).toBe('"the ""great"" gatsby"*'); });
it("returns null when there is nothing to match", () => { expect(ftsMatchQuery("")).toBeNull(); expect(ftsMatchQuery(" ")).toBeNull(); // Pure punctuation tokenizes to nothing and would match everything. expect(ftsMatchQuery("!!! ---")).toBeNull(); });
it("keeps queries that contain any letter or digit", () => { expect(ftsMatchQuery("1984")).toBe('"1984"*'); expect(ftsMatchQuery("café")).toBe('"café"*'); });});
describe("isUsefulFtsQuery", () => { it("rejects single characters, which prefix-match the whole corpus", () => { expect(isUsefulFtsQuery("a")).toBe(false); expect(isUsefulFtsQuery(" a ")).toBe(false); expect(isUsefulFtsQuery("it")).toBe(true); });});
describe("hive_book_fts (migration 019)", () => { let db: Database;
const search = async (input: string) => { const match = ftsMatchQuery(input)!; const rows = await sql<{ id: string }>` SELECT b.id FROM hive_book_fts f JOIN hive_book b ON b.rowid = f.rowid WHERE hive_book_fts MATCH ${match} ORDER BY b.ratingsCount DESC `.execute(db); return rows.rows.map((r) => r.id); };
const insert = (id: string, title: string, authors: string, ratingsCount = 0) => db .insertInto("hive_book") .values({ id: id as never, title, rawTitle: title, authors, ratingsCount, source: "goodreads", thumbnail: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as never) .execute();
beforeEach(async () => { db = await createTestDb(); await insert("bk_1", "The Girl with the Dragon Tattoo", "Stieg Larsson", 100); await insert("bk_2", "The Diary of a Young Girl", "Anne Frank", 999); await insert("bk_3", "The Hobbit", "J.R.R. Tolkien", 50); });
afterEach(async () => { await db.destroy(); });
it("requires phrase adjacency, matching the LIKE it replaced", async () => { // With an implicit AND, "the girl" would rank The Diary of a Young Girl // first purely on ratingsCount. The phrase form excludes it. expect(await search("the girl")).toEqual(["bk_1"]); });
it("prefix-matches a half-typed final word", async () => { expect(await search("the hobb")).toEqual(["bk_3"]); });
it("searches authors as well as titles", async () => { expect(await search("tolkien")).toEqual(["bk_3"]); });
it("indexes rows inserted after the migration", async () => { await insert("bk_4", "Dune", "Frank Herbert", 10); expect(await search("dune")).toEqual(["bk_4"]); });
it("reflects updates without leaving the old terms behind", async () => { await db .updateTable("hive_book") .set({ title: "Dune Messiah", rawTitle: "Dune Messiah" }) .where("id", "=", "bk_3" as never) .execute();
// Orphaned terms from the pre-update row would keep matching forever. expect(await search("hobbit")).toEqual([]); expect(await search("dune messiah")).toEqual(["bk_3"]); });
it("drops terms for deleted rows", async () => { await db .deleteFrom("hive_book") .where("id", "=", "bk_1" as never) .execute(); expect(await search("the girl")).toEqual([]); });
it("treats quotes in the input as separators rather than throwing", async () => { // Escaped quotes end up inside the phrase, where FTS5 tokenizes them away. // The old LIKE '%the "girl"%' matched nothing at all here. expect(await search('the "girl"')).toEqual(["bk_1"]); });});