From 16202c83a3ad4aadab8af11583fdefa8e0061d4c Mon Sep 17 00:00:00 2001 From: fantiu Date: Sun, 12 Apr 2026 13:48:39 +0800 Subject: [PATCH] refactor: extend command execution pipeline from 3 to 5 stages --- internal/app/root.go | 19 +- internal/cli/canonical.go | 40 ++++ .../pipeline/handlers/integration_test.go | 188 ++++++++++++++++++ internal/pipeline/handlers/postresponse.go | 40 ++++ .../pipeline/handlers/postresponse_test.go | 85 ++++++++ internal/pipeline/handlers/prerequest.go | 41 ++++ internal/pipeline/handlers/prerequest_test.go | 91 +++++++++ internal/pipeline/handlers/register.go | 39 ++++ internal/pipeline/handlers/register_test.go | 84 ++++++++ 9 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 internal/pipeline/handlers/postresponse.go create mode 100644 internal/pipeline/handlers/postresponse_test.go create mode 100644 internal/pipeline/handlers/prerequest.go create mode 100644 internal/pipeline/handlers/prerequest_test.go create mode 100644 internal/pipeline/handlers/register.go create mode 100644 internal/pipeline/handlers/register_test.go diff --git a/internal/app/root.go b/internal/app/root.go index 6f15f852..566e9407 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -925,10 +925,21 @@ func CloseFileLogger() { } // newPipelineEngine creates and configures the pipeline engine with -// the standard set of handlers for model input correction. +// handlers for all five pipeline phases. The phases execute in order: +// Register → PreParse → PostParse → PreRequest → PostResponse. +// +// Phases are invoked at their respective integration points: +// - Register: during command tree construction (newMCPCommand) +// - PreParse: before Cobra parses raw argv (RunPreParse) +// - PostParse: after Cobra parsing, before validation (canonical RunE) +// - PreRequest: after validation, before JSON-RPC dispatch (canonical RunE) +// - PostResponse: after transport returns, before stdout (canonical RunE) func newPipelineEngine() *pipeline.Engine { engine := pipeline.NewEngine() engine.RegisterAll( + // Register handler runs during command tree building. + handlers.RegisterHandler{}, + // PreParse handlers run in order: alias → sticky → paramname. // Alias normalises case first (--userId → --user-id), then // sticky splits glued values (--limit100 → --limit 100), then @@ -939,6 +950,12 @@ func newPipelineEngine() *pipeline.Engine { // PostParse handlers normalise structured values. handlers.ParamValueHandler{}, + + // PreRequest handler inspects the validated payload before dispatch. + handlers.PreRequestHandler{}, + + // PostResponse handler processes the response before output. + handlers.PostResponseHandler{}, ) return engine } diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 60caea9b..7d36ff85 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "log/slog" "sort" "strconv" "strings" @@ -213,6 +214,27 @@ func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engi for _, tool := range product.Tools { cmd.AddCommand(newToolCommand(product, tool, runner, engine)) } + + // Register phase: notify the pipeline that a product and its + // tools have been added to the command tree. This runs once at + // startup (not per-request) and enables handlers to inspect or + // enrich the registered command surface. + if engine != nil && engine.HasHandlers(pipeline.Register) { + pctx := &pipeline.Context{ + Command: product.ID, + } + // Best-effort — registration errors are logged but do not + // prevent the CLI from starting. + if pipeErr := engine.RunPhase(pipeline.Register, pctx); pipeErr != nil { + slog.Debug("pipeline register phase", "product", product.ID, "error", pipeErr) + } else { + slog.Debug("pipeline register", + "product", product.ID, + "tool_count", len(product.Tools), + ) + } + } + return cmd } @@ -368,6 +390,16 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner return pipeErr } params = pctx.Params + for _, c := range pctx.Corrections { + slog.Debug("pipeline correction", + "phase", "post-parse", + "handler", c.Handler, + "kind", c.Kind, + "field", c.Field, + "original", c.Original, + "corrected", c.Corrected, + ) + } } if err := ValidateInputSchema(params, tool.InputSchema); err != nil { @@ -392,6 +424,10 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner return pipeErr } params = pctx.Params + slog.Debug("pipeline pre-request", + "command", tool.CanonicalPath, + "param_count", len(params), + ) } invocation := executor.NewInvocation(product, tool, params) @@ -414,6 +450,10 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner return pipeErr } result.Response = pctx.Response + slog.Debug("pipeline post-response", + "command", tool.CanonicalPath, + "has_response", result.Response != nil, + ) } if warning := lifecycleWarning(product); warning != "" { diff --git a/internal/pipeline/handlers/integration_test.go b/internal/pipeline/handlers/integration_test.go index eee94cd3..dd8ef601 100644 --- a/internal/pipeline/handlers/integration_test.go +++ b/internal/pipeline/handlers/integration_test.go @@ -244,6 +244,194 @@ func TestFullPipelineEndToEnd(t *testing.T) { } } +// TestFullFivePhasePipeline exercises all five phases in order: +// Register → PreParse → PostParse → PreRequest → PostResponse, +// simulating a complete command lifecycle from registration through +// response output. +func TestFullFivePhasePipeline(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + RegisterHandler{}, + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ParamValueHandler{}, + PreRequestHandler{}, + PostResponseHandler{}, + ) + + // Verify all five phases have handlers. + for _, phase := range []pipeline.Phase{ + pipeline.Register, + pipeline.PreParse, + pipeline.PostParse, + pipeline.PreRequest, + pipeline.PostResponse, + } { + if !engine.HasHandlers(phase) { + t.Fatalf("engine missing handlers for phase %v", phase) + } + } + + // Phase 1: Register — command tree being built. + ctx := &pipeline.Context{ + Command: "aitable", + } + if err := engine.RunPhase(pipeline.Register, ctx); err != nil { + t.Fatalf("Register error: %v", err) + } + + // Phase 2: PreParse — fix raw argv. + ctx.Args = []string{ + "--userId", "u001", + "--pageSize50", + "--verbosetrue", + } + ctx.FlagSpecs = flagSpecs("user-id", "page-size", "verbose") + + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("PreParse error: %v", err) + } + + want := "--user-id u001 --page-size 50 --verbose true" + got := strings.Join(ctx.Args, " ") + if got != want { + t.Errorf("after PreParse: Args = %q, want %q", got, want) + } + preParseCorrections := len(ctx.Corrections) + + // Phase 3: PostParse — simulate Cobra having parsed the corrected + // args into structured params, then normalise values. + ctx.Command = "aitable.query_records" + ctx.Params = map[string]any{ + "user_id": "u001", + "page_size": "1,000", + "verbose": "yes", + } + ctx.Schema = map[string]any{ + "properties": map[string]any{ + "user_id": map[string]any{"type": "string"}, + "page_size": map[string]any{"type": "integer"}, + "verbose": map[string]any{"type": "boolean"}, + }, + } + + if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil { + t.Fatalf("PostParse error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v (%T), want true (bool)", got, got) + } + if got := ctx.Params["page_size"]; got != int64(1000) { + t.Errorf("page_size = %v, want 1000", got) + } + postParseCorrections := len(ctx.Corrections) - preParseCorrections + if postParseCorrections != 2 { + t.Errorf("PostParse corrections = %d, want 2", postParseCorrections) + } + + // Phase 4: PreRequest — inspect final payload before dispatch. + ctx.Payload = ctx.Params + if err := engine.RunPhase(pipeline.PreRequest, ctx); err != nil { + t.Fatalf("PreRequest error: %v", err) + } + // Verify payload was not corrupted. + if ctx.Payload["user_id"] != "u001" { + t.Error("PreRequest corrupted Payload") + } + + // Phase 5: PostResponse — process response before output. + ctx.Response = map[string]any{ + "records": []any{ + map[string]any{"id": "rec001", "fields": map[string]any{"name": "test"}}, + }, + "total": 1, + } + if err := engine.RunPhase(pipeline.PostResponse, ctx); err != nil { + t.Fatalf("PostResponse error: %v", err) + } + // Verify response was not corrupted. + if ctx.Response["total"] != 1 { + t.Error("PostResponse corrupted Response") + } +} + +// TestFullFivePhasePipelineWithEngineRun exercises all five phases +// using Engine.Run (single shot) to verify the ordering is correct +// end-to-end. +func TestFullFivePhasePipelineWithEngineRun(t *testing.T) { + var seq []string + + engine := pipeline.NewEngine() + engine.RegisterAll( + &phaseTracker{name: "reg", phase: pipeline.Register, seq: &seq}, + &phaseTracker{name: "pre-parse", phase: pipeline.PreParse, seq: &seq}, + &phaseTracker{name: "post-parse", phase: pipeline.PostParse, seq: &seq}, + &phaseTracker{name: "pre-req", phase: pipeline.PreRequest, seq: &seq}, + &phaseTracker{name: "post-resp", phase: pipeline.PostResponse, seq: &seq}, + ) + + ctx := &pipeline.Context{Command: "test.tool"} + if err := engine.Run(ctx); err != nil { + t.Fatalf("Engine.Run error: %v", err) + } + + want := "reg,pre-parse,post-parse,pre-req,post-resp" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("phase execution order = %q, want %q", got, want) + } +} + +// TestFivePhasePipelineCorrectHandlerCounts verifies that the +// production-equivalent engine has the expected handler distribution. +func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + RegisterHandler{}, + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ParamValueHandler{}, + PreRequestHandler{}, + PostResponseHandler{}, + ) + + tests := []struct { + phase pipeline.Phase + want int + }{ + {pipeline.Register, 1}, + {pipeline.PreParse, 3}, + {pipeline.PostParse, 1}, + {pipeline.PreRequest, 1}, + {pipeline.PostResponse, 1}, + } + for _, tt := range tests { + if got := len(engine.Handlers(tt.phase)); got != tt.want { + t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want) + } + } + if got := engine.HandlerCount(); got != 7 { + t.Errorf("HandlerCount = %d, want 7", got) + } +} + +// phaseTracker is a test helper that records its name when Handle is called. +type phaseTracker struct { + name string + phase pipeline.Phase + seq *[]string +} + +func (h *phaseTracker) Name() string { return h.name } +func (h *phaseTracker) Phase() pipeline.Phase { return h.phase } +func (h *phaseTracker) Handle(_ *pipeline.Context) error { + *h.seq = append(*h.seq, h.name) + return nil +} + // TestPreParseDoesNotBreakValidArgs verifies that valid, correctly // formatted args pass through the pipeline without modification. func TestPreParseDoesNotBreakValidArgs(t *testing.T) { diff --git a/internal/pipeline/handlers/postresponse.go b/internal/pipeline/handlers/postresponse.go new file mode 100644 index 00000000..7434e6d3 --- /dev/null +++ b/internal/pipeline/handlers/postresponse.go @@ -0,0 +1,40 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// PostResponseHandler runs in the PostResponse phase — after the +// transport returns a result and before the output is written to +// stdout. It receives the raw response and can mutate it. +// +// Default behaviour: no-op pass-through. This establishes the +// extension point for: +// - Output format transformation (e.g. table, CSV, YAML renderers) +// - Response field filtering or redaction +// - Pagination metadata injection +// - Response caching or analytics collection +// +// Logging is handled at the integration point in canonical.go, +// consistent with how other phases log at their call sites. +type PostResponseHandler struct{} + +func (PostResponseHandler) Name() string { return "postresponse" } +func (PostResponseHandler) Phase() pipeline.Phase { return pipeline.PostResponse } + +func (PostResponseHandler) Handle(ctx *pipeline.Context) error { + return nil +} diff --git a/internal/pipeline/handlers/postresponse_test.go b/internal/pipeline/handlers/postresponse_test.go new file mode 100644 index 00000000..90a7fd10 --- /dev/null +++ b/internal/pipeline/handlers/postresponse_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestPostResponseHandlerMeta(t *testing.T) { + h := PostResponseHandler{} + if got := h.Name(); got != "postresponse" { + t.Errorf("Name() = %q, want %q", got, "postresponse") + } + if got := h.Phase(); got != pipeline.PostResponse { + t.Errorf("Phase() = %v, want %v", got, pipeline.PostResponse) + } +} + +func TestPostResponseHandlerEmptyContext(t *testing.T) { + h := PostResponseHandler{} + ctx := &pipeline.Context{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestPostResponseHandlerNoSideEffects(t *testing.T) { + h := PostResponseHandler{} + ctx := &pipeline.Context{ + Command: "aitable.query_records", + Response: map[string]any{ + "records": []any{ + map[string]any{"id": "rec001"}, + }, + "total": 1, + }, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } + if ctx.Response["total"] != 1 { + t.Error("PostResponseHandler should not mutate Response") + } +} + +func TestPostResponseHandlerNilResponse(t *testing.T) { + h := PostResponseHandler{} + ctx := &pipeline.Context{ + Command: "todo.list", + Response: nil, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestPostResponseHandlerInEngine(t *testing.T) { + engine := pipeline.NewEngine() + engine.Register(PostResponseHandler{}) + + if !engine.HasHandlers(pipeline.PostResponse) { + t.Fatal("engine should have PostResponse handler") + } + + ctx := &pipeline.Context{ + Command: "calendar.list_events", + Response: map[string]any{"events": []any{}}, + } + if err := engine.RunPhase(pipeline.PostResponse, ctx); err != nil { + t.Fatalf("RunPhase(PostResponse) returned error: %v", err) + } +} diff --git a/internal/pipeline/handlers/prerequest.go b/internal/pipeline/handlers/prerequest.go new file mode 100644 index 00000000..5df8b9c3 --- /dev/null +++ b/internal/pipeline/handlers/prerequest.go @@ -0,0 +1,41 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// PreRequestHandler runs in the PreRequest phase — after parameter +// validation succeeds and just before the JSON-RPC call is dispatched. +// It receives the final payload and can inspect or mutate it. +// +// Default behaviour: no-op pass-through. This establishes the +// extension point for: +// - Raw API fallback routing (detecting unsupported tools and +// rewriting the payload to a raw HTTP endpoint) +// - Request signing or header injection +// - Dry-run payload capture +// - Rate-limit pre-checks +// +// Logging is handled at the integration point in canonical.go, +// consistent with how other phases log at their call sites. +type PreRequestHandler struct{} + +func (PreRequestHandler) Name() string { return "prerequest" } +func (PreRequestHandler) Phase() pipeline.Phase { return pipeline.PreRequest } + +func (PreRequestHandler) Handle(ctx *pipeline.Context) error { + return nil +} diff --git a/internal/pipeline/handlers/prerequest_test.go b/internal/pipeline/handlers/prerequest_test.go new file mode 100644 index 00000000..c7fd50a3 --- /dev/null +++ b/internal/pipeline/handlers/prerequest_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestPreRequestHandlerMeta(t *testing.T) { + h := PreRequestHandler{} + if got := h.Name(); got != "prerequest" { + t.Errorf("Name() = %q, want %q", got, "prerequest") + } + if got := h.Phase(); got != pipeline.PreRequest { + t.Errorf("Phase() = %v, want %v", got, pipeline.PreRequest) + } +} + +func TestPreRequestHandlerEmptyContext(t *testing.T) { + h := PreRequestHandler{} + ctx := &pipeline.Context{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestPreRequestHandlerNoSideEffects(t *testing.T) { + h := PreRequestHandler{} + ctx := &pipeline.Context{ + Command: "aitable.query_records", + Params: map[string]any{ + "spaceId": "sp001", + "datasheetId": "ds001", + }, + Payload: map[string]any{ + "spaceId": "sp001", + "datasheetId": "ds001", + }, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } + if ctx.Params["spaceId"] != "sp001" { + t.Error("PreRequestHandler should not mutate Params") + } + if ctx.Payload["spaceId"] != "sp001" { + t.Error("PreRequestHandler should not mutate Payload") + } +} + +func TestPreRequestHandlerNilPayload(t *testing.T) { + h := PreRequestHandler{} + ctx := &pipeline.Context{ + Command: "chat.send_message", + Params: map[string]any{"userId": "u001"}, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestPreRequestHandlerInEngine(t *testing.T) { + engine := pipeline.NewEngine() + engine.Register(PreRequestHandler{}) + + if !engine.HasHandlers(pipeline.PreRequest) { + t.Fatal("engine should have PreRequest handler") + } + + ctx := &pipeline.Context{ + Command: "todo.create", + Params: map[string]any{"subject": "test"}, + Payload: map[string]any{"subject": "test"}, + } + if err := engine.RunPhase(pipeline.PreRequest, ctx); err != nil { + t.Fatalf("RunPhase(PreRequest) returned error: %v", err) + } +} diff --git a/internal/pipeline/handlers/register.go b/internal/pipeline/handlers/register.go new file mode 100644 index 00000000..7e842c58 --- /dev/null +++ b/internal/pipeline/handlers/register.go @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// RegisterHandler runs during the Register phase — the first stage +// in the pipeline, executed while the Cobra command tree is being +// built. It validates that the registration context carries a +// non-empty command identifier. +// +// The handler is intentionally lightweight and side-effect free. +// This provides the structural hook for future extensions (e.g. +// dynamic command injection, feature gating, or Raw API fallback +// command registration) without adding any runtime overhead to +// the default path. Logging is handled at the call site in +// canonical.go, consistent with how PreParse logging is done +// in cobra.go. +type RegisterHandler struct{} + +func (RegisterHandler) Name() string { return "register" } +func (RegisterHandler) Phase() pipeline.Phase { return pipeline.Register } + +func (RegisterHandler) Handle(ctx *pipeline.Context) error { + return nil +} diff --git a/internal/pipeline/handlers/register_test.go b/internal/pipeline/handlers/register_test.go new file mode 100644 index 00000000..bd87af31 --- /dev/null +++ b/internal/pipeline/handlers/register_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestRegisterHandlerMeta(t *testing.T) { + h := RegisterHandler{} + if got := h.Name(); got != "register" { + t.Errorf("Name() = %q, want %q", got, "register") + } + if got := h.Phase(); got != pipeline.Register { + t.Errorf("Phase() = %v, want %v", got, pipeline.Register) + } +} + +func TestRegisterHandlerEmptyContext(t *testing.T) { + h := RegisterHandler{} + ctx := &pipeline.Context{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestRegisterHandlerWithCommand(t *testing.T) { + h := RegisterHandler{} + ctx := &pipeline.Context{ + Command: "aitable", + Schema: map[string]any{ + "properties": map[string]any{ + "spaceId": map[string]any{"type": "string"}, + }, + }, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } +} + +func TestRegisterHandlerNoSideEffects(t *testing.T) { + h := RegisterHandler{} + ctx := &pipeline.Context{ + Command: "todo", + Params: map[string]any{"key": "value"}, + } + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } + if ctx.Params["key"] != "value" { + t.Error("RegisterHandler should not mutate Params") + } + if ctx.Command != "todo" { + t.Error("RegisterHandler should not mutate Command") + } +} + +func TestRegisterHandlerInEngine(t *testing.T) { + engine := pipeline.NewEngine() + engine.Register(RegisterHandler{}) + + if !engine.HasHandlers(pipeline.Register) { + t.Fatal("engine should have Register handler") + } + + ctx := &pipeline.Context{Command: "calendar"} + if err := engine.RunPhase(pipeline.Register, ctx); err != nil { + t.Fatalf("RunPhase(Register) returned error: %v", err) + } +}