mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-08-22 22:32:28 -04:00
514 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|