Files
bibdle/tests/verse-submission.test.ts
2026-07-07 17:02:24 -04:00

514 lines
16 KiB
TypeScript

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);
});
});