import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { eq } from "drizzle-orm"; import { testDb as db } from "../src/lib/server/db/test"; import { dailyVerses, verseSubmissions, user } from "../src/lib/server/db/schema"; import { addDays, dayDiff, getCooldownState, isUniqueConstraintError, scheduleSubmission, validateSelection, COOLDOWN_MS, type Db } from "../src/lib/server/verse-submission"; import { bookIdToNumber } from "../src/lib/server/bible"; // ---- helpers -------------------------------------------------------------- const TODAY = "2026-07-01"; // frozen "today" for deterministic scheduling tests const DAY_MS = 1000 * 60 * 60 * 24; function uuid() { return Bun.randomUUIDv7(); } /** Insert a committed daily_verses row (simulating an existing scheduled verse). */ async function seedDailyVerse( date: string, bookId: string, reference: string, verseText = "verse text" ) { await db .insert(dailyVerses) .values({ id: uuid(), date, bookId, verseText, reference, createdAt: new Date(0) }) .run(); } async function seedUser(id: string) { await db .insert(user) .values({ id, firstName: "Test", email: `${id}@example.com`, isPrivate: false }) .run(); } async function seedSubmission( userId: string, scheduledDate: string, submittedAt: number, bookId = "GEN", chapter = 1, verse = 3 ) { await db .insert(verseSubmissions) .values({ id: uuid(), userId, scheduledDate, selectedBookId: bookId, selectedChapter: chapter, selectedVerse: verse, submittedAt }) .run(); } async function clearAll() { await db.delete(verseSubmissions).run(); await db.delete(dailyVerses).run(); await db.delete(user).run(); } // =========================================================================== // Pure date arithmetic // =========================================================================== describe("addDays / dayDiff (UTC arithmetic)", () => { test("addDays moves forward across month/year boundaries", () => { expect(addDays("2026-01-31", 1)).toBe("2026-02-01"); expect(addDays("2026-12-31", 1)).toBe("2027-01-01"); expect(addDays("2026-02-28", 7)).toBe("2026-03-07"); }); test("addDays is negative-safe", () => { expect(addDays("2026-03-01", -1)).toBe("2026-02-28"); expect(addDays("2026-01-01", -1)).toBe("2025-12-31"); }); test("addDays is its own inverse with dayDiff", () => { const start = "2026-07-01"; for (const n of [0, 1, 7, 30, 365, -1, -60]) { const shifted = addDays(start, n); expect(dayDiff(start, shifted)).toBe(n); } }); test("dayDiff handles DST-free UTC whole days exactly", () => { expect(dayDiff("2026-07-01", "2026-07-02")).toBe(1); expect(dayDiff("2026-07-01", "2026-06-30")).toBe(-1); expect(dayDiff("2026-01-01", "2026-12-31")).toBe(364); }); }); // =========================================================================== // validateSelection (structural validation, no DB) // =========================================================================== describe("validateSelection", () => { test("accepts a valid book/chapter/verse", () => { expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 1 })).toBeNull(); expect(validateSelection({ bookId: "JHN", chapter: 3, verse: 16 })).toBeNull(); }); test("rejects unknown bookId", () => { expect(validateSelection({ bookId: "ZZZ", chapter: 1, verse: 1 })).toBe( "Unknown bookId" ); }); test("rejects chapter out of range", () => { const gen = bookIdToNumber["GEN"]; expect(validateSelection({ bookId: "GEN", chapter: 999, verse: 1 })).toContain( "chapter out of range" ); }); test("rejects verse out of range", () => { expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 9999 })).toContain( "verse out of range" ); }); test("rejects non-positive / non-integer chapter & verse", () => { expect(validateSelection({ bookId: "GEN", chapter: 0, verse: 1 })).toContain( "positive integer" ); expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 0 })).toContain( "positive integer" ); expect( validateSelection({ bookId: "GEN", chapter: 1.5, verse: 1 }) ).toContain("positive integer"); }); }); // =========================================================================== // isUniqueConstraintError // =========================================================================== describe("isUniqueConstraintError", () => { test("matches SQLITE_CONSTRAINT_UNIQUE code", () => { expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE" })).toBe(true); }); test("matches SQLITE_CONSTRAINT code", () => { expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT" })).toBe(true); }); test("matches by message containing UNIQUE", () => { expect( isUniqueConstraintError( new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed: daily_verses.date") ) ).toBe(true); }); test("returns false for unrelated errors", () => { expect(isUniqueConstraintError(new Error("something else"))).toBe(false); expect(isUniqueConstraintError(null)).toBe(false); expect(isUniqueConstraintError(undefined)).toBe(false); }); }); // =========================================================================== // Cooldown arithmetic (DB) // =========================================================================== describe("getCooldownState", () => { const userId = "user-cooldown"; beforeEach(async () => { await clearAll(); await seedUser(userId); }); afterEach(async () => { await clearAll(); }); test("no submissions → not on cooldown, null end", async () => { const state = await getCooldownState(db, userId); expect(state.onCooldown).toBe(false); expect(state.cooldownEndsAt).toBeNull(); }); test("recent submission → on cooldown, ends at submittedAt + 7d", async () => { const now = Date.now(); const submittedAt = now - 1000; // 1s ago await seedSubmission(userId, "2026-08-15", submittedAt); const state = await getCooldownState(db, userId, now); expect(state.onCooldown).toBe(true); expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS); }); test("submission exactly 7 days ago → cooldown just expired (boundary)", async () => { const now = 1_700_000_000_000; const submittedAt = now - COOLDOWN_MS; // exactly 7d ago await seedSubmission(userId, "2026-08-15", submittedAt); // now == cooldownEndsAt → no longer on cooldown const state = await getCooldownState(db, userId, now); expect(state.onCooldown).toBe(false); }); test("submission 6d23h ago → still on cooldown", async () => { const now = 1_700_000_000_000; const submittedAt = now - (COOLDOWN_MS - 1000); await seedSubmission(userId, "2026-08-15", submittedAt); const state = await getCooldownState(db, userId, now); expect(state.onCooldown).toBe(true); expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS); }); test("uses the most recent of multiple submissions", async () => { const now = Date.now(); await seedSubmission(userId, "2026-08-15", now - 20 * DAY_MS); // older await seedSubmission(userId, "2026-08-22", now - 2 * DAY_MS); // recent const state = await getCooldownState(db, userId, now); expect(state.onCooldown).toBe(true); // The recent one (2d ago) drives the cooldown, not the 20d-old one. expect(state.cooldownEndsAt).toBe(now - 2 * DAY_MS + COOLDOWN_MS); }); }); // =========================================================================== // Scheduling validity (DB) // =========================================================================== describe("scheduleSubmission — validity rules", () => { const userId = "user-sched"; const now = Date.now(); beforeEach(async () => { await clearAll(); await seedUser(userId); }); afterEach(async () => { await clearAll(); }); test("empty calendar → schedules tomorrow (server UTC)", async () => { const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, userId, now, { today: TODAY } ); expect(result.scheduledDate).toBe(addDays(TODAY, 1)); expect(result.bookId).toBe("GEN"); expect(result.reference).toBe("Genesis 1:1-3"); // Both rows written. const [dv] = await db .select() .from(dailyVerses) .where(eq(dailyVerses.date, result.scheduledDate)); expect(dv).toBeDefined(); expect(dv.bookId).toBe("GEN"); const [vs] = await db .select() .from(verseSubmissions) .where(eq(verseSubmissions.scheduledDate, result.scheduledDate)); expect(vs).toBeDefined(); expect(vs.userId).toBe(userId); expect(vs.selectedBookId).toBe("GEN"); expect(vs.selectedChapter).toBe(1); expect(vs.selectedVerse).toBe(3); expect(vs.submittedAt).toBe(now); }); test("skips a date whose D-1 neighbor is the same book (no back-to-back)", async () => { // Committed row at tomorrow with GEN → tomorrow is back-to-blocked for GEN // (its D-1 = today, but today is empty so no conflict; instead block via // seeding GEN at day+2, which makes day+3's D-1 a GEN). const d2 = addDays(TODAY, 2); await seedDailyVerse(d2, "GEN", "Genesis 1:10-12"); const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, userId, now, { today: TODAY } ); // tomorrow (d1) is empty, d-1=today empty, d+1=d2=GEN → back-to-back → skip. // d2 is occupied. d3's d-1=d2=GEN → back-to-back → skip. d4 is earliest valid // (different window ref, so the 60-day repeat rule does not apply). expect(result.scheduledDate).toBe(addDays(TODAY, 4)); }); test("skips a date whose D+1 neighbor is the same book", async () => { // Seed GEN at day+3. Then day+2 (empty) has D+1 = GEN → back-to-back. // day+1: D+1 = day+2 empty, ok; D-1=today empty → valid → schedules day+1. // To force the D+1 rule, seed GEN at day+1 so day+1 is occupied and // the next empty candidate (day+2) has D+1=day+3 ... need day+3 to be GEN. const d1 = addDays(TODAY, 1); const d3 = addDays(TODAY, 3); await seedDailyVerse(d1, "EXO", "Exodus 1:1-3"); // occupy day+1 (different book) await seedDailyVerse(d3, "GEN", "Genesis 1:10-12"); // day+3 = GEN const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, userId, now, { today: TODAY } ); // day+2 is empty but D+1 = day+3 = GEN → back-to-back → skip. // day+3 occupied. day+4: D-1=day+3=GEN → back-to-back → skip. // day+5: D-1=day+4 empty, D+1=day+6 empty → valid (different window ref). expect(result.scheduledDate).toBe(addDays(TODAY, 5)); }); test("back-to-back with a different book is allowed", async () => { // Seed EXO at tomorrow. A GEN submission at day+2 has D-1=day+1=EXO (diff) → ok. await seedDailyVerse(addDays(TODAY, 1), "EXO", "Exodus 1:1-3"); const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, userId, now, { today: TODAY } ); expect(result.scheduledDate).toBe(addDays(TODAY, 2)); }); test("60-day repeat: identical window within ±60d pushes the date out", async () => { // Seed an identical GEN 1:1-3 window at day+10. const d10 = addDays(TODAY, 10); await seedDailyVerse(d10, "GEN", "Genesis 1:1-3"); const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, // same window: Genesis 1:1-3 userId, now, { today: TODAY } ); // The earliest empty, non-back-to-back candidate whose distance from d10 // is > 60 days. day+1..day+9: within 60d of d10 (and day+9 D+1=d10=GEN // back-to-back anyway). day+11: D-1=d10=GEN → back-to-back + within 60. // ... all dates within [d10-60, d10+60] are repeat-blocked. The first // valid date is d10+61. expect(result.scheduledDate).toBe(addDays(d10, 61)); // Sanity: distance is just over 60 days. expect(Math.abs(dayDiff(d10, result.scheduledDate))).toBeGreaterThan(60); }); test("different-window repeat at the same book is NOT blocked by rule 4", async () => { // Seed GEN 1:1-3 at day+1. A GEN 1:4-6 submission shares the book but // not the window identity, so rule 4 (60-day repeat) does not apply — // only back-to-back matters. const d1 = addDays(TODAY, 1); await seedDailyVerse(d1, "GEN", "Genesis 1:1-3"); const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 6 }, // window Genesis 1:4-6 userId, now, { today: TODAY } ); // day+1 occupied. day+2: D-1=day+1=GEN → back-to-back → skip. // day+3: D-1=day+2 empty → valid (window differs, so no 60-day block). expect(result.scheduledDate).toBe(addDays(TODAY, 3)); expect(result.reference).toBe("Genesis 1:4-6"); }); test("fall-forward window (Gen 1:1) schedules and stores Gen 1:1-3", async () => { const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 1 }, userId, now, { today: TODAY } ); expect(result.reference).toBe("Genesis 1:1-3"); }); }); // =========================================================================== // Concurrency (DB) // =========================================================================== describe("scheduleSubmission — concurrency", () => { const now = Date.now(); beforeEach(async () => { await clearAll(); }); afterEach(async () => { await clearAll(); }); test("two concurrent submissions with the same book land on distinct dates", async () => { const u1 = "user-conc-1"; const u2 = "user-conc-2"; await seedUser(u1); await seedUser(u2); const [r1, r2] = await Promise.all([ scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, { today: TODAY }), scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 4 }, u2, now, { today: TODAY }) ]); // Both must succeed and never share a scheduled date. expect(r1.scheduledDate).not.toBe(r2.scheduledDate); // And they must not be back-to-back (same book). const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate)); expect(diff).toBeGreaterThan(1); // Both rows exist in verse_submissions with their own user. const all = await db.select().from(verseSubmissions).all(); expect(all).toHaveLength(2); const userIds = all.map((r) => r.userId).sort(); expect(userIds).toEqual([u1, u2].sort()); // And two distinct daily_verses rows. const dv = await db.select().from(dailyVerses).all(); expect(dv).toHaveLength(2); expect(new Set(dv.map((r) => r.date)).size).toBe(2); }); test("retry recovers when a concurrent insert claims the candidate first", async () => { // Simulate a "lost race" by pre-occupying tomorrow (the first candidate) // right before the call — the scan already ran against a stale calendar // only if we insert between scan and insert. Instead, verify the simpler // guarantee: an existing row on the candidate date is detected on retry // because loadCalendar is re-read each attempt. const u1 = "user-conc-race"; await seedUser(u1); // Seed GEN at every day from tomorrow..tomorrow+3 so the first valid // empty slot for GEN (respecting back-to-back) is pushed well out. This // exercises the scan walking past occupied + back-to-back dates. for (let i = 1; i <= 3; i++) { await seedDailyVerse(addDays(TODAY, i), "GEN", `Genesis 1:10-12`); } const result = await scheduleSubmission( db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, { today: TODAY } ); // Days 1..3 occupied with GEN (different window ref, so rule 4 is silent). // day+4: D-1=day+3=GEN → back-to-back → skip. // day+5: D-1=day+4 empty, D+1=day+6 empty → valid. expect(result.scheduledDate).toBe(addDays(TODAY, 5)); }); test("two concurrent submissions for different books can be adjacent", async () => { const u1 = "user-conc-diff-1"; const u2 = "user-conc-diff-2"; await seedUser(u1); await seedUser(u2); const [r1, r2] = await Promise.all([ scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, { today: TODAY }), scheduleSubmission(db, { bookId: "EXO", chapter: 1, verse: 3 }, u2, now, { today: TODAY }) ]); expect(r1.bookId).toBe("GEN"); expect(r2.bookId).toBe("EXO"); expect(r1.scheduledDate).not.toBe(r2.scheduledDate); // Different books may legitimately be adjacent (diff == 1) — just assert // both are distinct future dates. const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate)); expect(diff).toBeGreaterThanOrEqual(1); }); });