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>
169 lines
7.2 KiB
TypeScript
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");
|
|
});
|
|
});
|