import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { getTableConfig } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES, ACCOUNT_PREFERENCE_NAMES, AccountStoreError, createAccountStore, DEFAULT_ACCOUNT_PREFERENCES, ekklesiaCoreUserPreference, parseAccountPreferencesUpdate, resolveAccountPreferences, } from "../src/account/index.js"; const migrationsDirectory = path.join( path.dirname(fileURLToPath(import.meta.url)), "..", "drizzle", ); describe("account preferences model", () => { it("defaults both preferences to following the system", () => { expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" }); expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true); }); it("declares a closed, ordered set of names and values", () => { expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]); expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]); expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]); }); it.each( ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const), )("accepts appearance %s on its own", (appearance) => { expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance }); }); it.each( ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const), )("accepts motion %s on its own", (motion) => { expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion }); }); it("accepts both preferences together", () => { expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({ appearance: "dark", motion: "reduce", }); }); it.each([ ["an empty object", {}], ["null", null], ["an array", [["appearance", "dark"]]], ["a string", "dark"], ["an unknown appearance", { appearance: "sepia" }], ["an unknown motion", { motion: "none" }], ["a differently cased value", { appearance: "Dark" }], ["a non-string value", { appearance: 1 }], ["a null value", { motion: null }], ["an unknown key beside a valid one", { appearance: "dark", radius: "large" }], ["an unknown key alone", { theme: "dark" }], ["a user id", { appearance: "dark", userId: "someone-else" }], ["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')], [ "a class instance", new (class Preferences { appearance = "dark"; })(), ], ])("refuses %s whole", (_label, value) => { expect(parseAccountPreferencesUpdate(value)).toBeUndefined(); }); it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => { expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual( DEFAULT_ACCOUNT_PREFERENCES, ); expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({ appearance: "dark", motion: "system", }); // A value the constraint should have made impossible is never passed through. expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({ appearance: "system", motion: "allow", }); }); }); describe("account preference storage", () => { it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => { const config = getTableConfig(ekklesiaCoreUserPreference); expect(config.name).toBe("ekklesia_core_user_preference"); expect(config.columns.map((column) => column.name).sort()).toEqual([ "appearance", "created_at", "motion", "updated_at", "user_id", ]); const userId = config.columns.find((column) => column.name === "user_id"); expect(userId?.primary).toBe(true); // Preference columns are nullable: `null` is "never chosen", resolved to the default on read. for (const name of ACCOUNT_PREFERENCE_NAMES) { expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false); } expect(config.foreignKeys).toHaveLength(1); expect(config.foreignKeys[0]?.onDelete).toBe("cascade"); }); it("constrains each column in the migration to exactly the model's values", async () => { const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8"); const allowed = (column: string): string[] => { const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql); return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", "")); }; expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]); expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]); // No JSON, no key/value pair, nothing a client could fill with anything. expect(sql).not.toMatch(/jsonb?|hstore/iu); }); it("is a migration that destroys nothing", async () => { const metadata = JSON.parse( await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"), ) as { entries: { tag: string; destructive: { mode: string } }[] }; expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({ destructive: { mode: "none" }, tag: "0008_user_preferences", }); }); }); describe("account store validation", () => { it("refuses an invalid update before touching the database", async () => { const untouchable = new Proxy( {}, { get() { throw new Error("the database must not be reached"); }, }, ); const store = createAccountStore(untouchable as never); await expect( store.updatePreferences("user-1" as never, { appearance: "sepia" } as never), ).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" }); await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf( AccountStoreError, ); }); it("reports a database failure without the underlying error", async () => { const failing = { select() { throw new Error("connect ECONNREFUSED postgres://secret@db:5432"); }, transaction() { throw new Error("connect ECONNREFUSED postgres://secret@db:5432"); }, }; const store = createAccountStore(failing as never); const read = await store.readAccount("user-1" as never).catch((error: unknown) => error); expect(read).toBeInstanceOf(AccountStoreError); expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" }); expect(String((read as Error).stack)).not.toContain("secret"); const write = await store .updatePreferences("user-1" as never, { appearance: "dark" }) .catch((error: unknown) => error); expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" }); expect(String((write as Error).message)).not.toContain("ECONNREFUSED"); }); });