test: cover cli entrypoint contract
This commit is contained in:
parent
afec7612aa
commit
9884ac40ac
3 changed files with 64 additions and 119 deletions
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
internal/cli/entrypoint.go
Normal file
22
internal/cli/entrypoint.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue