Files
LambdaMail/services/webmail/src/mail-state-integrity.test.ts
T
Lucas RayanandClaude Opus 5 d403c47a71 feat(webmail): pastas próprias na barra lateral e regras em linguagem comum
Regras. A tela pedia um tipo de correspondência do Sieve pelo nome -
:contains, :is, :matches (regex) - e colava o valor escrito
diretamente num script. Punha uma linguagem de padrões à frente de
quem só queria dizer "o que vem do banco vai para Faturas", e bastava
uma aspa no valor para o script deixar de fazer parse, desativando
silenciosamente todas as regras de uma vez.

O vocabulário passa a ser o de quem escreve: um campo, uma condição em
português, um valor e uma ação. Os curingas são gerados a partir de
"começa com" e "termina com" - ninguém os escreve, e um asterisco
escrito por acaso continua a ser um asterisco. A pasta de destino é
escolhida de uma lista das pastas que existem mesmo, em vez de ser
escrita à mão: antes uma regra podia nomear uma pasta inexistente e
nunca disparar, sem dizer porquê.

A ordem passa a ser visível e alterável, porque a primeira regra que
arquiva uma mensagem decide para onde ela vai.

Um script que esta tela não consegue exprimir - escrito à mão, ou por
um cliente de secretária sobre ManageSieve - é mostrado tal como está,
em vez de ser reescrito na coisa mais parecida que este formulário sabe
dizer, o que mudaria em silêncio o que o correio de alguém faz.

Pastas próprias. Existiam na base de dados e as regras podiam arquivar
nelas, mas a barra lateral listava só as seis fixas, portanto não havia
como ver uma - nem como criar. Criar fica onde a vontade aparece, na
própria barra; apagar avisa que leva as mensagens junto, porque leva.

Encontrado a escrever isto: o motor tratava "\\*" como barra seguida de
curinga em vez de um asterisco literal. Como é esta tela que gera os
curingas, uma regra sobre um assunto com "50% *" teria correspondido a
todos os assuntos começados por "50% ". Corrigido no motor, com testes
dos dois lados.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:48:10 -03:00

169 lines
7.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* The wiring behind the counters, the reader pane and the two surfaces.
*
* mailCounts.test.ts and emailBody.test.ts cover the rules themselves; these
* check that the screens actually go through them, because every defect in this
* file was a component computing the number or the markup inline where nothing
* could reach it.
*/
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
/** Strips comments, so a fix may name the code it removed without tripping. */
const code = (path: string) =>
read(path)
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "");
const USER_LAYOUT = "src/app/(user)/user/layout.tsx";
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
const ADMIN_LOGIN = "src/app/(auth)/admin/login/page.tsx";
const USER_LOGIN = "src/app/(auth)/user/login/page.tsx";
describe("the counters the sidebar shows", () => {
// The badge was computed inline from folders.find(...).unread_count, which
// missed folders with no special-use role and printed nothing for Drafts.
it("goes through the tested counting rules", () => {
// folderBadge, not badgeCount: the sidebar needs to know which of the two
// numbers to emphasise, not just what the number is. Rendering was where
// the defect lived - a folder with nothing unread printed its total in the
// unread badge's place, so reading a message never moved it.
expect(code(USER_LAYOUT)).toContain("folderBadge");
expect(code(USER_LAYOUT)).not.toMatch(/unread_count\s*\?\?/);
});
// The total was rendered twice for Drafts, where the badge and the size are
// the same number: "Rascunhos 1 1".
//
// Two occurrences now, not one: the standard folders and the folders the
// user created are rendered by separate templates. What must not happen is
// one template printing it twice, which is what the defect was.
it("renders the folder size once per folder template", () => {
const layout = code(USER_LAYOUT);
expect(layout.match(/\{badge\.total\}/g) ?? []).toHaveLength(2);
// And each template guards the unread badge, so the two numbers can never
// be the same value shown twice.
expect(layout.match(/badge\.showsUnread/g) ?? []).toHaveLength(2);
});
// The folder list was fetched once on mount, so every badge froze at the
// value it had when the tab was opened: new mail did not raise it and reading
// a message did not lower it.
it("refreshes the folder list instead of fetching it once on mount", () => {
const layout = code(USER_LAYOUT);
expect(layout).toContain("useFolders");
expect(layout).not.toMatch(/useEffect\([^)]*\)\s*=>\s*\{\s*void fetch\("\/api\/v1\/mail\/folders"\)/);
});
it("re-reads the counters when the server pushes a mailbox event", () => {
expect(code("src/lib/useFolders.ts")).toContain("useMailEvents");
});
// Reading a message changes a counter that lives in another component, so the
// reader has to say so; nothing else can know.
it("gives the reader a way to announce that a counter moved", () => {
expect(code("src/lib/useFolders.ts")).toContain("MAIL_STATE_CHANGED");
expect(code(MAIL_PAGE)).toContain("notifyMailStateChanged");
});
});
describe("the counters the message list shows", () => {
// The header printed messages.length - the size of one page, capped at 50 -
// as though it were the size of the folder.
it("does not present the length of the loaded page as the folder total", () => {
const page = code(MAIL_PAGE);
expect(page).toContain("listHeaderCount");
expect(page).not.toContain("({messages.length})");
});
it("filters through the tested rule rather than a chain of inline predicates", () => {
expect(code(MAIL_PAGE)).toContain("filterMessages");
});
// A chip that filters by unread or by attachment is worth a number, and both
// were available on the page already.
it("shows how many messages each filter chip would keep", () => {
expect(code(MAIL_PAGE)).toContain("messageCounts");
});
});
describe("read and unread state", () => {
// Opening a message marked it read on the server, so the flag flipping back
// on the next reload was the list, the badge and the server disagreeing.
it("persists a read-state change through the API, not only in local state", () => {
expect(code(MAIL_PAGE)).toContain("/api/v1/mail/seen");
});
it("keeps the row in step through the tested helper", () => {
expect(code(MAIL_PAGE)).toContain("applySeen");
});
});
describe("the reader pane", () => {
it("resolves the message's own inline images", () => {
const page = code(MAIL_PAGE);
expect(page).toContain("resolveInlineImages");
expect(page).toContain("inline_images");
});
it("renders the body inside the tested document rather than a bare fragment", () => {
expect(code(MAIL_PAGE)).toContain("buildReaderDocument");
});
// A fixed 500px frame clipped every long message and left a slab of white
// under every short one.
it("sizes the frame from the message instead of pinning it to 500px", () => {
const page = code(MAIL_PAGE);
expect(page).not.toContain("h-[500px]");
expect(page).toContain("lm:reader-height");
// The height report needs script inside the frame, which sandbox="" forbids.
expect(page).toContain("allow-scripts");
});
// Every one of these was a Portuguese string typed into a component that had
// a translated bundle beside it, so the interface was half English for a
// Spanish reader and half Portuguese for an English one.
it("has no hardcoded strings left in the reader", () => {
const page = read(MAIL_PAGE);
for (const literal of ["Sem assunto", "Para:", "Imagens Habilitadas", "(sanitised)"]) {
expect(page).not.toContain(literal);
}
});
});
describe("moving between the two surfaces", () => {
// The mailbox is the account's own mail behind the account's own password.
it("does not ask the webmail sign-in for a second factor", () => {
const login = code(USER_LOGIN);
expect(login).not.toContain("mfa_required");
expect(login).not.toContain("challenge_token");
});
// Crossing into the console asked for the password again, though the session
// in hand had just proven it.
it("steps up into the console instead of sending the operator to a password form", () => {
expect(code(USER_LAYOUT)).toContain("/admin/step-up");
expect(code(USER_LAYOUT)).not.toContain('href="/admin/login"');
});
it("collects only the second factor on the step-up screen", () => {
const stepUp = code("src/app/(auth)/admin/step-up/page.tsx");
expect(stepUp).toContain("/api/v1/auth/admin/step-up");
expect(stepUp).not.toContain('type="password"');
});
// Leaving the console needs nothing: the admin sign-in now issues the webmail
// session too, so the link back cannot land on a login screen.
it("keeps a plain link out of the console", () => {
expect(code("src/app/(admin)/admin/layout.tsx")).toContain("/user/mail/inbox");
});
it("still requires the second factor at the admin sign-in itself", () => {
expect(code(ADMIN_LOGIN)).toContain("challenge_token");
});
});