Merge scheduled send
Both branches turn on where Stalwart advertises a capability, so they meet in the same two files. The mock keeps `urn:stalwart:jmap` out of the session-level capabilities and hands it out per-account, as a real server does, while the submission capability it grew for scheduled send lives per-account beside it; the client keeps both accessors, one asking whether a capability is advertised anywhere and one reading the object itself.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canScheduleSend,
|
||||
describeSpan,
|
||||
holdUntil,
|
||||
maxDelayMs,
|
||||
MIN_LEAD_MS,
|
||||
schedulePresets,
|
||||
scheduleError,
|
||||
} from "@/lib/schedule";
|
||||
|
||||
/** Stalwart's own numbers, from the account capability it advertises. */
|
||||
const STALWART = { maxDelayedSend: 86400 * 30, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [] } };
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe("capability detection", () => {
|
||||
it("accepts a server that advertises FUTURERELEASE and a non-zero window", () => {
|
||||
expect(canScheduleSend(STALWART)).toBe(true);
|
||||
expect(maxDelayMs(STALWART)).toBe(30 * DAY);
|
||||
});
|
||||
|
||||
it("refuses a server whose window is zero, which RFC 8621 defines as unsupported", () => {
|
||||
expect(canScheduleSend({ ...STALWART, maxDelayedSend: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a server that offers a window but not the extension", () => {
|
||||
expect(canScheduleSend({ maxDelayedSend: 86400, submissionExtensions: { DSN: [] } })).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses the empty capability object Stalwart puts at session level", () => {
|
||||
expect(canScheduleSend({})).toBe(false);
|
||||
expect(canScheduleSend(undefined)).toBe(false);
|
||||
expect(maxDelayMs(undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("holdUntil", () => {
|
||||
it("is an RFC 3339 UTC date-time, which is what Stalwart parses since 0.16.17", () => {
|
||||
expect(holdUntil(new Date("2026-11-20T05:00:00Z"))).toBe("2026-11-20T05:00:00Z");
|
||||
});
|
||||
|
||||
it("drops milliseconds, so the sendAt that comes back agrees with what we asked", () => {
|
||||
expect(holdUntil(new Date("2026-11-20T05:00:00.789Z"))).toBe("2026-11-20T05:00:00Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePresets", () => {
|
||||
// A Monday morning: everything is still ahead.
|
||||
const monday9am = new Date(2026, 7, 24, 9, 0, 0, 0);
|
||||
|
||||
it("offers later today, tomorrow and next Monday from a Monday morning", () => {
|
||||
const ids = schedulePresets(monday9am, 30 * DAY).map((p) => p.id);
|
||||
expect(ids).toEqual(["later-today", "tomorrow-morning", "tomorrow-afternoon", "monday-morning"]);
|
||||
});
|
||||
|
||||
it("puts the times where the labels say", () => {
|
||||
const by = Object.fromEntries(schedulePresets(monday9am, 30 * DAY).map((p) => [p.id, p.at]));
|
||||
expect(by["later-today"]!.getHours()).toBe(17);
|
||||
expect(by["later-today"]!.getDate()).toBe(24);
|
||||
expect(by["tomorrow-morning"]!.getDate()).toBe(25);
|
||||
expect(by["tomorrow-morning"]!.getHours()).toBe(8);
|
||||
expect(by["tomorrow-afternoon"]!.getHours()).toBe(13);
|
||||
});
|
||||
|
||||
it("skips a Monday for the Monday a week out, not today", () => {
|
||||
const monday = schedulePresets(monday9am, 30 * DAY).find((p) => p.id === "monday-morning")!;
|
||||
expect(monday.at.getDate()).toBe(31);
|
||||
expect(monday.at.getDay()).toBe(1);
|
||||
});
|
||||
|
||||
it("finds next Monday from mid-week", () => {
|
||||
const wednesday = new Date(2026, 7, 26, 9, 0, 0, 0);
|
||||
const monday = schedulePresets(wednesday, 30 * DAY).find((p) => p.id === "monday-morning")!;
|
||||
expect(monday.at.getDate()).toBe(31);
|
||||
expect(monday.at.getDay()).toBe(1);
|
||||
});
|
||||
|
||||
it("drops later today once the evening has passed", () => {
|
||||
const ids = schedulePresets(new Date(2026, 7, 24, 18, 0, 0, 0), 30 * DAY).map((p) => p.id);
|
||||
expect(ids).not.toContain("later-today");
|
||||
expect(ids).toContain("tomorrow-morning");
|
||||
});
|
||||
|
||||
it("offers nothing beyond what the server will hold", () => {
|
||||
// A two-hour window reaches this evening but nothing after it.
|
||||
const ids = schedulePresets(new Date(2026, 7, 24, 16, 0, 0, 0), 2 * 3_600_000).map((p) => p.id);
|
||||
expect(ids).toEqual(["later-today"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleError", () => {
|
||||
const now = new Date(2026, 7, 24, 9, 0, 0, 0);
|
||||
|
||||
it("accepts a time comfortably ahead", () => {
|
||||
expect(scheduleError(new Date(now.getTime() + DAY), now, 30 * DAY)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses the past and the almost-now", () => {
|
||||
expect(scheduleError(new Date(now.getTime() - 1000), now, 30 * DAY)).toMatch(/at least a minute/);
|
||||
expect(scheduleError(new Date(now.getTime() + MIN_LEAD_MS - 1), now, 30 * DAY)).toMatch(/at least a minute/);
|
||||
});
|
||||
|
||||
it("refuses what the server would reject, naming the limit", () => {
|
||||
const err = scheduleError(new Date(now.getTime() + 31 * DAY), now, 30 * DAY);
|
||||
expect(err).toMatch(/30 days/);
|
||||
});
|
||||
|
||||
it("refuses an unparseable date rather than sending one", () => {
|
||||
expect(scheduleError(new Date("nonsense"), now, 30 * DAY)).toMatch(/Pick a date/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSpan", () => {
|
||||
it("reads in days when there are days, hours otherwise", () => {
|
||||
expect(describeSpan(30 * DAY)).toBe("30 days");
|
||||
expect(describeSpan(DAY)).toBe("1 day");
|
||||
expect(describeSpan(2 * 3_600_000)).toBe("2 hours");
|
||||
expect(describeSpan(3_600_000)).toBe("1 hour");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user