cm_whatsapp_bot_v1/apps/web/src/lib/date-picker.test.ts
yiekheng f19ea03e0d feat: edit reminders, mature recurrence, QR throttle, more tests
Reminders
- Reminder list / detail show recurrence summary ("Every Mon, Wed, Fri",
  "Every 2 weeks until 2027-01-01", etc).
- Detail page reorganised: each section (Account / Message / When /
  Groups) is itself a clickable card that deep-links into the wizard
  step in edit mode (editReminderId URL param). No standalone Edit
  button. Run history stays read-only.
- New /reminders/[id]/edit shell loads the row, encodes its state into
  wizard URL params, and forwards to /reminders/new. The wizard
  threads editReminderId through every step.
- updateReminderAction: validates ownership of both the existing
  reminder and the (possibly changed) target account, replaces targets
  + messages wholesale, re-arms the pg-boss job (singleton key picks
  up the new fire time).
- Wizard submit branches to updateReminderAction when editReminderId
  is set; button reads "Save changes" / "Saving…".
- Wizard default first-fire is now the current minute in the operator
  zone (not now+1h). Same-minute clicks bump silently to next minute
  via a 60 s grace window so the user isn't punished.
- /reminders empty state is filter-aware: "No failed reminders yet."
  when ?filter=failed and there are reminders in other states.

Recurrence
- Spec is now a structured object: { kind, interval, weeklyDays,
  monthDay, end }. Builder produces RRULEs with INTERVAL, BYDAY,
  BYMONTHDAY, COUNT, UNTIL as appropriate. specFromRrule round-trips
  for resuming/edit.
- When-step UI: frequency pills, "Every N days/weeks/…" interval,
  weekday picker (weekly), day-of-month input (monthly), end picker
  (Never / After N occurrences / On date), live human-readable
  summary preview.

QR pairing
- Throttle QR refresh to once per 25 s and detach the previous
  per-account session listener on Re-pair so listeners can't
  accumulate. The UI countdown was flicking every ~5 s because each
  Re-pair attached an extra listener — every Baileys QR event then
  triggered a fresh DB write + NOTIFY.

Tests (60 green total, +33 in this batch)
- recurrence.test.ts: extended to 25 tests covering interval,
  monthday, end conditions (COUNT/UNTIL), and round-trip parsing.
- date-picker.test.ts: 14 tests for splitDateTime / combineDateTime /
  validateScheduledAt (incl. the "click-too-fast" same-minute grace)
  and defaultFirstFireIso.
- /api/qr/[accountId] route.test.ts: 4 tests — 404 when no QR yet,
  404 on missing row, 200 with image/png + no-store + correct PNG
  bytes, and verifies the where-clause queries by accountId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:22:22 +08:00

116 lines
3.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { DateTime } from "luxon";
import {
splitDateTime,
combineDateTime,
validateScheduledAt,
defaultFirstFireIso,
} from "./date-picker";
const TZ = "Asia/Kuala_Lumpur";
describe("splitDateTime", () => {
it("splits a zoned ISO into date + time strings in that zone", () => {
// 09:00 KL is the same wall-clock no matter what offset is on the ISO.
expect(splitDateTime("2026-05-13T09:00:00+08:00", TZ)).toEqual({
date: "2026-05-13",
time: "09:00",
});
});
it("converts a UTC ISO into the operator's local wall-clock", () => {
// 2026-05-13 01:00Z = 09:00 KL.
expect(splitDateTime("2026-05-13T01:00:00Z", TZ)).toEqual({
date: "2026-05-13",
time: "09:00",
});
});
it("returns empty strings on malformed input", () => {
expect(splitDateTime("not-an-iso", TZ)).toEqual({ date: "", time: "" });
});
});
describe("combineDateTime", () => {
it("returns null when either field is missing", () => {
expect(combineDateTime("", "09:00", TZ)).toBe(null);
expect(combineDateTime("2026-05-13", "", TZ)).toBe(null);
});
it("parses a valid pair into a luxon DateTime in the right zone", () => {
const dt = combineDateTime("2026-05-13", "09:00", TZ);
expect(dt).not.toBeNull();
expect(dt!.zoneName).toBe(TZ);
// Use the offset format (timezone display varies by ICU build).
expect(dt!.toFormat("yyyy-MM-dd HH:mm ZZ")).toBe("2026-05-13 09:00 +08:00");
});
it("returns null for an unparseable pair", () => {
expect(combineDateTime("2026-99-99", "09:00", TZ)).toBe(null);
});
});
describe("validateScheduledAt", () => {
// Pin "now" so these tests are deterministic. 2026-05-13 09:00 KL.
const NOW = DateTime.fromISO("2026-05-13T09:00:00", { zone: TZ }).toMillis();
it("rejects when the date is missing", () => {
expect(validateScheduledAt("", "09:30", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
});
it("rejects when the time is missing", () => {
expect(validateScheduledAt("2026-05-13", "", TZ, NOW)).toEqual({ ok: false, reason: "missing" });
});
it("rejects an invalid date+time pair", () => {
expect(validateScheduledAt("2026-99-99", "09:30", TZ, NOW)).toEqual({
ok: false,
reason: "invalid",
});
});
it("rejects timestamps clearly in the past", () => {
expect(validateScheduledAt("2026-05-13", "07:00", TZ, NOW)).toEqual({
ok: false,
reason: "past",
});
});
it("bumps a same-minute time forward by one minute (the user clicked too fast)", () => {
const r = validateScheduledAt("2026-05-13", "09:00", TZ, NOW);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.dt.toFormat("HH:mm")).toBe("09:01");
}
});
it("accepts any future time as-is", () => {
const r = validateScheduledAt("2026-05-13", "10:30", TZ, NOW);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.dt.toFormat("HH:mm")).toBe("10:30");
}
});
it("respects a custom grace window", () => {
// 30 seconds in the past, grace = 0 → reject.
expect(validateScheduledAt("2026-05-13", "08:59", TZ, NOW + 0, 0)).toEqual({
ok: false,
reason: "past",
});
});
});
describe("defaultFirstFireIso", () => {
it("rounds 'now' down to the start of the minute in the operator zone", () => {
const now = DateTime.fromISO("2026-05-13T09:42:37.500", { zone: "UTC" });
const iso = defaultFirstFireIso(TZ, now);
const back = DateTime.fromISO(iso, { zone: TZ });
expect(back.zoneName).toBe(TZ);
expect(back.second).toBe(0);
expect(back.millisecond).toBe(0);
// 09:42:37 UTC = 17:42:37 KL → start of minute = 17:42 KL.
expect(back.toFormat("HH:mm")).toBe("17:42");
});
});