Files
emcli/internal/tui/account_test.go
T
steve a837b25d73 feat(admin): Phase 4 — doctor, admin completeness, and bubbletea TUI
Adds the admin/diagnostics surface from SPEC §7.2:

- doctor [--account]: per-account IMAP + (RW) SMTP connectivity/auth checks via
  new mail.CheckIMAP/CheckSMTP (connect+auth only, no mail). Exit non-zero on any
  failure; secrets never printed.
- store.UpdateAccount: partial edit, re-encrypts password/secrets only when a
  non-empty value is supplied (blank keeps existing). RecentAuditFor(account).
- config set/get (validates audit_retention_days), audit list [--account][--limit],
  account edit (flag partial-update) / remove [--yes].
- internal/tui: bubbletea AccountForm with pure, fully-tested Fields (validation +
  store.Account assembly + edit prefill). init / bare `account add` / `account edit
  --name X` drop into the TUI; flag forms remain for scripting.

Built test-first; full suite green incl -race. Validated live against the mxlogin
(password) and Gmail (app-password) accounts. Live validation caught a real bug:
doctor authenticated with empty passwords because it iterated ListAccounts (which
strips secrets) — fixed to re-fetch via GetAccount, locked in by a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:09:43 +01:00

149 lines
3.9 KiB
Go

package tui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func validFields() Fields {
return Fields{
Name: "work", Mode: "RW",
IMAPHost: "imap.x.com", IMAPPort: "993", IMAPSecurity: "tls",
SMTPHost: "smtp.x.com", SMTPPort: "465", SMTPSecurity: "tls",
Username: "u@x.com", Password: "pw",
}
}
func TestFieldsValidateRequired(t *testing.T) {
f := validFields()
f.Name = ""
if err := f.Validate(); err == nil {
t.Fatal("missing name must fail validation")
}
f = validFields()
f.Username = ""
if err := f.Validate(); err == nil {
t.Fatal("missing username must fail validation")
}
f = validFields()
f.IMAPHost = ""
if err := f.Validate(); err == nil {
t.Fatal("missing imap host must fail validation")
}
}
func TestFieldsValidateEnums(t *testing.T) {
f := validFields()
f.Mode = "XX"
if err := f.Validate(); err == nil {
t.Fatal("mode must be RO or RW")
}
f = validFields()
f.IMAPSecurity = "ssl"
if err := f.Validate(); err == nil {
t.Fatal("security must be tls or starttls")
}
f = validFields()
f.IMAPPort = "notnum"
if err := f.Validate(); err == nil {
t.Fatal("port must be numeric")
}
}
func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
f := validFields()
f.SMTPHost = ""
if err := f.Validate(); err == nil {
t.Fatal("RW account requires an SMTP host")
}
// RO without SMTP is fine.
f = validFields()
f.Mode = "RO"
f.SMTPHost, f.SMTPPort, f.SMTPSecurity = "", "", ""
if err := f.Validate(); err != nil {
t.Fatalf("RO without SMTP should validate: %v", err)
}
}
func TestFieldsToAccount(t *testing.T) {
f := validFields()
f.WhitelistIn = true
f.SubjectRegex = "^urgent"
acc, pwSet := f.ToAccount()
if !pwSet {
t.Fatal("password was provided, PasswordSet should be true")
}
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
t.Fatalf("account not assembled: %+v", acc)
}
if acc.AuthType != "password" || !acc.WhitelistInEnabled || acc.SubjectRegex != "^urgent" {
t.Fatalf("account flags wrong: %+v", acc)
}
if acc.Password != "pw" {
t.Fatalf("password not carried: %q", acc.Password)
}
}
func TestFieldsToAccountBlankPassword(t *testing.T) {
f := validFields()
f.Password = ""
_, pwSet := f.ToAccount()
if pwSet {
t.Fatal("blank password should report PasswordSet=false (edit keeps existing)")
}
}
func TestFieldsFromAccountRoundTrip(t *testing.T) {
a := store.Account{
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:",
}
f := FieldsFromAccount(a)
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || !f.WhitelistOut || f.SubjectRegex != "re:" {
t.Fatalf("FieldsFromAccount wrong: %+v", f)
}
// Password is never read back from an account.
if f.Password != "" {
t.Fatalf("password must not be prefilled: %q", f.Password)
}
}
func TestAccountFormSubmitValid(t *testing.T) {
m := NewAccountForm(validFields(), false)
// Enter submits; with valid fields the form completes.
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = nm.(AccountForm)
if !m.Done() || m.Cancelled() {
t.Fatalf("valid submit should be Done, not cancelled (err=%v)", m.Err())
}
if m.Account().Name != "work" {
t.Fatalf("submitted account wrong: %+v", m.Account())
}
}
func TestAccountFormSubmitInvalidStays(t *testing.T) {
f := validFields()
f.Name = ""
m := NewAccountForm(f, false)
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = nm.(AccountForm)
if m.Done() {
t.Fatal("invalid submit must not complete the form")
}
if m.Err() == nil {
t.Fatal("invalid submit should set an error to show the user")
}
}
func TestAccountFormCancel(t *testing.T) {
m := NewAccountForm(validFields(), false)
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = nm.(AccountForm)
if !m.Cancelled() {
t.Fatal("esc should cancel the form")
}
}