From 9884ac40ac80c8926a8069e74624f2db3e58bc3c Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 12:39:10 +0200 Subject: [PATCH] test: cover cli entrypoint contract --- cmd/email-mcp/main.go | 6 +- internal/cli/entrypoint.go | 22 +++++ internal/cli/integration_test.go | 155 ++++++++----------------------- 3 files changed, 64 insertions(+), 119 deletions(-) create mode 100644 internal/cli/entrypoint.go diff --git a/cmd/email-mcp/main.go b/cmd/email-mcp/main.go index 267da43..305f179 100644 --- a/cmd/email-mcp/main.go +++ b/cmd/email-mcp/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "os" "email-mcp/internal/cli" @@ -9,8 +8,5 @@ import ( func main() { app := cli.BuildApp() - if err := app.Run(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + os.Exit(cli.Execute(app, os.Args[1:], os.Stderr)) } diff --git a/internal/cli/entrypoint.go b/internal/cli/entrypoint.go new file mode 100644 index 0000000..d68dbbe --- /dev/null +++ b/internal/cli/entrypoint.go @@ -0,0 +1,22 @@ +package cli + +import ( + "fmt" + "io" +) + +func Execute(app *App, args []string, stderr io.Writer) int { + if stderr == nil { + stderr = io.Discard + } + if app == nil { + fmt.Fprintln(stderr, "application is not configured") + return 1 + } + + if err := app.Run(args); err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index e52614d..8e341c8 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -3,8 +3,6 @@ package cli import ( "bytes" "context" - "errors" - "io" "testing" "email-mcp/internal/imapclient" @@ -13,146 +11,75 @@ import ( "email-mcp/internal/secretstore/kwallet" ) -type integrationPromptStub struct { +type entrypointPromptStub struct { credential secretstore.Credential - called bool } -func (p *integrationPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) { - p.called = true +func (p *entrypointPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) { return p.credential, nil } -type integrationStoreStub struct { - saved secretstore.Credential - savedKey string - saveCalled bool - loadErr error +type entrypointStoreStub struct { + saveErr error + loadErr error } -func (s *integrationStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error { - s.saveCalled = true - s.savedKey = key - s.saved = cred - return nil +func (s *entrypointStoreStub) Save(context.Context, string, secretstore.Credential) error { + return s.saveErr } -func (s *integrationStoreStub) Load(context.Context, string) (secretstore.Credential, error) { - if s.loadErr != nil { - return secretstore.Credential{}, s.loadErr - } - return s.saved, nil +func (s *entrypointStoreStub) Load(context.Context, string) (secretstore.Credential, error) { + return secretstore.Credential{}, s.loadErr } -type integrationMailServiceStub struct{} +type entrypointMailServiceStub struct{} -func (integrationMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { +func (entrypointMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { return nil, nil } -func (integrationMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { +func (entrypointMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { return nil, nil } -func (integrationMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { +func (entrypointMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { return imapclient.Message{}, nil } -type integrationRunnerStub struct { - called bool - err error -} +func TestExecuteSetupWritesWalletGuidanceAndReturnsExitCodeOne(t *testing.T) { + app := NewAppWithDependencies( + &entrypointPromptStub{ + credential: secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }, + }, + &entrypointStoreStub{saveErr: kwallet.ErrKWalletUnavailable}, + nil, + nil, + ) -func (r *integrationRunnerStub) Run(context.Context) error { - r.called = true - return r.err -} - -func TestAppRunSetupPersistsPromptedCredential(t *testing.T) { - prompter := &integrationPromptStub{ - credential: secretstore.Credential{ - Host: "imap.example.com", - Username: "alice", - Password: "secret", - }, + stderr := &bytes.Buffer{} + if code := Execute(app, []string{"setup"}, stderr); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) } - store := &integrationStoreStub{} - runner := &integrationRunnerStub{} - - app := buildApp(bytes.NewBuffer(nil), bytes.NewBuffer(nil), bytes.NewBuffer(nil), runtimeFactories{ - newPrompter: func(io.Reader, io.Writer) SetupPrompter { - return prompter - }, - newWalletClient: func() kwallet.Client { - return nil - }, - newStore: func(kwallet.Client) secretstore.Store { - return store - }, - newMailService: func() mcpserver.MailService { - return integrationMailServiceStub{} - }, - newRunner: func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner { - return runner - }, - }) - - if err := app.Run([]string{"setup"}); err != nil { - t.Fatalf("setup returned error: %v", err) - } - if !prompter.called { - t.Fatal("expected setup prompter to be called") - } - if !store.saveCalled { - t.Fatal("expected setup to persist credentials") - } - if store.savedKey != secretstore.DefaultAccountKey { - t.Fatalf("expected save key %q, got %q", secretstore.DefaultAccountKey, store.savedKey) - } - if store.saved != prompter.credential { - t.Fatalf("unexpected saved credential: %#v", store.saved) - } - if runner.called { - t.Fatal("setup should not invoke the MCP runner") + if got := stderr.String(); got != "kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running\n" { + t.Fatalf("unexpected stderr: %q", got) } } -func TestAppRunMCPReturnsSetupHintWhenCredentialsAreMissing(t *testing.T) { - store := &integrationStoreStub{ - loadErr: kwallet.ErrCredentialNotFound, - } - output := &bytes.Buffer{} - mail := integrationMailServiceStub{} +func TestExecuteMCPWritesMissingCredentialGuidanceAndReturnsExitCodeOne(t *testing.T) { + store := &entrypointStoreStub{loadErr: kwallet.ErrCredentialNotFound} + mail := entrypointMailServiceStub{} + runner := mcpserver.NewRunner(mcpserver.New(store, mail), nil, &bytes.Buffer{}, &bytes.Buffer{}) + app := NewAppWithDependencies(nil, store, runner, nil) - app := buildApp(bytes.NewBuffer(nil), output, &bytes.Buffer{}, runtimeFactories{ - newPrompter: func(io.Reader, io.Writer) SetupPrompter { - return &integrationPromptStub{} - }, - newWalletClient: func() kwallet.Client { - return nil - }, - newStore: func(kwallet.Client) secretstore.Store { - return store - }, - newMailService: func() mcpserver.MailService { - return mail - }, - newRunner: func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner { - return mcpserver.NewRunner(mcpserver.New(store, mail), nil, output, &bytes.Buffer{}) - }, - }) - - err := app.Run([]string{"mcp"}) - if err == nil { - t.Fatal("expected error") + stderr := &bytes.Buffer{} + if code := Execute(app, []string{"mcp"}, stderr); code != 1 { + t.Fatalf("expected exit code 1, got %d", code) } - if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) { - t.Fatalf("expected credentials-not-configured sentinel, got %v", err) - } - if err.Error() != "credentials not configured; run `email-mcp setup`" { - t.Fatalf("unexpected user-facing error: %q", err.Error()) - } - if output.Len() != 0 { - t.Fatalf("expected no MCP protocol output on missing credentials, got %q", output.String()) + if got := stderr.String(); got != "credentials not configured; run `email-mcp setup`\n" { + t.Fatalf("unexpected stderr: %q", got) } }