blob: 75979e162711734b36d17bb715f3c16ce0b170ce [file]
/** The import form: preview first, then say where the events go. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte";
import ImportDialog from "../components/ImportDialog.svelte";
import type { ImportCandidate } from "../lib/importing";
import { makeCalendars, makeEvent } from "./factories";
const noop = () => {};
const fetchMock = vi.fn();
const CANDIDATE: ImportCandidate = {
title: "Community call",
start: "2026-07-10T13:00:00Z",
end: "2026-07-10T14:00:00Z",
all_day: false,
description: "",
location: "Room 3",
url: "",
timezone: "Europe/Berlin",
uid: "1@t",
warnings: [],
};
function respond(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function previewOf(events: ImportCandidate[] = [CANDIDATE], warnings: string[] = []) {
return { events, count: events.length, warnings };
}
const pmcMember = makeCalendars({
uid: "bob",
projects: ["httpd"],
committees: ["httpd"],
can_create: { personal: true, project: ["httpd"], project_private: ["httpd"], foundation: false },
});
function props(overrides: Record<string, unknown> = {}) {
return {
calendars: pmcMember,
zone: "UTC",
onclose: noop,
onimported: noop,
...overrides,
};
}
function icsFile(name = "events.ics") {
return new File(["BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"], name, { type: "text/calendar" });
}
/** Puts a file into the dialog's file input the way a picker would. */
async function attach(container: HTMLElement, file = icsFile()) {
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
Object.defineProperty(input, "files", { value: [file], configurable: true });
await fireEvent.change(input);
}
function importButton(): HTMLButtonElement {
return screen.getByRole("button", { name: /^Import/ }) as HTMLButtonElement;
}
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("before a file is chosen", () => {
it("asks for one", () => {
render(ImportDialog, { props: props() });
expect(screen.getByText("Choose a file")).toBeInTheDocument();
expect(screen.getByText(/drop an .ics file here/)).toBeInTheDocument();
});
it("cannot import anything yet", () => {
render(ImportDialog, { props: props() });
expect(importButton()).toBeDisabled();
});
it("asks nothing about calendars yet", () => {
render(ImportDialog, { props: props() });
expect(screen.queryByLabelText("Calendar")).not.toBeInTheDocument();
});
});
describe("the preview", () => {
it("reads the file without saving it", async () => {
fetchMock.mockResolvedValue(respond(previewOf()));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("Community call")).toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/import/preview");
expect(init.method).toBe("POST");
expect(init.body).toBeInstanceOf(FormData);
});
it("sends the file as multipart, letting the browser set the boundary", async () => {
fetchMock.mockResolvedValue(respond(previewOf()));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
const init = fetchMock.mock.calls[0][1];
expect(init.headers["Content-Type"]).toBeUndefined();
expect((init.body as FormData).get("file")).toBeInstanceOf(File);
});
it("shows what was found, on the reader's clock", async () => {
fetchMock.mockResolvedValue(respond(previewOf()));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("Community call")).toBeInTheDocument());
expect(screen.getByText("1 event found")).toBeInTheDocument();
expect(screen.getByText(/10 July 2026, 13:00 - 14:00/)).toBeInTheDocument();
expect(screen.getByText("Room 3")).toBeInTheDocument();
});
it("counts several events", async () => {
fetchMock.mockResolvedValue(
respond(previewOf([CANDIDATE, { ...CANDIDATE, uid: "2@t", title: "ApacheCon" }])),
);
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("2 events found")).toBeInTheDocument());
});
it("shows the file's own warnings", async () => {
fetchMock.mockResolvedValue(respond(previewOf([CANDIDATE], ["Skipped 1 entry with no start time."])));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() =>
expect(screen.getByText("Skipped 1 entry with no start time.")).toBeInTheDocument(),
);
});
it("shows a warning attached to one event", async () => {
const repeating = { ...CANDIDATE, warnings: ["This event repeats."] };
fetchMock.mockResolvedValue(respond(previewOf([repeating])));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("This event repeats.")).toBeInTheDocument());
expect(screen.getByText(/1 thing to note/)).toBeInTheDocument();
});
it("renders recurrence overrides, which all carry the parent event's UID", async () => {
// A repeating event exported from Google or Outlook is one VEVENT with an
// RRULE plus one per modified occurrence, and every one of them repeats the
// same UID. Keying the list on the UID made a real file crash the dialog.
const override = { ...CANDIDATE, title: "Community call (moved)" };
fetchMock.mockResolvedValue(respond(previewOf([CANDIDATE, override])));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("2 events found")).toBeInTheDocument());
expect(screen.getByText("Community call (moved)")).toBeInTheDocument();
});
it("renders events with no UID at all", async () => {
const anonymous = { ...CANDIDATE, uid: null };
fetchMock.mockResolvedValue(
respond(previewOf([anonymous, { ...anonymous, title: "ApacheCon" }])),
);
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getByText("2 events found")).toBeInTheDocument());
expect(screen.getByText("ApacheCon")).toBeInTheDocument();
});
it("repeats a warning the file raises twice", async () => {
const twice = ["Two entries were skipped.", "Two entries were skipped."];
fetchMock.mockResolvedValue(respond(previewOf([CANDIDATE], twice)));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getAllByText("Two entries were skipped.")).toHaveLength(2));
});
it("repeats a warning one event raises twice", async () => {
const noisy = { ...CANDIDATE, warnings: ["Something was cut short.", "Something was cut short."] };
fetchMock.mockResolvedValue(respond(previewOf([noisy])));
const { container } = render(ImportDialog, { props: props() });
await attach(container);
await waitFor(() => expect(screen.getAllByText("Something was cut short.")).toHaveLength(2));
});
it("reports a file it cannot read", async () => {
fetchMock.mockResolvedValue(
respond({ error: "That does not look like an iCalendar (.ics) file.", field: "file" }, 400),
);
const { container } = render(ImportDialog, { props: props() });
await attach(container, new File(["nope"], "holiday.jpg", { type: "image/jpeg" }));
expect(await screen.findByRole("alert")).toHaveTextContent("does not look like an iCalendar");
expect(importButton()).toBeDisabled();
});
});
describe("choosing where the events go", () => {
async function withPreview() {
fetchMock.mockResolvedValue(respond(previewOf()));
const rendered = render(ImportDialog, { props: props() });
await attach(rendered.container);
await waitFor(() => expect(screen.getByText("Community call")).toBeInTheDocument());
return rendered;
}
it("will not save until a calendar is chosen", async () => {
await withPreview();
expect(importButton()).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("which calendar these events belong to");
});
it("offers only the calendars this session may write to", async () => {
await withPreview();
const picker = screen.getByLabelText<HTMLSelectElement>("Calendar");
const options = [...picker.querySelectorAll("option")].map((option) => option.value);
expect(options).toEqual(["", "personal", "project"]);
});
it("insists on a project and a visibility for a project import", async () => {
await withPreview();
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "project" } });
expect(importButton()).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("whether they are public or private");
expect(screen.getByRole("status")).toHaveTextContent("which project they belong to");
});
it("enables saving once everything is answered", async () => {
await withPreview();
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "project" } });
await fireEvent.change(screen.getByLabelText("Visibility"), { target: { value: "public" } });
expect(importButton()).toBeDisabled();
await fireEvent.change(screen.getByLabelText("Project"), { target: { value: "httpd" } });
expect(importButton()).toBeEnabled();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("asks nothing more for a personal import", async () => {
await withPreview();
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "personal" } });
expect(screen.queryByLabelText("Visibility")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Project")).not.toBeInTheDocument();
expect(screen.getByText(/always private to you/)).toBeInTheDocument();
expect(importButton()).toBeEnabled();
});
it("nothing is pre-selected, so a wrong default cannot be saved by accident", async () => {
await withPreview();
expect(screen.getByLabelText<HTMLSelectElement>("Calendar").value).toBe("");
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "project" } });
expect(screen.getByLabelText<HTMLSelectElement>("Visibility").value).toBe("");
expect(screen.getByLabelText<HTMLSelectElement>("Project").value).toBe("");
});
it("forgets a project that switching visibility has taken off the list", async () => {
const committer = makeCalendars({
can_create: { personal: true, project: ["httpd"], project_private: [], foundation: false },
});
fetchMock.mockResolvedValue(respond(previewOf()));
const { container } = render(ImportDialog, { props: props({ calendars: committer }) });
await attach(container);
await waitFor(() => expect(screen.getByText("Community call")).toBeInTheDocument());
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "project" } });
await fireEvent.change(screen.getByLabelText("Visibility"), { target: { value: "public" } });
await fireEvent.change(screen.getByLabelText("Project"), { target: { value: "httpd" } });
expect(importButton()).toBeEnabled();
});
});
describe("saving", () => {
async function readyToSave() {
fetchMock.mockResolvedValue(respond(previewOf()));
const onimported = vi.fn();
const rendered = render(ImportDialog, { props: props({ onimported }) });
await attach(rendered.container);
await waitFor(() => expect(screen.getByText("Community call")).toBeInTheDocument());
await fireEvent.change(screen.getByLabelText("Calendar"), { target: { value: "project" } });
await fireEvent.change(screen.getByLabelText("Visibility"), { target: { value: "private" } });
await fireEvent.change(screen.getByLabelText("Project"), { target: { value: "httpd" } });
return { ...rendered, onimported };
}
it("posts the file with the chosen settings", async () => {
const { onimported } = await readyToSave();
fetchMock.mockResolvedValue(respond({ created: [makeEvent()], count: 1, warnings: [] }, 201));
await fireEvent.click(importButton());
await waitFor(() => expect(onimported).toHaveBeenCalledWith(1));
const [url, init] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
expect(url).toBe("/api/import");
const body = init.body as FormData;
expect(body.get("category")).toBe("project");
expect(body.get("visibility")).toBe("private");
expect(body.get("project")).toBe("httpd");
expect(body.get("file")).toBeInstanceOf(File);
});
it("says how many will be imported", async () => {
await readyToSave();
expect(importButton()).toHaveTextContent("Import 1 event");
});
it("reports a refusal from the server without closing", async () => {
const { onimported } = await readyToSave();
fetchMock.mockResolvedValue(
respond({ error: "You are not a member of the httpd project." }, 403),
);
await fireEvent.click(importButton());
expect(await screen.findByRole("alert")).toHaveTextContent("not a member of the httpd project");
expect(onimported).not.toHaveBeenCalled();
});
});
describe("getting out", () => {
it("closes on Cancel and on Escape", async () => {
const onclose = vi.fn();
render(ImportDialog, { props: props({ onclose }) });
await fireEvent.click(screen.getByText("Cancel"));
expect(onclose).toHaveBeenCalledOnce();
await fireEvent.keyDown(window, { key: "Escape" });
expect(onclose).toHaveBeenCalledTimes(2);
});
it("stays open when the dialog itself is clicked", async () => {
const onclose = vi.fn();
render(ImportDialog, { props: props({ onclose }) });
await fireEvent.click(within(screen.getByRole("dialog")).getByText(/Import events from/));
expect(onclose).not.toHaveBeenCalled();
});
});