diff --git a/services/protocols/internal/infrastructure/postgres/mail_state_contract_test.go b/services/protocols/internal/infrastructure/postgres/mail_state_contract_test.go new file mode 100644 index 0000000..fb80f66 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/mail_state_contract_test.go @@ -0,0 +1,405 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// The mailbox state contract. +// +// A message can be read, flagged, moved, copied and deleted from IMAP, from the +// webmail, and by the server itself. All of them change the same rows, and the +// rows carry cached numbers (folder counters, the folder's modseq, the +// mailbox's used bytes) that every path must keep true. Before there was one +// implementation, each path kept them true on its own, and they drifted: +// +// - reading a message over IMAP never moved the unread counter, so the badge +// in the webmail and IMAP STATUS disagreed with the flags; +// - deleting from the webmail never gave the quota back; +// - a second expunge of the same message could push a counter below zero. +// +// These tests state what must hold after any operation and run every operation +// through each path that can perform it. They do not care which code did the +// work, only that the result is the same. + +// folderState is everything observable about one folder. +type folderState struct { + Name string + CachedTotal int + CachedUnread int + RealTotal int + RealUnread int + HighestModSeq int64 + UIDNext int64 + MaxMessageSeq int64 +} + +// mailState reads the cached and the real numbers side by side. +func mailState(t *testing.T, pool *pgxpool.Pool, mailboxID string) (folders map[string]folderState, usedBytes, realBytes int64) { + t.Helper() + ctx := context.Background() + + rows, err := pool.Query(ctx, ` + SELECT f.name, f.total_count, f.unread_count, f.highest_modseq, f.uid_next, + COUNT(e.id)::int, + COUNT(e.id) FILTER (WHERE NOT EXISTS ( + SELECT 1 FROM message_flags mf + WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'))::int, + COALESCE(MAX(e.modseq), 0) + FROM folders f + LEFT JOIN email_messages e ON e.folder_id = f.id AND e.expunged_at IS NULL + WHERE f.mailbox_id = $1 + GROUP BY f.id`, mailboxID) + if err != nil { + t.Fatalf("read folder state: %v", err) + } + defer rows.Close() + + folders = map[string]folderState{} + for rows.Next() { + var s folderState + if err := rows.Scan(&s.Name, &s.CachedTotal, &s.CachedUnread, &s.HighestModSeq, &s.UIDNext, &s.RealTotal, &s.RealUnread, &s.MaxMessageSeq); err != nil { + t.Fatalf("scan folder state: %v", err) + } + folders[s.Name] = s + } + + if err := pool.QueryRow(ctx, `SELECT used_bytes FROM mailboxes WHERE id = $1`, mailboxID).Scan(&usedBytes); err != nil { + t.Fatalf("read used_bytes: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT COALESCE(SUM(size_bytes), 0) FROM email_messages WHERE mailbox_id = $1 AND expunged_at IS NULL`, mailboxID).Scan(&realBytes); err != nil { + t.Fatalf("read real bytes: %v", err) + } + return folders, usedBytes, realBytes +} + +// assertConsistent is the contract: every cached number equals what the rows say. +func assertConsistent(t *testing.T, pool *pgxpool.Pool, mailboxID, after string) { + t.Helper() + folders, used, real := mailState(t, pool, mailboxID) + + for name, f := range folders { + if f.CachedTotal != f.RealTotal { + t.Errorf("after %s: %s total_count = %d, but it holds %d messages", after, name, f.CachedTotal, f.RealTotal) + } + if f.CachedUnread != f.RealUnread { + t.Errorf("after %s: %s unread_count = %d, but %d messages are unread", after, name, f.CachedUnread, f.RealUnread) + } + if f.CachedUnread < 0 || f.CachedTotal < 0 { + t.Errorf("after %s: %s has a negative counter (unread %d, total %d)", after, name, f.CachedUnread, f.CachedTotal) + } + if f.HighestModSeq < f.MaxMessageSeq { + t.Errorf("after %s: %s highest_modseq %d is behind a message's modseq %d, so a CONDSTORE client would miss the change", after, name, f.HighestModSeq, f.MaxMessageSeq) + } + } + if used != real { + t.Errorf("after %s: mailbox used_bytes = %d, but its messages add up to %d", after, used, real) + } +} + +// modseqOf is the folder's highest_modseq, which every observable change must raise. +func modseqOf(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string) int64 { + t.Helper() + folders, _, _ := mailState(t, pool, mailboxID) + return folders[folder].HighestModSeq +} + +func folderID(t *testing.T, pool *pgxpool.Pool, mailboxID, name string) string { + t.Helper() + var id string + if err := pool.QueryRow(context.Background(), `SELECT id::text FROM folders WHERE mailbox_id = $1 AND name = $2`, mailboxID, name).Scan(&id); err != nil { + t.Fatalf("find folder %s: %v", name, err) + } + return id +} + +// path is one way of doing each thing to a message. IMAP and webmail both +// implement it; the contract runs against each. +type path struct { + name string + setSeen func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool) + setFlag func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool) + expunge func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32) + moveTo func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string) + flagsKept func() bool +} + +func imapPath() path { + return path{ + name: "imap", + setSeen: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool) { + op := port.FlagOpAdd + if !seen { + op = port.FlagOpDel + } + if _, err := NewFlagRepository(pool).SetFlags(context.Background(), folderID(t, pool, mailboxID, folder), uid, op, []string{`\Seen`}, 0); err != nil { + t.Fatalf("imap SetFlags: %v", err) + } + }, + setFlag: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool) { + op := port.FlagOpAdd + if !flagged { + op = port.FlagOpDel + } + if _, err := NewFlagRepository(pool).SetFlags(context.Background(), folderID(t, pool, mailboxID, folder), uid, op, []string{`\Flagged`}, 0); err != nil { + t.Fatalf("imap SetFlags: %v", err) + } + }, + expunge: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32) { + if err := NewExpungeRepository(pool).Expunge(context.Background(), folderID(t, pool, mailboxID, folder), []uint32{uid}); err != nil { + t.Fatalf("imap Expunge: %v", err) + } + }, + moveTo: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string) { + // IMAP has no MOVE here: a client copies, marks \Deleted and expunges + src, dst := folderID(t, pool, mailboxID, folder), folderID(t, pool, mailboxID, target) + if _, err := NewCopyRepository(pool).CopyMessages(context.Background(), src, []uint32{uid}, dst); err != nil { + t.Fatalf("imap Copy: %v", err) + } + if err := NewExpungeRepository(pool).Expunge(context.Background(), src, []uint32{uid}); err != nil { + t.Fatalf("imap Expunge after copy: %v", err) + } + }, + } +} + +func webmailPath() path { + return path{ + name: "webmail", + setSeen: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, seen bool) { + if err := NewWebmailRepository(pool).MarkSeen(context.Background(), mailboxID, folder, uid, seen); err != nil { + t.Fatalf("webmail MarkSeen: %v", err) + } + }, + setFlag: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, flagged bool) { + if err := NewWebmailRepository(pool).MarkFlagged(context.Background(), mailboxID, folder, uid, flagged); err != nil { + t.Fatalf("webmail MarkFlagged: %v", err) + } + }, + expunge: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32) { + if err := NewWebmailRepository(pool).Expunge(context.Background(), mailboxID, folder, uid); err != nil { + t.Fatalf("webmail Expunge: %v", err) + } + }, + moveTo: func(t *testing.T, pool *pgxpool.Pool, mailboxID, folder string, uid uint32, target string) { + if _, err := NewWebmailRepository(pool).MoveToFolder(context.Background(), mailboxID, folder, uid, target); err != nil { + t.Fatalf("webmail Move: %v", err) + } + }, + } +} + +func eachPath(t *testing.T, scenario func(t *testing.T, p path)) { + for _, p := range []path{imapPath(), webmailPath()} { + p := p + t.Run(p.name, func(t *testing.T) { scenario(t, p) }) + } +} + +func TestContract_ReadingAMessageMovesTheUnreadCounter(t *testing.T) { + pool := testPool(t) + eachPath(t, func(t *testing.T, p path) { + mailboxID := seedMailbox(t, pool) + a := deliver(t, pool, mailboxID, "INBOX", "a", false) + deliver(t, pool, mailboxID, "INBOX", "b", false) + assertConsistent(t, pool, mailboxID, "delivery") + + before := modseqOf(t, pool, mailboxID, "INBOX") + p.setSeen(t, pool, mailboxID, "INBOX", a, true) + assertConsistent(t, pool, mailboxID, "marking one message read") + if after := modseqOf(t, pool, mailboxID, "INBOX"); after <= before { + t.Errorf("marking read left highest_modseq at %d (was %d): clients holding the folder never learn of it", after, before) + } + + p.setSeen(t, pool, mailboxID, "INBOX", a, true) + assertConsistent(t, pool, mailboxID, "marking the same message read twice") + + p.setSeen(t, pool, mailboxID, "INBOX", a, false) + assertConsistent(t, pool, mailboxID, "marking it unread again") + }) +} + +func TestContract_FlaggingBumpsTheModseqAndNothingElse(t *testing.T) { + pool := testPool(t) + eachPath(t, func(t *testing.T, p path) { + mailboxID := seedMailbox(t, pool) + a := deliver(t, pool, mailboxID, "INBOX", "a", false) + + before := modseqOf(t, pool, mailboxID, "INBOX") + p.setFlag(t, pool, mailboxID, "INBOX", a, true) + assertConsistent(t, pool, mailboxID, "flagging") + if after := modseqOf(t, pool, mailboxID, "INBOX"); after <= before { + t.Errorf("flagging left highest_modseq at %d (was %d)", after, before) + } + p.setFlag(t, pool, mailboxID, "INBOX", a, false) + assertConsistent(t, pool, mailboxID, "unflagging") + }) +} + +func TestContract_DeletingFreesTheQuotaAndTheCounters(t *testing.T) { + pool := testPool(t) + eachPath(t, func(t *testing.T, p path) { + mailboxID := seedMailbox(t, pool) + unread := deliver(t, pool, mailboxID, "INBOX", "unread", false) + read := deliver(t, pool, mailboxID, "INBOX", "read", true) + assertConsistent(t, pool, mailboxID, "delivery") + + p.expunge(t, pool, mailboxID, "INBOX", unread) + assertConsistent(t, pool, mailboxID, "deleting an unread message") + + p.expunge(t, pool, mailboxID, "INBOX", read) + assertConsistent(t, pool, mailboxID, "deleting a read message") + + _, used, _ := mailState(t, pool, mailboxID) + if used != 0 { + t.Errorf("an emptied mailbox still reports %d used bytes", used) + } + }) +} + +func TestContract_AMessageAlreadyDeletedCannotDriveACounterNegative(t *testing.T) { + pool := testPool(t) + eachPath(t, func(t *testing.T, p path) { + mailboxID := seedMailbox(t, pool) + a := deliver(t, pool, mailboxID, "INBOX", "a", false) + deliver(t, pool, mailboxID, "INBOX", "b", false) + + p.expunge(t, pool, mailboxID, "INBOX", a) + // The second one is the same message again. The webmail reports it as + // not found; IMAP ignores it. Either way no number may move. + func() { + defer func() { _ = recover() }() + expungeAgain(t, pool, p, mailboxID, a) + }() + assertConsistent(t, pool, mailboxID, "deleting the same message twice") + }) +} + +// expungeAgain tolerates the webmail's "not found" while still letting the +// contract check the counters afterwards. +func expungeAgain(t *testing.T, pool *pgxpool.Pool, p path, mailboxID string, uid uint32) { + t.Helper() + switch p.name { + case "imap": + _ = NewExpungeRepository(pool).Expunge(context.Background(), folderID(t, pool, mailboxID, "INBOX"), []uint32{uid}) + default: + _ = NewWebmailRepository(pool).Expunge(context.Background(), mailboxID, "INBOX", uid) + } +} + +func TestContract_MovingAMessageKeepsBothFoldersTrue(t *testing.T) { + pool := testPool(t) + eachPath(t, func(t *testing.T, p path) { + mailboxID := seedMailbox(t, pool) + unread := deliver(t, pool, mailboxID, "INBOX", "unread", false) + read := deliver(t, pool, mailboxID, "INBOX", "read", true) + + p.moveTo(t, pool, mailboxID, "INBOX", unread, "Trash") + assertConsistent(t, pool, mailboxID, "moving an unread message") + p.moveTo(t, pool, mailboxID, "INBOX", read, "Trash") + assertConsistent(t, pool, mailboxID, "moving a read message") + + folders, _, _ := mailState(t, pool, mailboxID) + if folders["INBOX"].RealTotal != 0 || folders["Trash"].RealTotal != 2 { + t.Errorf("INBOX holds %d and Trash %d, want 0 and 2", folders["INBOX"].RealTotal, folders["Trash"].RealTotal) + } + }) +} + +// Reading mail over IMAP and then looking at it in the webmail (or the other way +// round) is the everyday case: the two views must agree after every step. +func TestContract_ImapAndWebmailAgreeOnTheSameMailbox(t *testing.T) { + pool := testPool(t) + imap, web := imapPath(), webmailPath() + + mailboxID := seedMailbox(t, pool) + var uids []uint32 + for i := 0; i < 5; i++ { + uids = append(uids, deliver(t, pool, mailboxID, "INBOX", fmt.Sprintf("m%d", i), false)) + } + + imap.setSeen(t, pool, mailboxID, "INBOX", uids[0], true) + assertConsistent(t, pool, mailboxID, "reading m0 over IMAP") + web.setSeen(t, pool, mailboxID, "INBOX", uids[1], true) + assertConsistent(t, pool, mailboxID, "reading m1 in the webmail") + imap.setFlag(t, pool, mailboxID, "INBOX", uids[2], true) + web.expunge(t, pool, mailboxID, "INBOX", uids[3]) + assertConsistent(t, pool, mailboxID, "deleting m3 in the webmail") + imap.expunge(t, pool, mailboxID, "INBOX", uids[1]) + assertConsistent(t, pool, mailboxID, "deleting the already-read m1 over IMAP") + web.moveTo(t, pool, mailboxID, "INBOX", uids[4], "Trash") + assertConsistent(t, pool, mailboxID, "moving m4 to Trash in the webmail") + imap.moveTo(t, pool, mailboxID, "INBOX", uids[2], "Trash") + assertConsistent(t, pool, mailboxID, "moving m2 to Trash over IMAP") + + // What the webmail reports for a folder is what IMAP would answer + ctx := context.Background() + wmFolders, err := NewWebmailRepository(pool).ListFolders(ctx, mailboxID) + if err != nil { + t.Fatalf("webmail ListFolders: %v", err) + } + imFolders, err := NewImapFolderRepository(pool).ListFolders(ctx, mailboxID) + if err != nil { + t.Fatalf("imap ListFolders: %v", err) + } + imapTotals := map[string]int{} + for _, f := range imFolders { + imapTotals[f.Name] = int(f.NumMessages) + } + for _, f := range wmFolders { + if got := imapTotals[f.Name]; got != f.TotalCount { + t.Errorf("%s: the webmail shows %d messages and IMAP says %d", f.Name, f.TotalCount, got) + } + } +} + +// A folder deleted by its owner takes its messages with it, and their bytes. +func TestContract_DeletingAFolderReturnsItsBytes(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + mailboxID := seedMailbox(t, pool) + repo := NewWebmailRepository(pool) + + if err := repo.CreateFolder(ctx, mailboxID, "Projetos"); err != nil { + t.Fatalf("create folder: %v", err) + } + deliver(t, pool, mailboxID, "Projetos", "one", false) + deliver(t, pool, mailboxID, "Projetos", "two", true) + deliver(t, pool, mailboxID, "INBOX", "stays", false) + assertConsistent(t, pool, mailboxID, "filing into a new folder") + + if err := repo.DeleteFolder(ctx, mailboxID, "Projetos"); err != nil { + t.Fatalf("delete folder: %v", err) + } + assertConsistent(t, pool, mailboxID, "deleting the folder") +} + +// IMAP's STORE can replace a message's whole flag set; the unread counter has to +// follow the \Seen that comes or goes with it. +func TestContract_ReplacingTheFlagSetKeepsTheUnreadCounterTrue(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + mailboxID := seedMailbox(t, pool) + a := deliver(t, pool, mailboxID, "INBOX", "a", false) + b := deliver(t, pool, mailboxID, "INBOX", "b", true) + inbox := folderID(t, pool, mailboxID, "INBOX") + repo := NewFlagRepository(pool) + + if _, err := repo.SetFlags(ctx, inbox, a, port.FlagOpSet, []string{`\Seen`, `\Flagged`}, 0); err != nil { + t.Fatalf("SetFlags: %v", err) + } + assertConsistent(t, pool, mailboxID, "replacing flags with one that includes \\Seen") + + if _, err := repo.SetFlags(ctx, inbox, b, port.FlagOpSet, []string{`\Flagged`}, 0); err != nil { + t.Fatalf("SetFlags: %v", err) + } + assertConsistent(t, pool, mailboxID, "replacing flags with one that drops \\Seen") +} + +var _ = uuid.Nil