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.
122 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|