test: cover cli entrypoint contract

This commit is contained in:
thibaud-leclere 2026-04-10 12:39:10 +02:00
parent afec7612aa
commit 9884ac40ac
3 changed files with 64 additions and 119 deletions

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"os" "os"
"email-mcp/internal/cli" "email-mcp/internal/cli"
@ -9,8 +8,5 @@ import (
func main() { func main() {
app := cli.BuildApp() app := cli.BuildApp()
if err := app.Run(os.Args[1:]); err != nil { os.Exit(cli.Execute(app, os.Args[1:], os.Stderr))
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
} }

View file

@ -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
}

View file

@ -3,8 +3,6 @@ package cli
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io"
"testing" "testing"
"email-mcp/internal/imapclient" "email-mcp/internal/imapclient"
@ -13,146 +11,75 @@ import (
"email-mcp/internal/secretstore/kwallet" "email-mcp/internal/secretstore/kwallet"
) )
type integrationPromptStub struct { type entrypointPromptStub struct {
credential secretstore.Credential credential secretstore.Credential
called bool
} }
func (p *integrationPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) { func (p *entrypointPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) {
p.called = true
return p.credential, nil return p.credential, nil
} }
type integrationStoreStub struct { type entrypointStoreStub struct {
saved secretstore.Credential saveErr error
savedKey string loadErr error
saveCalled bool
loadErr error
} }
func (s *integrationStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error { func (s *entrypointStoreStub) Save(context.Context, string, secretstore.Credential) error {
s.saveCalled = true return s.saveErr
s.savedKey = key
s.saved = cred
return nil
} }
func (s *integrationStoreStub) Load(context.Context, string) (secretstore.Credential, error) { func (s *entrypointStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
if s.loadErr != nil { return secretstore.Credential{}, s.loadErr
return secretstore.Credential{}, s.loadErr
}
return s.saved, nil
} }
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 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 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 return imapclient.Message{}, nil
} }
type integrationRunnerStub struct { func TestExecuteSetupWritesWalletGuidanceAndReturnsExitCodeOne(t *testing.T) {
called bool app := NewAppWithDependencies(
err error &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 { stderr := &bytes.Buffer{}
r.called = true if code := Execute(app, []string{"setup"}, stderr); code != 1 {
return r.err t.Fatalf("expected exit code 1, got %d", code)
}
func TestAppRunSetupPersistsPromptedCredential(t *testing.T) {
prompter := &integrationPromptStub{
credential: secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
},
} }
store := &integrationStoreStub{} if got := stderr.String(); got != "kwallet is not available; make sure KDE Wallet is installed and your session D-Bus is running\n" {
runner := &integrationRunnerStub{} t.Fatalf("unexpected stderr: %q", got)
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")
} }
} }
func TestAppRunMCPReturnsSetupHintWhenCredentialsAreMissing(t *testing.T) { func TestExecuteMCPWritesMissingCredentialGuidanceAndReturnsExitCodeOne(t *testing.T) {
store := &integrationStoreStub{ store := &entrypointStoreStub{loadErr: kwallet.ErrCredentialNotFound}
loadErr: kwallet.ErrCredentialNotFound, mail := entrypointMailServiceStub{}
} runner := mcpserver.NewRunner(mcpserver.New(store, mail), nil, &bytes.Buffer{}, &bytes.Buffer{})
output := &bytes.Buffer{} app := NewAppWithDependencies(nil, store, runner, nil)
mail := integrationMailServiceStub{}
app := buildApp(bytes.NewBuffer(nil), output, &bytes.Buffer{}, runtimeFactories{ stderr := &bytes.Buffer{}
newPrompter: func(io.Reader, io.Writer) SetupPrompter { if code := Execute(app, []string{"mcp"}, stderr); code != 1 {
return &integrationPromptStub{} t.Fatalf("expected exit code 1, got %d", code)
},
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")
} }
if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) { if got := stderr.String(); got != "credentials not configured; run `email-mcp setup`\n" {
t.Fatalf("expected credentials-not-configured sentinel, got %v", err) t.Fatalf("unexpected stderr: %q", got)
}
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())
} }
} }