package cli import ( "bytes" "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "testing" frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config" frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest" frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore" "email-mcp/internal/imapclient" "email-mcp/internal/mcpserver" "email-mcp/internal/secretstore" ) var _ func() *App = NewApp type configPrompterStub struct { credential secretstore.Credential err error called bool existing secretstore.Credential hasStored bool } func (p *configPrompterStub) PromptCredential(context.Context, secretstore.Credential, bool) (secretstore.Credential, error) { p.called = true if p.err != nil { return secretstore.Credential{}, p.err } return p.credential, nil } type capturingPrompterStub struct { credential secretstore.Credential existing secretstore.Credential hasStored bool } func (p *capturingPrompterStub) PromptCredential(_ context.Context, existing secretstore.Credential, hasStored bool) (secretstore.Credential, error) { p.existing = existing p.hasStored = hasStored return p.credential, nil } type configStoreStub struct { cfg frameworkconfig.FileConfig[ProfileConfig] loadErr error saveErr error saved frameworkconfig.FileConfig[ProfileConfig] saveCalled bool configPath string } func (s *configStoreStub) LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error) { if s.loadErr != nil { return frameworkconfig.FileConfig[ProfileConfig]{}, "", s.loadErr } path := s.configPath if path == "" { path = "/tmp/email-mcp/config.json" } return s.cfg, path, nil } func (s *configStoreStub) SaveDefault(cfg frameworkconfig.FileConfig[ProfileConfig]) (string, error) { s.saveCalled = true s.saved = cfg if s.saveErr != nil { return "", s.saveErr } path := s.configPath if path == "" { path = "/tmp/email-mcp/config.json" } return path, nil } type secretStoreStub struct { values map[string]string setErr error getErr error setName string setValue string setCalled bool } func (s *secretStoreStub) SetSecret(name, _ string, secret string) error { s.setCalled = true s.setName = name s.setValue = secret if s.setErr != nil { return s.setErr } if s.values == nil { s.values = map[string]string{} } s.values[name] = secret return nil } func (s *secretStoreStub) GetSecret(name string) (string, error) { if s.getErr != nil { return "", s.getErr } value, ok := s.values[name] if !ok { return "", frameworksecretstore.ErrNotFound } return value, nil } func (s *secretStoreStub) DeleteSecret(name string) error { delete(s.values, name) return nil } type runnerStub struct { err error called bool } func (r *runnerStub) Run(context.Context) error { r.called = true return r.err } func TestAppRunRejectsUnknownCommand(t *testing.T) { app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") err := app.Run([]string{"unknown"}) if err == nil { t.Fatal("expected error for unknown command") } } func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev") if err := app.Run(nil); err != nil { t.Fatalf("expected help to be rendered, got error %v", err) } text := output.String() for _, snippet := range []string{"Usage:", "doctor", "version"} { if !strings.Contains(text, snippet) { t.Fatalf("help output missing %q: %q", snippet, text) } } } func TestAppRunVersionPrintsBuildVersion(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "v1.2.3") if err := app.Run([]string{"version"}); err != nil { t.Fatalf("version returned error: %v", err) } if got := output.String(); got != "v1.2.3\n" { t.Fatalf("version output = %q, want %q", got, "v1.2.3\n") } } func TestAppRunDoctorHelp(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev") if err := app.Run([]string{"doctor", "--help"}); err != nil { t.Fatalf("doctor help returned error: %v", err) } if got := output.String(); !strings.Contains(got, "email-mcp doctor [--profile NAME]") { t.Fatalf("unexpected doctor help output: %q", got) } } func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } cfgStore := &configStoreStub{} secrets := &secretStoreStub{} output := &bytes.Buffer{} app := NewAppWithDependencies( prompter, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"config"}); err != nil { t.Fatalf("Run returned error: %v", err) } if !prompter.called { t.Fatal("expected config prompter to be called") } if !secrets.setCalled { t.Fatal("expected password to be stored") } if secrets.setName != "imap-password/default" { t.Fatalf("unexpected secret name %q", secrets.setName) } if !cfgStore.saveCalled { t.Fatal("expected config to be saved") } if cfgStore.saved.CurrentProfile != "default" { t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile) } if cfgStore.saved.Profiles["default"].Host != "imap.example.com" { t.Fatalf("unexpected saved profile: %#v", cfgStore.saved.Profiles["default"]) } if got := output.String(); !strings.Contains(got, `profile "default" saved`) { t.Fatalf("unexpected output %q", got) } } func TestAppRunSetupAliasesConfig(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "secret", }, } cfgStore := &configStoreStub{} secrets := &secretStoreStub{} app := NewAppWithDependencies( prompter, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, io.Discard, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"setup"}); err != nil { t.Fatalf("setup returned error: %v", err) } if !cfgStore.saveCalled { t.Fatal("expected setup to save config via config command") } } func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) { prompter := &capturingPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "updated-secret", }, } cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }, } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "stored-secret", }, } app := NewAppWithDependencies( prompter, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, io.Discard, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"config"}); err != nil { t.Fatalf("config returned error: %v", err) } if !prompter.hasStored { t.Fatal("expected stored password to be reported") } if prompter.existing.Password != "stored-secret" { t.Fatalf("expected existing password to be forwarded, got %q", prompter.existing.Password) } } func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) { cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }, } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "secret", }, } runner := &runnerStub{} var gotCredential secretstore.Credential var gotMailService mcpserver.MailService app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, func() mcpserver.MailService { return wireMailServiceStub{} }, func(cred secretstore.Credential, mail mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner { gotCredential = cred gotMailService = mail return runner }, nil, nil, nil, nil, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"mcp"}); err != nil { t.Fatalf("mcp returned error: %v", err) } if !runner.called { t.Fatal("expected runner to be called") } if gotCredential.Password != "secret" || gotCredential.Username != "alice" { t.Fatalf("unexpected credential %#v", gotCredential) } if gotMailService == nil { t.Fatal("expected mail service to be built") } } func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) { tempDir := t.TempDir() executablePath := filepath.Join(tempDir, "email-mcp") if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil { t.Fatalf("WriteFile returned error: %v", err) } if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(` [update] source_name = "test" base_url = "http://127.0.0.1:1" latest_release_url = "http://127.0.0.1:1/releases/latest" `), 0o600); err != nil { t.Fatalf("WriteFile manifest returned error: %v", err) } client := &bytes.Buffer{} app := NewAppWithDependencies( nil, nil, nil, nil, nil, frameworkmanifest.LoadDefault, func() (string, error) { return executablePath, nil }, nil, client, &bytes.Buffer{}, "dev", ) err := app.Run([]string{"update"}) if err == nil { t.Fatal("expected update to fail without a reachable release endpoint") } if !strings.Contains(err.Error(), "fetch latest release metadata") { t.Fatalf("unexpected error: %v", err) } } type doctorMailServiceStub struct { listMailboxes []imapclient.Mailbox listErr error called bool } func (s *doctorMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { s.called = true return s.listMailboxes, s.listErr } func (s *doctorMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { return nil, nil } func (s *doctorMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { return imapclient.Message{}, nil } func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] latest_release_url = "https://example.com/releases/latest" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "secret", }, } mail := &doctorMailServiceStub{ listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}}, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return secrets, nil }, func() mcpserver.MailService { return mail }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"doctor"}); err != nil { t.Fatalf("doctor returned error: %v", err) } if !mail.called { t.Fatal("expected connectivity check to call mail service") } text := output.String() for _, needle := range []string{ "[OK] config: config file is readable", "[OK] profile: resolved profile is complete", "[OK] password: stored password is present", "[OK] connectivity: IMAP server is reachable", "Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total", } { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, Profiles: map[string]ProfileConfig{ "default": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] latest_release_url = "https://example.com/releases/latest" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return &secretStoreStub{}, nil }, func() mcpserver.MailService { return &doctorMailServiceStub{} }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) err = app.Run([]string{"doctor"}) if err == nil { t.Fatal("expected doctor to fail when password is missing") } if !strings.Contains(err.Error(), "doctor checks failed") { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") { t.Fatalf("unexpected output: %q", output.String()) } } func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) { app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") tests := []struct { command string want string }{ {command: "config", want: "config prompter is not configured"}, {command: "mcp", want: "mcp runner is not configured"}, {command: "doctor", want: "config store is not configured"}, {command: "update", want: "manifest loader is not configured"}, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { err := app.Run([]string{tt.command}) if err == nil { t.Fatalf("expected %s to fail without dependencies", tt.command) } if !strings.Contains(err.Error(), tt.want) { t.Fatalf("expected error to contain %q, got %v", tt.want, err) } }) } } func TestMapAppErrorMapsMissingCredentialError(t *testing.T) { err := mapAppError(fmt.Errorf("%w: missing profile", mcpserver.ErrCredentialsNotConfigured)) if err == nil { t.Fatal("expected mapped error") } if !strings.Contains(err.Error(), "run `email-mcp config`") { t.Fatalf("expected config guidance, got %v", err) } if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) { t.Fatalf("expected typed error to be preserved, got %v", err) } } func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) { err := mapAppError(&frameworksecretstore.BackendUnavailableError{ Policy: frameworksecretstore.BackendAuto, Required: "any keyring backend", }) if err == nil { t.Fatal("expected mapped error") } if !strings.Contains(strings.ToLower(err.Error()), "wallet") { t.Fatalf("expected wallet guidance, got %v", err) } } func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) { app := NewAppWithDependencies( &configPrompterStub{}, &configStoreStub{}, func() (secretStore, error) { return nil, &frameworksecretstore.BackendUnavailableError{ Policy: frameworksecretstore.BackendAuto, Required: "any keyring backend", } }, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev", ) stderr := &bytes.Buffer{} if code := Execute(app, []string{"config"}, stderr); code != 1 { t.Fatalf("expected exit code 1, got %d", code) } if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") { t.Fatalf("unexpected stderr: %q", got) } } func TestNewAppBuildsProductionDependencies(t *testing.T) { app := NewApp() if app == nil { t.Fatal("expected app instance") } if app.prompter == nil { t.Fatal("expected config prompter to be configured") } if app.configStore == nil { t.Fatal("expected config store to be configured") } if app.openSecretStore == nil { t.Fatal("expected secret store opener to be configured") } if app.newRunner == nil { t.Fatal("expected MCP runner factory to be configured") } } type wireMailServiceStub struct{} func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { return nil, nil } func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { return nil, nil } func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { return imapclient.Message{}, nil }