Files
ihasmail/web/src/lib/__tests__/appointment.test.ts
T
jcoffey-dev b862f61cde Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender
and everyone it was addressed to already in the guest list, so a thread
becomes a meeting without retyping the room.

Two things are deliberately left out. The reader's own addresses, since
they are the organiser and an organiser among their own guests is an
invitation to your own appointment. And a blind copy, on a message the
reader sent: a guest list is visible to every guest, so promoting a Bcc
to a guest would tell the room about a copy the sender chose to hide.
That is not something a menu item may do quietly.

*Send invitation emails to guests* now starts off when the guests were
inherited rather than typed, and on everywhere else -- which is every
event whose guests somebody chose one at a time. The reason is the case
the issue opened with: a reminder made out of a bill carries the biller
and everyone else on the mail. Left on, the primary button reads Send
invites and the first press mails all of them an invitation to what was
meant as a note to self. The switch sits right there under the list and
says what it does, so inviting them is one deliberate click. Un-sending
is not.
2026-08-31 23:07:18 -07:00

110 lines
4.8 KiB
TypeScript

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([]);
});
});