Files
webmail-client/frontend/src/test/ThreadView.test.tsx
T
lukasandClaude Opus 5.5 b88855931e M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:25:10 +02:00

108 lines
4.1 KiB
TypeScript

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { ThreadView } from "../components/thread/ThreadView";
import { I18nProvider } from "../i18n";
import { ThemeProvider } from "../lib/theme";
import type { MessageEnvelope } from "../lib/types";
function msg(id: string, minutes: number, unread = false): MessageEnvelope {
return {
id,
account_id: "acc",
mailbox_id: "mb",
thread_id: "t",
subject: "Subject",
from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }],
to_addr: [],
cc_addr: [],
bcc_addr: [],
date: new Date(2026, 0, 1, 12, minutes).toISOString(),
flags: unread ? [] : ["\\Seen"],
is_unread: unread,
is_starred: false,
has_attachments: false,
has_remote_images: false,
images_allowed: false,
size_bytes: 1024,
snippet: `snippet ${id}`,
also_in_account_ids: [],
};
}
// ASC input always (SPEC §9.4): oldest first.
const MESSAGES = [msg("a", 0), msg("b", 1), msg("c", 2)];
function wrap(children: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<I18nProvider>
<ThemeProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</ThemeProvider>
</I18nProvider>
);
}
function renderThread(order: "asc" | "desc") {
return render(wrap(<ThreadView subject="Subject" messages={MESSAGES} order={order} />));
}
function messageOrder(): string[] {
return screen.getAllByTestId("message").map((el) => el.getAttribute("data-message-id")!);
}
describe("ThreadView direction-agnostic layout (SPEC §9.4)", () => {
it("renders oldest-first when order=asc", () => {
renderThread("asc");
expect(messageOrder()).toEqual(["a", "b", "c"]);
});
it("renders newest-first when order=desc", () => {
renderThread("desc");
expect(messageOrder()).toEqual(["c", "b", "a"]);
});
it("attaches the composer directly after the newest message in both orders", () => {
// asc: newest 'c' is last -> composer at the very bottom
const asc = renderThread("asc");
const ascNodes = Array.from(
asc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'),
);
const ascComposerIdx = ascNodes.findIndex(
(n) => n.getAttribute("data-testid") === "composer-anchor",
);
const ascNewestIdx = ascNodes.findIndex((n) => n.getAttribute("data-message-id") === "c");
expect(ascComposerIdx).toBe(ascNewestIdx + 1);
expect(ascComposerIdx).toBe(ascNodes.length - 1); // bottom
// desc: newest 'c' is first -> composer right after it (top), above older
const desc = renderThread("desc");
const descNodes = Array.from(
desc.container.querySelectorAll('[data-testid="message"],[data-testid="composer-anchor"]'),
);
const descComposerIdx = descNodes.findIndex(
(n) => n.getAttribute("data-testid") === "composer-anchor",
);
const descNewestIdx = descNodes.findIndex((n) => n.getAttribute("data-message-id") === "c");
expect(descComposerIdx).toBe(descNewestIdx + 1);
expect(descNewestIdx).toBe(0); // newest at top
});
it("keeps unread messages expanded but read (non-newest) ones collapsed", () => {
const messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)];
const { container } = render(wrap(<ThreadView subject="Subject" messages={messages} order="asc" />));
// An expanded message renders a MessageBody (iframe/loading/error); collapsed does not.
const aNode = container.querySelector('[data-message-id="a"]') as HTMLElement;
const bNode = container.querySelector('[data-message-id="b"]') as HTMLElement;
const hasBody = (el: HTMLElement) =>
el.querySelector("iframe") !== null ||
/Loading thread|Couldn't load/i.test(el.textContent ?? "");
expect(hasBody(aNode)).toBe(false); // read, not newest -> collapsed
expect(hasBody(bNode)).toBe(true); // unread -> expanded
expect(container).toBeTruthy();
});
});