Files
WeiCHEN d019388f37 feat(intent): wire production QueryOwner and confirm dispatcher
Replace FakeActionOwner with QueryOwner+QueryGuard at the composition
root; DispatchEligible reuses claim/execute/recover so confirm stays
zero-write before dispatch and default tier remains legacy.
2026-09-22 21:44:56 +08:00

122 lines
4.5 KiB
Go

package intentflow_test
import (
"context"
"errors"
"testing"
"time"
"chat-agent-system/internal/app/intentflow"
)
func TestDispatchEligible_RecoverReceiptAfterCrash(t *testing.T) {
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC)
owner := intentflow.NewFakeActionOwner()
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
Now: func() time.Time { return now },
Lease: 10 * time.Second,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-crash", RequestDigest: "d-crash",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{ActionKey: "regulation.query", ActionDigest: "q"}},
})
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-crash", DecisionDigest: "d-crash", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
stepID := confirmed.Steps[0].StepID
claimed, err := p.ClaimStep(ctx, "w-crash", confirmed.PlanID, stepID)
if err != nil || claimed == nil {
t.Fatalf("claim: %v %+v", err, claimed)
}
// Domain receipt persisted; plan write-back has not happened yet (crash window).
if _, err := owner.Execute(ctx, intentflow.StepExec{
Principal: claimed.Plan.Principal, PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID,
ActionKey: claimed.Step.ActionKey, IdempotencyKey: claimed.IdempotencyKey,
ActionDigest: claimed.Step.ActionDigest, FencingToken: claimed.FencingToken,
}); err != nil {
t.Fatal(err)
}
if owner.ExecCount(claimed.IdempotencyKey) != 1 {
t.Fatalf("domain exec count = %d", owner.ExecCount(claimed.IdempotencyKey))
}
// Lease expires; re-claim then ExecuteClaimed fails re-auth → RecoverReceipt.
now = now.Add(30 * time.Second)
owner.Denied = true
view, err := p.DispatchEligible(ctx, "w-crash-2", confirmed.PlanID)
if err != nil {
t.Fatalf("recover path: %v", err)
}
if view == nil || len(view.Steps) == 0 || view.Steps[0].Status != intentflow.StepSucceeded {
t.Fatalf("recovered view: %+v", view)
}
if owner.ExecCount(claimed.IdempotencyKey) != 1 {
t.Fatalf("recover must not re-execute, count=%d", owner.ExecCount(claimed.IdempotencyKey))
}
}
func TestDispatchEligible_ReauthsOnExecute(t *testing.T) {
owner := intentflow.NewFakeActionOwner()
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-auth", RequestDigest: "d-auth",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{ActionKey: "regulation.query", ActionDigest: "q"}},
})
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-auth", DecisionDigest: "d-auth", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
owner.Denied = true
_, err = p.DispatchEligible(ctx, "w-auth", confirmed.PlanID)
if !errors.Is(err, intentflow.ErrPlanActionForbidden) {
t.Fatalf("revoked dispatch must fail closed, got %v", err)
}
}
func TestDispatchEligible_VersionConflict(t *testing.T) {
owner := intentflow.NewFakeActionOwner()
owner.RequiredVersion = map[string]string{"regulation.query": "v2"}
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
Owner: owner,
})
ctx := context.Background()
created, err := p.CreatePlan(ctx, intentflow.CreateCommand{
Principal: principal(), ClientTurnID: "t-ver", RequestDigest: "d-ver",
Status: intentflow.PlanAwaitingConfirmation, ExecutionMode: intentflow.ModeLive,
Steps: []intentflow.StepDraft{{
ActionKey: "regulation.query", ActionDigest: "q",
ExpectedResourceVersion: "v1",
}},
})
if err != nil {
t.Fatal(err)
}
// Confirm itself re-auths; version conflict must block before execute.
_, err = p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-ver", DecisionDigest: "d-ver", Kind: intentflow.DecisionConfirm,
})
if !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("want version conflict on confirm, got %v", err)
}
}