Group the admin and calendar modules, and stop calling screenshots docs

web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
This commit is contained in:
2026-09-15 22:44:53 -07:00
parent 0bde2df69d
commit f7712b1c1e
72 changed files with 108 additions and 108 deletions
-106
View File
@@ -1,106 +0,0 @@
import { describe, expect, it } from "vitest";
import { ADMIN_BASELINE, adminSections, can, dashboardCards, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
const set = (...p: string[]) => permissionSet(p);
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
const roles = new Map<string, RoleDef>([
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
]);
describe("who is offered administration", () => {
it("needs both halves of reading the account list to list accounts", () => {
// Groups are accounts to the server, so they come with the same two permissions.
expect(adminSections(set("sysAccountQuery", "sysAccountGet"))).toEqual(["dashboard", "accounts", "groups"]);
// A query alone is a count on the dashboard, not a list.
expect(adminSections(set("sysAccountQuery"))).toEqual(["dashboard"]);
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
expect(hasAdministration(permissionSet(undefined))).toBe(false);
});
it("offers each section only with both halves of reading it", () => {
expect(adminSections(set("sysDomainQuery", "sysDomainGet"))).toEqual(["dashboard", "domains"]);
expect(hasAdministration(set("sysDomainQuery", "sysDomainGet"))).toBe(true);
expect(adminSections(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery"))).toEqual(["dashboard", "accounts", "groups"]);
});
it("gives the dashboard a card for each number the role can read", () => {
expect(dashboardCards(set("sysAccountQuery", "sysAccountGet", "sysDomainQuery", "sysDomainGet"))).toEqual(["users", "domains"]);
expect(dashboardCards(set("sysQueuedMessageQuery"))).toEqual(["pending"]);
// The history takes its get as well: the query only finds the records.
expect(dashboardCards(set("sysMetricQuery"))).toEqual([]);
expect(dashboardCards(set("sysMetricQuery", "sysMetricGet"))).toEqual(["memory", "received", "sent"]);
expect(adminSections(set("jmapEmailGet"))).toEqual([]);
});
it("reads one permission per object and operation", () => {
expect(can(helpdesk, "Account", "Update")).toBe(true);
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
expect(can(helpdesk, "Domain", "Get")).toBe(false);
});
});
/**
* Stalwart checks a grant, but not a password change or a delete. Without this,
* anyone allowed to edit accounts could take over one that can do more.
*/
describe("an account that outranks the viewer", () => {
it("an ordinary user never does", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
expect(outranks(helpdesk, {}, null)).toBe(false);
});
it("an administrator does, unless the viewer is one too", () => {
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
});
it("a custom role does when it carries something the viewer lacks", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
});
it("a role that cannot be read counts against the target, not for it", () => {
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
});
it("extra permissions on the account itself are counted", () => {
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
// Replace ignores the roles entirely, so only what it lists matters.
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
});
it("survives a role that names itself", () => {
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
});
});
describe("granting a role", () => {
it("is offered only for roles whose every permission the viewer holds", () => {
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
expect(canGrantRole(everything, "missing", roles)).toBe(false);
});
});
describe("generated passwords", () => {
it("are four groups of five unambiguous characters", () => {
const p = generatePassword();
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
expect(p).not.toMatch(/[01lIO]/);
});
it("skip bytes that would favor the start of the alphabet", () => {
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
// the first 36 characters twice as often. Bytes of 220 and up are dropped
// and more are drawn, so a batch of nothing but those costs a draw.
let call = 0;
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
expect(call).toBe(2);
});
});
@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { client, JmapMethodError } from "@/jmap/client";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
describe("the dashboard's message numbers", () => {
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
const stats = summarizeMetrics([
counter("queue.message-queued", 6),
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
counter("queue.authenticated-message-queued", 2),
counter("queue.dsn-queued", 1),
counter("queue.report-queued", 3),
// Recorded, but not either number.
counter("message-ingest.ham", 50),
]);
expect(stats.received).toBe(10);
expect(stats.sent).toBe(6);
});
it("reads memory from the newest gauge, not the first one listed", () => {
const stats = summarizeMetrics([
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
]);
expect(stats.memory).toEqual({ bytes: 300, at: "2026-09-15T14:00:00Z" });
});
it("tells a history that records nothing from a quiet day", () => {
expect(summarizeMetrics([]).recorded).toBe(false);
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
});
});
describe("the dashboard's queries", () => {
it("counts users rather than accounts, and asks for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 5 });
expect(await countObjects("Account")).toBe(5);
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User" }, limit: 0, calculateTotal: true });
await countObjects("QueuedMessage");
expect(call).toHaveBeenLastCalledWith("x:QueuedMessage/query", { limit: 0, calculateTotal: true });
call.mockRestore();
});
it("filters the history with Stalwart's comparison names, and pages the gets", async () => {
// A bare `timestamp` or `after` is unsupportedFilter on a live server.
vi.spyOn(client, "maxObjectsInGet", "get").mockReturnValue(2);
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
if (method === "x:Metric/query") return (args as { position: number }).position === 0 ? { ids: ["a", "b"] } : { ids: ["c"] };
return { list: ((args as { ids: string[] }).ids).map((id) => counter("queue.message-queued", 1, id)) };
});
const records = await loadMetrics(new Date("2026-09-14T15:30:00.123Z"));
expect(records).toHaveLength(3);
expect(call.mock.calls[0]).toEqual([
"x:Metric/query",
{
filter: { timestampIsGreaterThanOrEqual: "2026-09-14T15:30:00Z", metric: ["queue.message-queued", "queue.authenticated-message-queued", "queue.dsn-queued", "queue.report-queued", "server.memory"] },
sort: [{ property: "timestamp", isAscending: false }],
position: 0,
limit: 2,
},
]);
vi.restoreAllMocks();
});
it("treats only a forbidden answer as the server refusing", () => {
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "forbidden" }))).toBe(true);
expect(isRefused(new JmapMethodError("x:Metric/query", { type: "serverFail" }))).toBe(false);
expect(isRefused(new Error("offline"))).toBe(false);
});
});
describe("the card grid", () => {
it("never leaves a row short when the cards can be divided evenly", () => {
for (const n of [1, 2, 3, 4, 6]) {
const { wide, mid } = balancedColumns(n);
expect(n % wide, `${n} cards across ${wide}`).toBe(0);
expect(n % mid, `${n} cards across ${mid}`).toBe(0);
expect(wide).toBeLessThanOrEqual(4);
}
expect(balancedColumns(6)).toEqual({ wide: 3, mid: 2 });
expect(balancedColumns(3)).toEqual({ wide: 3, mid: 1 });
});
});
@@ -1,99 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, queryAccounts, quotasWithDisk } from "@/lib/adminDirectory";
describe("setting a password", () => {
it("writes into the existing password credential, keeping its place", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
});
it("adds one after the last index when the account has none", () => {
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
expect(hasPassword(account)).toBe(false);
});
});
describe("lists written back", () => {
it("re-index aliases the way the server stores a list", () => {
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
"0": { enabled: true, name: "b", domainId: "d1", description: null },
"1": { enabled: false, name: "c", domainId: "d2", description: null },
});
});
it("change the disk limit without touching the other quotas", () => {
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
expect(quotasWithDisk(undefined, 0)).toEqual({});
});
});
describe("explaining a refusal", () => {
it("says what a taken address means", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
});
it("keeps the server's own words for a password policy", () => {
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
});
it("handles a method-level refusal as well as a set error", () => {
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
});
});
describe("the account query", () => {
it("filters on @type, the property's name on the object", async () => {
// A live 0.16 server answers a plain `type` with "unsupportedFilter - type"
// and fails the whole list, which is how this was found.
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryAccounts({ type: "User", text: " ada ", position: 50, limit: 50 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", text: "ada" }, position: 50, limit: 50, calculateTotal: true });
call.mockRestore();
});
});
/**
* Stalwart explains a refusal in English, and none of it should reach an
* interface in another language as it is. Each case below is a refusal a
* live server gave, or one its source says it gives.
*/
describe("refusals in the reader's language", () => {
it("recognizes the registry's validators and says it again, without the server's words", () => {
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
expect(domain).toMatch(/isn't a valid domain name/);
expect(domain).not.toContain("Invalid domain name");
expect(describeDirectoryError(new DirectoryError("invalidPatch", "Invalid email address", ["catchAllAddress"]), "domain")).toMatch(/full address/);
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Invalid email local part", ["name"]))).toMatch(/before the @/);
});
it("never echoes a description it does not know", () => {
const text = describeDirectoryError(new DirectoryError("invalidPatch", "Something only the server would say", ["whatever"]));
expect(text).not.toContain("Something only the server would say");
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not allowed to do that thing"))).not.toContain("not allowed to do that thing");
expect(describeDirectoryError(new DirectoryError("someNewType", "Brand new English"))).not.toContain("Brand new English");
});
it("tells a grant refusal and a directory-backed account apart from a plain no", () => {
expect(describeDirectoryError(new DirectoryError("forbidden", "You are not authorized to grant permissions: sysDomainDestroy."))).toMatch(/permissions your own role/);
expect(describeDirectoryError(new DirectoryError("forbidden", "Cannot set credentials for accounts in an external directory."))).toMatch(/external directory/);
});
it("words a clash and a missing object for what it was about", () => {
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined, ["name"]), "domain")).toMatch(/domain name is already in use/);
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", undefined))).toMatch(/address is already in use/);
expect(describeDirectoryError(new DirectoryError("notFound", undefined), "domain")).toMatch(/domain no longer exists/);
});
it("explains ihasmail's own refusals by their code, not their English message", () => {
const own = { status: 403, code: "administration_needs_own_device", message: "Administration is only available when signed in on a device marked as your own (x:Account/query)." };
expect(describeDirectoryError(own)).toMatch(/marked as your own/);
expect(describeDirectoryError(own)).not.toContain("x:Account/query");
expect(describeDirectoryError({ status: 403, code: "administration_disabled", message: "…" })).toMatch(/turned off/);
expect(describeDirectoryError({ method: "x:Account/query", type: "unsupportedFilter", message: "x:Account/query: unsupportedFilter - type" })).toBe("The mail server could not carry out the request (unsupportedFilter).");
});
});
@@ -1,69 +0,0 @@
import { describe, expect, it } from "vitest";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
/**
* Written the way Stalwart's BIND serializer writes it (dns-update's
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
* parenthesized run of quoted chunks.
*/
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
const zone = [
"example.com. IN MX 10 mail.example.com.",
'example.com. IN TXT "v=spf1 mx ra=postmaster -all"',
"v1-rsa-20260601._domainkey.example.com. IN TXT (",
...(long.match(/.{1,255}/g) ?? []).map((c) => ` "${c}"`),
")",
'_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:\\"postmaster\\"@example.com"',
"_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.",
'example.com. IN CAA 0 issue "letsencrypt.org"',
"",
].join("\n");
describe("reading the zone file", () => {
const records = parseZoneFile(zone);
it("gives one row per record, without the root dot", () => {
expect(records.map((r) => r.type)).toEqual(["MX", "TXT", "TXT", "TXT", "SRV", "CAA"]);
expect(records[0]).toMatchObject({ name: "example.com", value: "10 mail.example.com." });
});
it("joins a split TXT record back into the value a DNS form wants", () => {
expect(records[2]!.name).toBe("v1-rsa-20260601._domainkey.example.com");
expect(records[2]!.value).toBe(long);
expect(records[2]!.line).toContain("(");
});
it("unquotes and unescapes TXT values, and leaves other types as written", () => {
expect(records[1]!.value).toBe("v=spf1 mx ra=postmaster -all");
expect(records[3]!.value).toBe('v=DMARC1; p=reject; rua=mailto:"postmaster"@example.com');
expect(records[5]!.value).toBe('0 issue "letsencrypt.org"');
});
it("keeps a line it cannot read rather than dropping it", () => {
expect(parseZoneFile("something unexpected")).toEqual([{ name: "", type: "", value: "something unexpected", line: "something unexpected" }]);
});
});
describe("domain names", () => {
it("are written back lower-case without the root dot", () => {
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
});
it("are checked loosely before the server decides", () => {
expect(looksLikeDomain("mail.example.co.uk")).toBe(true);
expect(looksLikeDomain("example")).toBe(false);
expect(looksLikeDomain("exa mple.com")).toBe(false);
expect(looksLikeDomain("-bad.example.com")).toBe(false);
});
});
describe("explaining what still uses a domain", () => {
it("counts by kind", () => {
expect(describeLinked(["Account", "Account", "DkimSignature", "MailingList", "Whatever"])).toBe("2 accounts, 1 DKIM key, 1 mailing list, 1 other item");
});
it("names a key's algorithm from its type", () => {
expect(dkimAlgorithm("Dkim1Ed25519Sha256")).toBe("Ed25519 · DKIM1");
expect(dkimAlgorithm("Dkim2RsaSha256")).toBe("RSA · DKIM2");
});
});
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countMembers, createGroup, destroyGroup, groupRoleKey, groupRolesFromKey, membershipPatch } from "@/lib/adminGroups";
describe("group membership", () => {
it("is a patch to each member, one pointer each, so no other membership moves", () => {
// Stalwart's set patch adds a key on `true` and removes it on `null`, and
// leaves every other key in the set as it was.
expect(membershipPatch(["u1", "u2"], "g1", true)).toEqual({ u1: { "memberGroupIds/g1": true }, u2: { "memberGroupIds/g1": true } });
expect(membershipPatch(["u1"], "g1", false)).toEqual({ u1: { "memberGroupIds/g1": null } });
});
it("counts members as users whose memberships name the group, asking for no ids", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 4 });
expect(await countMembers(["g1"])).toEqual(new Map([["g1", 4]]));
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberGroupIds: "g1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("leaves a count out rather than showing a failed one as none", async () => {
const call = vi.spyOn(client, "call").mockRejectedValue(new Error("offline"));
expect(await countMembers(["g1"])).toEqual(new Map());
call.mockRestore();
});
});
describe("creating and deleting a group", () => {
it("creates an account of type Group, with nothing a person needs to sign in", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "g9" } } });
expect(await createGroup({ name: " sales ", domainId: "d1", description: "", roles: { "@type": "Default" }, diskQuotaBytes: null })).toBe("g9");
const create = (call.mock.calls[0]![1] as { create: { n: Record<string, unknown> } }).create.n;
expect(create).toMatchObject({ "@type": "Group", name: "sales", domainId: "d1", description: null, roles: { "@type": "Default" }, permissions: { "@type": "Inherit" }, quotas: {} });
expect(create).not.toHaveProperty("credentials");
expect(create).not.toHaveProperty("encryptionAtRest");
expect(create).not.toHaveProperty("memberGroupIds");
call.mockRestore();
});
it("takes the members out before deleting, and deletes nothing if that fails", async () => {
const call = vi.spyOn(client, "call").mockResolvedValueOnce({ updated: { u1: null } }).mockResolvedValueOnce({ destroyed: ["g1"] });
await destroyGroup("g1", ["u1"]);
expect(call.mock.calls.map((c) => [c[0], Object.keys(c[1] as object)])).toEqual([
["x:Account/set", ["update"]],
["x:Account/set", ["destroy"]],
]);
call.mockReset();
call.mockResolvedValueOnce({ notUpdated: { u1: { type: "forbidden" } } });
await expect(destroyGroup("g1", ["u1"])).rejects.toMatchObject({ type: "forbidden" });
expect(call).toHaveBeenCalledTimes(1);
call.mockRestore();
});
it("goes straight to the delete for a group with no members", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ destroyed: ["g1"] });
await destroyGroup("g1", []);
expect(call).toHaveBeenCalledTimes(1);
expect(call).toHaveBeenCalledWith("x:Account/set", { destroy: ["g1"] });
call.mockRestore();
});
it("round-trips a group's roles, which are Default or Custom", () => {
for (const roles of [{ "@type": "Default" } as const, { "@type": "Custom", roleIds: { r1: true, r2: true } } as const]) {
expect(groupRolesFromKey(groupRoleKey(roles))).toEqual(roles);
}
});
});
-48
View File
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { createList, parseAddresses, queryLists, recipientsPatch } from "@/lib/adminLists";
describe("a mailing list's recipients", () => {
it("are saved as what was added and removed, one pointer each", () => {
// The live server adds a set key on `true`, removes it on `null`, and
// leaves the rest -- so a recipient added elsewhere meanwhile survives.
expect(recipientsPatch(["[email protected]", "[email protected]"], ["[email protected]", "[email protected]"])).toEqual({
"recipients/[email protected]": null,
"recipients/[email protected]": true,
});
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
});
it("compare without regard to case, and escape what a pointer cannot hold", () => {
expect(recipientsPatch(["[email protected]"], ["[email protected]"])).toEqual({});
expect(recipientsPatch([], ["odd/[email protected]"])).toEqual({ "recipients/[email protected]": true });
});
it("come out of a paste of names, commas and angle brackets, and keep what isn't an address", () => {
expect(parseAddresses('Ada Lovelace <[email protected]>, [email protected]; "Alan" [email protected]\[email protected] mailto:[email protected]')).toEqual({
addresses: ["[email protected]", "[email protected]", "[email protected]", "[email protected]"],
rejected: [],
});
expect(parseAddresses("ada@, @example.org, someone@nowhere")).toEqual({ addresses: [], rejected: ["ada@", "@example.org", "someone@nowhere"] });
});
});
describe("the list calls", () => {
it("search on text, and leave the filter out when there is none", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ ids: [], total: 0 });
await queryLists({ text: " board ", position: 50, limit: 50 });
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { filter: { text: "board" }, position: 50, limit: 50, calculateTotal: true });
await queryLists({});
expect(call).toHaveBeenLastCalledWith("x:MailingList/query", { position: 0, calculateTotal: true });
call.mockRestore();
});
it("create one with its recipients as a set", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ created: { n: { id: "l9" } } });
expect(await createList({ name: "team", domainId: "d1", description: " ", recipients: ["[email protected]", "[email protected]"] })).toBe("l9");
expect(call).toHaveBeenCalledWith("x:MailingList/set", {
create: { n: { name: "team", domainId: "d1", description: null, recipients: { "[email protected]": true, "[email protected]": true }, aliases: {} } },
});
call.mockRestore();
});
});
-41
View File
@@ -1,41 +0,0 @@
import { describe, expect, it } from "vitest";
import { permissionSet } from "@/lib/adminAccess";
import { canBuildOn, effectivePermissions, inherited, roleOutranks, setPatch, type DirectoryRole } from "@/lib/adminRoles";
const flags = (...n: string[]) => Object.fromEntries(n.map((x) => [x, true]));
const roles = new Map<string, DirectoryRole>([
["user", { id: "user", description: "User", enabledPermissions: flags("jmapEmailGet", "jmapEmailUpdate") }],
["help", { id: "help", description: "Helpdesk", enabledPermissions: flags("sysAccountGet"), disabledPermissions: flags("jmapEmailUpdate"), roleIds: flags("user") }],
["lead", { id: "lead", description: "Lead", enabledPermissions: flags("sysAccountUpdate"), roleIds: flags("help") }],
]);
describe("what a role holds", () => {
it("follows every base, and a denial anywhere in the tree wins", () => {
// Stalwart unions enabled with enabled and disabled with disabled across
// the tree, then takes the disabled away (permissions.rs).
expect([...effectivePermissions(roles.get("lead")!, roles, "lead")].sort()).toEqual(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
const { granted, denied } = inherited(["help"], roles, "lead");
expect(granted.get("jmapEmailGet")).toBe("help");
expect(denied.get("jmapEmailUpdate")).toBe("help");
});
it("changes a set one pointer at a time", () => {
expect(setPatch("enabledPermissions", ["a", "b"], new Set(["b", "c"]))).toEqual({ "enabledPermissions/a": null, "enabledPermissions/c": true });
expect(setPatch("roleIds", [], [])).toEqual({});
});
it("will not build on itself, or on a role already built on it", () => {
expect(canBuildOn("help", "help", roles)).toBe(false);
expect(canBuildOn("help", "lead", roles)).toBe(false);
expect(canBuildOn("lead", "user", roles)).toBe(true);
expect(canBuildOn(null, "lead", roles)).toBe(true);
});
it("is read-only to a viewer missing anything enabled in its tree, denied or not", () => {
const viewer = permissionSet(["jmapEmailGet", "sysAccountGet", "sysAccountUpdate"]);
// jmapEmailUpdate is denied on Helpdesk but enabled on User beneath it: a
// grant Stalwart would check, and a delete it would not.
expect(roleOutranks(viewer, roles.get("lead")!, roles)).toBe(true);
expect(roleOutranks(permissionSet([...viewer, "jmapEmailUpdate"]), roles.get("lead")!, roles)).toBe(false);
});
});
@@ -1,49 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { client } from "@/jmap/client";
import { countTenantMembers, drawableLogo, quotasPatch, setDomainTenant } from "@/lib/adminTenants";
describe("a tenant's limits", () => {
it("change one pointer each, leaving the quotas ihasmail does not offer alone", () => {
const before = { maxAccounts: 25, maxDomains: 2, maxOauthClients: 7 };
expect(quotasPatch(before, { maxAccounts: 30, maxDomains: null, maxGroups: 5, maxRoles: null })).toEqual({
"quotas/maxAccounts": 30,
"quotas/maxDomains": null,
"quotas/maxGroups": 5,
});
expect(quotasPatch(before, { maxAccounts: 25 })).toEqual({});
});
});
describe("what a tenant holds", () => {
it("is counted with a memberTenantId filter per kind, users and groups apart", async () => {
const call = vi.spyOn(client, "call").mockImplementation(async (method, args) => {
const f = (args as { filter: Record<string, unknown> }).filter;
if (method === "x:Role/query") throw new Error("forbidden");
return { total: method === "x:Account/query" && f["@type"] === "Group" ? 2 : 1 };
});
expect(await countTenantMembers("t1")).toEqual({ accounts: 1, groups: 2, lists: 1, domains: 1, dkimKeys: 1 });
expect(call).toHaveBeenCalledWith("x:Account/query", { filter: { "@type": "User", memberTenantId: "t1" }, limit: 0, calculateTotal: true });
expect(call).toHaveBeenCalledWith("x:Domain/query", { filter: { memberTenantId: "t1" }, limit: 0, calculateTotal: true });
call.mockRestore();
});
it("moves a domain in and out by its memberTenantId", async () => {
const call = vi.spyOn(client, "call").mockResolvedValue({ updated: { d4: null } });
await setDomainTenant("d4", "t1");
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: "t1" } } });
await setDomainTenant("d4", null);
expect(call).toHaveBeenLastCalledWith("x:Domain/set", { update: { d4: { memberTenantId: null } } });
call.mockRestore();
});
});
describe("a tenant's logo", () => {
it("is drawn only from https or an image data URL", () => {
expect(drawableLogo("https://example.com/logo.png")).toBe("https://example.com/logo.png");
expect(drawableLogo("data:image/png;base64,AAAA")).toBe("data:image/png;base64,AAAA");
expect(drawableLogo("http://example.com/logo.png")).toBeNull();
expect(drawableLogo("javascript:alert(1)")).toBeNull();
expect(drawableLogo("data:text/html;base64,AAAA")).toBeNull();
expect(drawableLogo(null)).toBeNull();
});
});
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, it } from "vitest";
import { appointmentDraft, nextHalfHour } from "@/lib/appointment";
import type { Email, EmailBodyPart } from "@/jmap/types";
/**
* A reminder made out of a mail: the subject becomes the title and the body
* becomes the description, and the reader supplies the one thing the message
* cannot — when it happens. What these pin is that the copy is faithful and
* bounded, because everything else about the event is the editor's job.
*/
function part(partId: string, type: string): EmailBodyPart {
return { partId, type } as EmailBodyPart;
}
function email(parts: Partial<Email>): Email {
return { id: "m1", subject: null, ...parts } as Email;
}
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
const key = type === "text/plain" ? "textBody" : "htmlBody";
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
}
const text = (value: string) => body("Water bill", "text/plain", value);
describe("the time an appointment starts", () => {
it("rounds up to the next half hour", () => {
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("moves on from a time already on the boundary, rather than starting now", () => {
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("runs for an hour", () => {
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
expect(d.allDay).toBe(false);
});
});
describe("what is copied from the message", () => {
it("takes the subject as the title and the body as the description", () => {
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
expect(d.title).toBe("Water bill");
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
});
it("reads an HTML-only message as text, so the description is not markup", () => {
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
expect(d.description).toBe("Renews Friday");
});
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
expect(appointmentDraft(email({ subject: null })).title).toBe("");
});
/*
* A newsletter is a message too. The whole body would be stored on the
* event, synced everywhere, and shown in a three-row box, so the tail is
* dropped — visibly, so a truncated bill is not read as the whole of it.
*/
it("truncates a body too long to be a description", () => {
const d = appointmentDraft(text("x".repeat(9000)));
expect(d.description).toHaveLength(5001);
expect(d.description.endsWith("…")).toBe(true);
});
});
const between = (parts: Partial<Email>) => email({ subject: "Kickoff", ...parts });
const addr = (email: string, name: string | null = null) => ({ name, email });
describe("who is invited", () => {
it("carries the sender and everyone it was addressed to", () => {
const d = appointmentDraft(
between({ from: [addr("[email protected]", "Grace")], to: [addr("[email protected]"), addr("[email protected]")], cc: [addr("[email protected]")] }),
new Date(),
["[email protected]"],
);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]", "[email protected]", "[email protected]"]);
expect(d.attendees[0]?.name).toBe("Grace");
});
it("leaves the reader out, whatever case their address was written in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("counts someone once, however many headers they appear in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], cc: [addr("[email protected]")] }));
expect(d.attendees).toHaveLength(1);
});
/*
* On a message the reader sent, a blind copy is still a recipient — and
* putting one on a guest list shows them to every other guest. Turning a
* hidden copy into a visible one is not something a menu item may do.
*/
it("never turns a blind copy into a guest", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], bcc: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("invites nobody when the message has no addresses at all", () => {
expect(appointmentDraft(between({})).attendees).toEqual([]);
});
});
@@ -1,113 +0,0 @@
import { describe, expect, it } from "vitest";
import { availabilityWindow } from "@/lib/availabilityWindow";
const at = (s: string) => new Date(s);
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
describe("the span an availability bar covers", () => {
it("covers the whole day for an event inside one", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
expect(w.start.getHours()).toBe(0);
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
expect(w.end.getHours()).toBe(0);
});
it("stretches to cover an event running over several days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
expect(w.days).toBe(3);
expect(w.start.getDate()).toBe(2);
expect(w.end.getDate()).toBe(5);
});
it("ends an event on the day it ends on, not the midnight it stops at", () => {
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
// not touch the 3rd and the bar should not show it.
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
});
it("never collapses to nothing, even when start and end are the same moment", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
expect(w.days).toBe(1);
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labeling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
});
it("thins the marks out to every six hours across two days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
});
it("marks day boundaries once there are more than two", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
expect(w.scale).toBe("days");
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
expect(w.ticks.every((t) => t.major)).toBe(true);
});
it("puts every mark at its true fraction of the span", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.ticks[0]!.at).toBe(0);
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
});
it("stops at a week and says how much it left out", () => {
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
expect(w.days).toBe(7);
expect(w.daysHidden).toBe(23);
});
it("hides nothing when the event fits", () => {
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
});
it("lands on real midnights, and measures the span between them", () => {
/*
* The span is what every position is a fraction of, so it has to be the
* distance between the two boundaries rather than a count of 24-hour days:
* on the day a clock changes those differ by an hour, which would end the
* bar early and put every block after the change in the wrong place. This
* asserts the relationship; whether the run happens to sit in a zone with
* DST is not something a test should depend on.
*/
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
expect(w.start.getHours(), day).toBe(0);
expect(w.end.getHours(), day).toBe(0);
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
}
});
});
describe("looking around the event without changing it", () => {
it("slides the whole window forward, keeping its width", () => {
const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 });
expect(later.days).toBe(here.days);
expect(later.start.getDate()).toBe(5);
expect(later.end.getDate()).toBe(8);
});
it("slides backwards, across the end of a month", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 });
expect(w.start.getMonth()).toBe(7); // August
expect(w.start.getDate()).toBe(30);
expect(w.days).toBe(1);
});
it("keeps the marks in step with where the window moved to", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 });
expect(w.ticks[0]!.time.getDate()).toBe(3);
expect(w.ticks[0]!.at).toBe(0);
});
});
+1 -1
View File
@@ -7,7 +7,7 @@
*/
import { describe, expect, it } from "vitest";
import { describeRule as describeSieve } from "../sieve";
import { describeRule as describeRecurrence, weekdayOptions } from "../recurrence";
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
-230
View File
@@ -1,230 +0,0 @@
import { describe, expect, it } from "vitest";
import {
canDragEvent,
formatDuration,
MIN_DURATION_MINUTES,
movedBy,
movedToDay,
pixelsToMinutes,
resizedBy,
snap,
movePatch,
moveByDaysPatch,
dayDelta,
resizePatch,
SNAP_MINUTES,
} from "@/lib/eventDrag";
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
import type { CalendarEvent } from "@/jmap/types";
const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0);
const span = (from: Date, to: Date) => ({ start: from, end: to });
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
describe("snap", () => {
it("rounds to the nearest quarter hour", () => {
expect(snap(0)).toBe(0);
expect(snap(7)).toBe(0);
expect(snap(8)).toBe(15);
expect(snap(22)).toBe(15);
expect(snap(23)).toBe(30);
expect(snap(-8)).toBe(-15);
});
it("takes another slot when asked", () => {
expect(snap(20, 30)).toBe(30);
expect(snap(14, 30)).toBe(0);
});
});
describe("movedBy", () => {
it("moves both ends, so the length does not change", () => {
const out = movedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:30");
expect(hhmm(out.end)).toBe("15:30");
});
it("snaps the drag rather than taking it literally", () => {
const out = movedBy(span(at(14), at(15)), 7);
expect(hhmm(out.start)).toBe("14:00");
});
it("moves backwards too", () => {
const out = movedBy(span(at(14), at(15)), -60);
expect(hhmm(out.start)).toBe("13:00");
expect(hhmm(out.end)).toBe("14:00");
});
it("carries an event across midnight without losing its length", () => {
const out = movedBy(span(at(23, 30), at(23, 45)), 60);
expect(ymd(out.start)).toBe("2026-09-05");
expect(hhmm(out.start)).toBe("00:30");
expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000);
});
});
describe("movedToDay", () => {
it("keeps the time of day, which is what the month grid is not asking about", () => {
// Dragged from Friday to Monday: still at two o'clock.
const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7));
expect(ymd(out.start)).toBe("2026-09-07");
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("keeps a length that spans days", () => {
const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20));
expect(ymd(out.start)).toBe("2026-09-20");
expect(ymd(out.end)).toBe("2026-09-22");
});
it("moves across a month boundary", () => {
const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1));
expect(ymd(out.start)).toBe("2026-10-01");
expect(hhmm(out.start)).toBe("09:00");
});
});
describe("resizedBy", () => {
it("moves the end and leaves the start alone", () => {
const out = resizedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("clamps at one slot rather than refusing the drag", () => {
// A drag that goes too far is still a drag; stopping is what the reader
// sees happening while they do it.
const out = resizedBy(span(at(14), at(15)), -600);
expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000);
expect(hhmm(out.end)).toBe("14:15");
});
it("never lets the end cross the start", () => {
for (const delta of [-60, -120, -1000]) {
const out = resizedBy(span(at(9), at(9, 30)), delta);
expect(out.end.getTime()).toBeGreaterThan(out.start.getTime());
}
});
});
describe("formatDuration", () => {
it("writes the shapes the wire expects", () => {
expect(formatDuration(3600)).toBe("PT1H");
expect(formatDuration(5400)).toBe("PT1H30M");
expect(formatDuration(900)).toBe("PT15M");
expect(formatDuration(86400)).toBe("P1D");
expect(formatDuration(90000)).toBe("P1DT1H");
expect(formatDuration(0)).toBe("PT0S");
expect(formatDuration(45)).toBe("PT45S");
});
});
describe("the patch a drag sends, computed in the event's own frame", () => {
/*
* The bug this shape exists to prevent: working the new time out from the
* reader's local hours and then re-expressing it in the event's zone
* converts twice, and the two do not cancel. An event two hours from the
* reader jumped two hours the first time it was dragged and then sat still.
* None of these functions touches a zone at all.
*/
it("moves the stored start by the snapped delta", () => {
expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" });
expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" });
expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" });
});
it("carries a move across midnight and across a month", () => {
expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" });
});
it("never sends a duration for a move, so the length is left alone", () => {
expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined();
});
it("keeps the time of day when moving by whole days", () => {
expect(moveByDaysPatch("2026-09-04T14:30:00", 6)).toEqual({ start: "2026-09-10T14:30:00" });
expect(moveByDaysPatch("2026-09-04T14:30:00", -3)).toEqual({ start: "2026-09-01T14:30:00" });
});
it("moves by the delta the hand made, not to the date that was dropped on", () => {
/*
* The month grid's cells are local days; the stored date is in the event's
* own zone. Writing the dropped-on date put a Tokyo event dropped on the
* 11th onto the 10th, because 15:00 in Tokyo is the previous evening in
* Phoenix — it went where its own calendar said, not where the pointer did.
*/
const storedTokyo = "2026-09-04T15:00:00"; // shown to a Phoenix reader on the 3rd
const shownOn = new Date(2026, 8, 3);
const droppedOn = new Date(2026, 8, 11);
const patch = moveByDaysPatch(storedTokyo, dayDelta(shownOn, droppedOn));
// Eight days later in its own frame, so eight days later on screen too.
expect(patch).toEqual({ start: "2026-09-12T15:00:00" });
});
it("counts whole local days, ignoring the time on either side", () => {
expect(dayDelta(new Date(2026, 8, 3, 23, 30), new Date(2026, 8, 4, 0, 30))).toBe(1);
expect(dayDelta(new Date(2026, 8, 4), new Date(2026, 8, 4))).toBe(0);
expect(dayDelta(new Date(2026, 8, 11), new Date(2026, 8, 3))).toBe(-8);
expect(dayDelta(new Date(2026, 8, 30), new Date(2026, 9, 2))).toBe(2);
});
it("never sends a start for a resize, so the zone question does not arise", () => {
const patch = resizePatch(3600, 60);
expect(patch).toEqual({ duration: "PT2H" });
expect(patch.start).toBeUndefined();
});
it("clamps a resize at one slot", () => {
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
});
it("says nothing at all about a start it cannot read", () => {
expect(movePatch("not a date", 30)).toEqual({});
expect(moveByDaysPatch("", 3)).toEqual({});
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
});
});
describe("canDragEvent", () => {
const writable = { myRights: { mayWriteAll: true } };
const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } };
const event = { id: "e1" } as CalendarEvent;
it("allows a normal event on a calendar you can write to", () => {
expect(canDragEvent(event, writable)).toBe(true);
expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true);
});
it("refuses a birthday, which is derived and has nothing to move", () => {
expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false);
});
it("refuses a calendar you cannot write to, and one that is not there", () => {
expect(canDragEvent(event, readonly)).toBe(false);
expect(canDragEvent(event, undefined)).toBe(false);
});
it("refuses nothing at all", () => {
expect(canDragEvent(null, writable)).toBe(false);
});
});
describe("pixelsToMinutes", () => {
it("converts against the grid's own scale", () => {
expect(pixelsToMinutes(48, 48)).toBe(60);
expect(pixelsToMinutes(24, 48)).toBe(30);
expect(pixelsToMinutes(-48, 48)).toBe(-60);
});
it("says nothing rather than dividing by zero before the grid is measured", () => {
expect(pixelsToMinutes(100, 0)).toBe(0);
});
it("round-trips through snap to the slot the pointer is over", () => {
expect(snap(pixelsToMinutes(10, 48))).toBe(15);
expect(snap(pixelsToMinutes(2, 48))).toBe(0);
expect(SNAP_MINUTES).toBe(15);
});
});
-187
View File
@@ -1,187 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
describe("unfold", () => {
it("joins a continuation with nothing between, per the RFC", () => {
expect(unfold("SUMMARY:A very\r\n long title")).toEqual(["SUMMARY:A very long title"]);
expect(unfold("SUMMARY:A\r\n\tB")).toEqual(["SUMMARY:AB"]);
});
it("handles all three line endings", () => {
expect(unfold("A\r\nB\nC\rD")).toEqual(["A", "B", "C", "D"]);
});
it("does not treat a leading space on the first line as a continuation", () => {
expect(unfold(" oops")).toEqual([" oops"]);
});
});
describe("parseLine", () => {
it("splits a plain property", () => {
expect(parseLine("SUMMARY:Standup")).toEqual({ name: "SUMMARY", params: {}, value: "Standup" });
});
it("reads parameters", () => {
expect(parseLine("DTSTART;VALUE=DATE:20260904")).toEqual({
name: "DTSTART",
params: { VALUE: "DATE" },
value: "20260904",
});
});
it("ignores a colon inside a quoted parameter, which is a real shape", () => {
// A naive indexOf(":") reads this as a property called DTSTART;TZID="GMT+01
const line = parseLine('DTSTART;TZID="GMT+01:00":20260904T140000');
expect(line?.name).toBe("DTSTART");
expect(line?.value).toBe("20260904T140000");
expect(line?.params.TZID).toBe("GMT+01:00");
});
it("uppercases the name, since the RFC does not require any particular case", () => {
expect(parseLine("summary:x")?.name).toBe("SUMMARY");
});
it("says nothing about a line with no colon", () => {
expect(parseLine("NONSENSE")).toBeNull();
expect(parseLine("")).toBeNull();
});
});
describe("unescapeText", () => {
it("undoes the four escapes and leaves everything else", () => {
expect(unescapeText("a\\nb")).toBe("a\nb");
expect(unescapeText("a\\Nb")).toBe("a\nb");
expect(unescapeText("a\\,b\\;c")).toBe("a,b;c");
expect(unescapeText("a\\\\b")).toBe("a\\b");
expect(unescapeText("100% \\real")).toBe("100% \\real");
});
});
describe("parseDateValue", () => {
it("reads a date as all-day in local time, not UTC midnight", () => {
// UTC midnight lands on the day before for anyone west of Greenwich.
const out = parseDateValue("20260904");
expect(out?.allDay).toBe(true);
expect(ymd(out!.date)).toBe("2026-09-04");
expect(hhmm(out!.date)).toBe("00:00");
});
it("respects VALUE=DATE even on a longer string", () => {
expect(parseDateValue("20260904", { VALUE: "DATE" })?.allDay).toBe(true);
});
it("reads a UTC instant", () => {
const out = parseDateValue("20260904T140000Z");
expect(out?.allDay).toBe(false);
expect(out?.date.toISOString()).toBe("2026-09-04T14:00:00.000Z");
});
it("reads a floating wall clock as local time", () => {
const out = parseDateValue("20260904T140000");
expect(out?.allDay).toBe(false);
expect(hhmm(out!.date)).toBe("14:00");
expect(ymd(out!.date)).toBe("2026-09-04");
});
it("says nothing about a value it cannot read", () => {
expect(parseDateValue("not a date")).toBeNull();
expect(parseDateValue("")).toBeNull();
});
});
describe("parseIcsDuration", () => {
it("reads the forms a DTEND substitute uses", () => {
expect(parseIcsDuration("PT1H")).toBe(3600);
expect(parseIcsDuration("PT30M")).toBe(1800);
expect(parseIcsDuration("P1D")).toBe(86400);
expect(parseIcsDuration("P1W")).toBe(604800);
expect(parseIcsDuration("P1DT2H30M")).toBe(95400);
expect(parseIcsDuration("-PT1H")).toBe(-3600);
});
it("says nothing about nonsense", () => {
expect(parseIcsDuration("1 hour")).toBeNull();
expect(parseIcsDuration("")).toBeNull();
});
});
describe("looksLikeCalendar", () => {
it("recognizes a calendar and rejects an error page", () => {
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
});
});
describe("parseIcs", () => {
it("reads a timed event with a summary and an end", () => {
const { events } = parseIcs(cal(event("UID:a@x\r\nSUMMARY:Standup\r\nDTSTART:20260904T090000Z\r\nDTEND:20260904T091500Z")));
expect(events).toHaveLength(1);
expect(events[0]!.summary).toBe("Standup");
expect(events[0]!.uid).toBe("a@x");
expect(events[0]!.allDay).toBe(false);
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(15 * 60_000);
});
it("reads an all-day event", () => {
const { events } = parseIcs(cal(event("UID:b@x\r\nSUMMARY:Holiday\r\nDTSTART;VALUE=DATE:20260904")));
expect(events[0]!.allDay).toBe(true);
expect(ymd(events[0]!.start)).toBe("2026-09-04");
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(86400_000);
});
it("takes DURATION when there is no DTEND", () => {
const { events } = parseIcs(cal(event("UID:c@x\r\nDTSTART:20260904T090000Z\r\nDURATION:PT90M")));
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(90 * 60_000);
});
it("reads the calendar's own name where it gives one", () => {
expect(parseIcs(cal(`X-WR-CALNAME:Team calendar\r\n${event("UID:d\r\nDTSTART:20260904T090000Z")}`)).name).toBe("Team calendar");
});
it("unfolds a long summary before reading it", () => {
const { events } = parseIcs(cal("BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260904T090000Z\r\nSUMMARY:A very\r\n long title\r\nEND:VEVENT"));
expect(events[0]!.summary).toBe("A very long title");
});
it("steps over components that are not events", () => {
const doc = cal(`BEGIN:VTIMEZONE\r\nTZID:Europe/London\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n${event("UID:f\r\nSUMMARY:Real\r\nDTSTART:20260904T090000Z")}\r\nBEGIN:VTODO\r\nSUMMARY:Not an event\r\nEND:VTODO`);
const { events } = parseIcs(doc);
expect(events.map((e) => e.summary)).toEqual(["Real"]);
});
it("counts a recurring event once and does not expand it", () => {
// Showing the wrong dates would be worse than showing the first and saying so.
const { events, recurringCount } = parseIcs(cal(event("UID:g\r\nSUMMARY:Weekly\r\nDTSTART:20260904T090000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10")));
expect(events).toHaveLength(1);
expect(events[0]!.recurring).toBe(true);
expect(recurringCount).toBe(1);
});
it("drops an event with no usable start rather than inventing a time", () => {
const { events } = parseIcs(cal(event("UID:h\r\nSUMMARY:When?")));
expect(events).toEqual([]);
});
it("repairs an end that is before its start", () => {
const { events } = parseIcs(cal(event("UID:i\r\nDTSTART:20260904T100000Z\r\nDTEND:20260904T090000Z")));
expect(events[0]!.end.getTime()).toBeGreaterThanOrEqual(events[0]!.start.getTime());
});
it("gives an event with no UID one of its own, so keys stay unique", () => {
const { events } = parseIcs(cal(`${event("SUMMARY:One\r\nDTSTART:20260904T090000Z")}\r\n${event("SUMMARY:Two\r\nDTSTART:20260905T090000Z")}`));
expect(events).toHaveLength(2);
expect(events[0]!.uid).not.toBe(events[1]!.uid);
});
it("reads several events, and survives an empty document", () => {
const many = cal([1, 2, 3].map((n) => event(`UID:m${n}\r\nSUMMARY:E${n}\r\nDTSTART:2026090${n}T090000Z`)).join("\r\n"));
expect(parseIcs(many).events.map((e) => e.summary)).toEqual(["E1", "E2", "E3"]);
expect(parseIcs("").events).toEqual([]);
expect(parseIcs("<!doctype html>").events).toEqual([]);
});
});
-305
View File
@@ -1,305 +0,0 @@
import { describe, expect, it } from "vitest";
import { toIcs, parseIcs } from "@/lib/ics";
import type { JSCalendarEvent } from "@/jmap/types";
/*
* Writing iCalendar out of the server's RFC 8984 objects.
*
* The properties worth pinning are the ones where the two formats disagree, or
* where getting it wrong shows up as a wrong time rather than as an error: how
* a zone is said, what UNTIL is measured in, and where a changed occurrence
* goes.
*/
const base: JSCalendarEvent = {
"@type": "Event", uid: "[email protected]", title: "Kickoff",
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Europe/Berlin",
};
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
/*
* From the first event onwards. The zone definitions above carry DTSTART and
* TZNAME of their own, and a test asking "what is this event's DTSTART" must
* not be answered by a transition rule.
*/
const eventLines = (e: JSCalendarEvent[]) => {
const all = lines(e);
return all.slice(all.indexOf("BEGIN:VEVENT"));
};
const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l) => l.startsWith(prefix));
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
describe("the document around the events", () => {
it("is a calendar a reader will recognize", () => {
const l = lines([base]);
expect(l[0]).toBe("BEGIN:VCALENDAR");
expect(l).toContain("VERSION:2.0");
expect(l).toContain("END:VCALENDAR");
expect(l.some((x) => x.startsWith("PRODID:"))).toBe(true);
});
it("carries the calendar's name where a reader will look for it", () => {
expect(lines([base], "Work")).toContain("X-WR-CALNAME:Work");
});
it("ends every line the way the format requires", () => {
expect(toIcs([base]).endsWith("\r\n")).toBe(true);
expect(toIcs([base]).includes("\n\n")).toBe(false);
});
});
describe("times and zones", () => {
it("names the zone rather than converting, so a series survives a DST change", () => {
expect(one(base, "DTSTART")).toBe("DTSTART;TZID=Europe/Berlin:20260902T090000");
});
it("writes UTC as UTC", () => {
expect(one({ ...base, timeZone: "Etc/UTC" }, "DTSTART")).toBe("DTSTART:20260902T090000Z");
});
it("leaves a floating time floating, with no zone at all", () => {
// No zone means "whatever clock the reader is on", which is a real and
// different thing from UTC -- a 09:00 alarm clock, not an instant.
expect(one({ ...base, timeZone: null }, "DTSTART")).toBe("DTSTART:20260902T090000");
});
it("writes an all-day event as a date, not as midnight", () => {
const e = { ...base, showWithoutTime: true, duration: "P1D" };
expect(one(e, "DTSTART")).toBe("DTSTART;VALUE=DATE:20260902");
});
it("keeps the duration rather than working out an end", () => {
expect(one(base, "DURATION")).toBe("DURATION:PT1H");
});
it("says nothing about duration when the event has none", () => {
expect(find([{ ...base, duration: undefined }], "DURATION")).toEqual([]);
});
});
describe("recurrence", () => {
const weekly = { ...base, recurrenceRule: { frequency: "weekly" as const, byDay: [{ day: "we" as const }] } };
it("writes the rule rather than expanding it into a year of events", () => {
expect(one(weekly, "RRULE")).toBe("RRULE:FREQ=WEEKLY;BYDAY=WE");
expect(find([weekly], "BEGIN:VEVENT")).toHaveLength(1);
});
it("reads the array form as well as the single rule Stalwart stores", () => {
const e = { ...base, recurrenceRules: [{ frequency: "monthly" as const, interval: 2, count: 5 }] };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5");
});
it("measures UNTIL in UTC, so a series does not stop a day early elsewhere", () => {
const e = { ...base, recurrenceRule: { frequency: "weekly" as const, until: "2026-12-30T09:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=WEEKLY;UNTIL=20261230T090000Z");
});
it("measures UNTIL as a date when the series is all-day", () => {
const e = { ...base, showWithoutTime: true, recurrenceRule: { frequency: "daily" as const, until: "2026-12-30T00:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=DAILY;UNTIL=20261230");
});
it("keeps the nth-weekday form that BYDAY carries a number for", () => {
const e = { ...base, recurrenceRule: { frequency: "monthly" as const, byDay: [{ day: "th" as const, nthOfPeriod: -1 }] } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
});
it("turns a canceled occurrence into an EXDATE", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
});
it("treats an override marked excluded the same way", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
});
it("gives a changed occurrence its own event, sharing the uid", () => {
/*
* Which is how iCalendar has always said it: the same UID, plus the
* RECURRENCE-ID of the slot being replaced. The master keeps its rule and
* the override must not.
*/
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Kickoff (moved)" } } };
const l = lines([e]);
expect(l.filter((x) => x === "BEGIN:VEVENT")).toHaveLength(2);
expect(l.filter((x) => x === "UID:[email protected]")).toHaveLength(2);
expect(l).toContain("RECURRENCE-ID;TZID=Europe/Berlin:20260909T090000");
expect(l).toContain("SUMMARY:Kickoff (moved)");
// One RRULE in the file, on the master.
expect(l.filter((x) => x.startsWith("RRULE:"))).toHaveLength(1);
});
});
describe("the rest of an event", () => {
it("escapes what the format uses as punctuation", () => {
const e = { ...base, title: "Budget; Q4, final", description: "line one\nline two" };
// Both escapes doubled here for JS's sake: what reaches the file is one
// backslash before each of the two characters the format reserves.
expect(one(e, "SUMMARY")).toBe("SUMMARY:Budget\\; Q4\\, final");
expect(one(e, "DESCRIPTION")).toBe("DESCRIPTION:line one\\nline two");
});
it("folds a long line rather than writing it past the limit", () => {
const e = { ...base, title: "x".repeat(200) };
for (const l of lines([e])) expect(l.length).toBeLessThanOrEqual(75);
});
it("puts a room in LOCATION and a video link in URL", () => {
// A meeting URL where a room name goes is what makes a printed agenda
// useless, and they are different fields in both formats.
const e = {
...base,
locations: { l1: { name: "Room 3" } },
virtualLocations: { v1: { uri: "https://meet.example.org/abc" } },
} as JSCalendarEvent;
expect(one(e, "LOCATION")).toBe("LOCATION:Room 3");
expect(one(e, "URL")).toBe("URL:https://meet.example.org/abc");
});
it("maps the words the two formats spell differently", () => {
const e = { ...base, status: "tentative" as const, privacy: "secret" as const, freeBusyStatus: "free" as const };
expect(one(e, "STATUS")).toBe("STATUS:TENTATIVE");
expect(one(e, "CLASS")).toBe("CLASS:CONFIDENTIAL");
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
});
it("writes the organizer and the guests, with what each answered", () => {
const e = {
...base,
organizerCalendarAddress: "mailto:[email protected]",
participants: {
p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:[email protected]", participationStatus: "accepted" as const, expectReply: true },
p2: { roles: { optional: true }, sendTo: { imip: "mailto:[email protected]" }, participationStatus: "needs-action" as const },
},
} as JSCalendarEvent;
expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:[email protected]");
const att = find([e], "ATTENDEE");
expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:[email protected]");
expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:[email protected]");
});
it("skips a participant with no address at all rather than writing a broken line", () => {
const e = { ...base, participants: { p1: { roles: { attendee: true }, name: "Nobody" } } } as JSCalendarEvent;
expect(find([e], "ATTENDEE")).toEqual([]);
});
it("nests an alarm inside the event it belongs to", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "-PT15M" } } } } as JSCalendarEvent;
const l = lines([e]);
expect(l).toContain("BEGIN:VALARM");
expect(l).toContain("TRIGGER:-PT15M");
expect(l).toContain("ACTION:DISPLAY");
expect(l.indexOf("BEGIN:VALARM")).toBeLessThan(l.indexOf("END:VEVENT"));
});
it("says when an alarm hangs off the end rather than the start", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "PT5M", relativeTo: "end" as const } } } } as JSCalendarEvent;
expect(one(e, "TRIGGER")).toBe("TRIGGER;RELATED=END:PT5M");
});
});
describe("what comes back out of the parser", () => {
/*
* Not a full round trip -- the reader is a subscription parser and keeps far
* less than the writer emits -- but what it does read should be what went in.
*/
it("reads back the events it wrote", () => {
const two = [base, { ...base, uid: "[email protected]", title: "Retro", start: "2026-09-09T14:00:00" }];
const back = parseIcs(toIcs(two));
expect(back.events.map((e) => e.uid)).toEqual(["[email protected]", "[email protected]"]);
expect(back.events.map((e) => e.summary)).toEqual(["Kickoff", "Retro"]);
});
it("reads back a title that needed escaping, unescaped", () => {
const back = parseIcs(toIcs([{ ...base, title: "Budget; Q4, final" }]));
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
});
});
/*
* Time zone definitions.
*
* These exist because leaving them out was wrong, and measurably: ical.js --
* Mozilla's library, the one Thunderbird's calendar uses -- reads a TZID with
* nothing defining it as *floating*, so a 09:00 in Phoenix opened anywhere else
* reads as 09:00 there. Seven hours out, silently, on every timed event.
*/
describe("the zones an export names", () => {
const inZone = (uid: string, tz: string, start = "2026-09-02T09:00:00") =>
({ ...base, uid, timeZone: tz, start }) as JSCalendarEvent;
it("defines every zone its events refer to", () => {
const l = lines([inZone("a", "America/Phoenix"), inZone("b", "Asia/Tokyo")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(2);
expect(l).toContain("TZID:America/Phoenix");
expect(l).toContain("TZID:Asia/Tokyo");
});
it("defines a zone once however many events use it", () => {
const l = lines([inZone("a", "Europe/Berlin"), inZone("b", "Europe/Berlin"), inZone("c", "Europe/Berlin")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(1);
});
it("says nothing about UTC, which needs no definition", () => {
expect(lines([inZone("a", "Etc/UTC")]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("says nothing about an all-day event, which has no zone to define", () => {
const e = { ...base, showWithoutTime: true, timeZone: "Europe/Berlin" } as JSCalendarEvent;
expect(lines([e]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("writes a zone that never changes as one standing rule", () => {
// Phoenix keeps MST all year: one sub-component, and the two offsets equal.
const l = lines([inZone("a", "America/Phoenix")]);
expect(l.filter((x) => x === "BEGIN:DAYLIGHT")).toHaveLength(0);
expect(l.filter((x) => x === "BEGIN:STANDARD")).toHaveLength(1);
expect(l).toContain("TZOFFSETFROM:-0700");
expect(l).toContain("TZOFFSETTO:-0700");
expect(l).toContain("TZNAME:MST");
});
it("finds the transitions of a zone that does change", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
// Both directions, and at the hours the EU actually changes at.
expect(l).toContain("DTSTART:20260329T020000");
expect(l).toContain("DTSTART:20261025T030000");
const spring = l.indexOf("DTSTART:20260329T020000");
expect(l[spring - 1]).toBe("BEGIN:DAYLIGHT");
expect(l[spring + 1]).toBe("TZOFFSETFROM:+0100");
expect(l[spring + 2]).toBe("TZOFFSETTO:+0200");
});
it("covers years around the events rather than only the year they fall in", () => {
// An open-ended weekly meeting outlives the year it was created in, so a
// definition that stopped at that year would leave later occurrences
// undefined.
const l = lines([inZone("a", "Europe/Berlin")]);
const years = new Set(l.filter((x) => x.startsWith("DTSTART:")).map((x) => x.slice(8, 12)));
expect(years.size).toBeGreaterThan(5);
expect([...years].some((y) => Number(y) > 2030)).toBe(true);
});
it("leaves out a zone name that only repeats the offset", () => {
// Intl answers "GMT+9" for Tokyo, which says nothing TZOFFSETTO has not.
const l = lines([inZone("a", "Asia/Tokyo")]);
expect(l.some((x) => x.startsWith("TZNAME:GMT"))).toBe(false);
expect(l).toContain("TZOFFSETTO:+0900");
});
it("says nothing at all about a zone the browser does not know", () => {
// Rather than writing a definition made up out of nothing. The TZID stays
// on the event, which is where it was before any of this.
const l = lines([inZone("a", "Mars/Olympus_Mons")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
expect(l).toContain("DTSTART;TZID=Mars/Olympus_Mons:20260902T090000");
});
it("puts the definitions before the events that use them", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
expect(l.indexOf("BEGIN:VTIMEZONE")).toBeLessThan(l.indexOf("BEGIN:VEVENT"));
});
});