Files
dingtalk-workspace-cli/internal/helpers/connect_approval_card_test.go
2026-07-15 11:41:30 +08:00

906 lines
33 KiB
Go

// 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 helpers
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
// ---- fakes (no network, no real runner) ----
type fakeCardSender struct {
mu sync.Mutex
sent []*ApprovalRequest
updates []string
failSend bool
lastTrack string
}
func (f *fakeCardSender) SendApprovalCard(_ context.Context, _ string, req *ApprovalRequest) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSend {
return "", context.DeadlineExceeded
}
f.sent = append(f.sent, req)
f.lastTrack = "track-" + req.ID
return f.lastTrack, nil
}
func (f *fakeCardSender) UpdateApprovalCard(_ context.Context, _, text string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.updates = append(f.updates, text)
return nil
}
// fakeRunner records the invocations it is asked to run and reports them as
// implemented (so the orchestrator treats them as executed).
type fakeRunner struct {
mu sync.Mutex
runs []executor.Invocation
fail bool // when true, Run returns an error (simulates identity not ready)
}
func (r *fakeRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.runs = append(r.runs, inv)
if r.fail {
return executor.Result{}, context.DeadlineExceeded
}
inv.Implemented = true
return executor.Result{Invocation: inv}, nil
}
func (r *fakeRunner) setFail(v bool) { r.mu.Lock(); defer r.mu.Unlock(); r.fail = v }
func (r *fakeRunner) count() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.runs) }
// fakeNotifier records the proactive 1:1 messages text-mode approval sends to
// the owner and the requester.
type fakeNotifier struct {
mu sync.Mutex
sent []sentMsg
}
type sentMsg struct {
to []string
text string
}
func (n *fakeNotifier) sendOTOText(_ context.Context, userIDs []string, text string) error {
n.mu.Lock()
defer n.mu.Unlock()
n.sent = append(n.sent, sentMsg{to: append([]string(nil), userIDs...), text: text})
return nil
}
// toUser reports whether any recorded message was sent to userID.
func (n *fakeNotifier) toUser(userID string) bool {
n.mu.Lock()
defer n.mu.Unlock()
for _, m := range n.sent {
for _, u := range m.to {
if u == userID {
return true
}
}
}
return false
}
func (n *fakeNotifier) count() int { n.mu.Lock(); defer n.mu.Unlock(); return len(n.sent) }
func TestDingTalkApprovalCardSenderCoverage(t *testing.T) {
if newDingtalkApprovalCardSender("client", "secret", " ") != nil {
t.Fatal("empty approval template created a sender")
}
rec, srv := newCardAPIServer(t)
withCardAPIBase(t, srv.URL)
sender, ok := newDingtalkApprovalCardSender("client", "secret", "template").(*dingtalkApprovalCardSender)
if !ok {
t.Fatal("approval sender type mismatch")
}
req := &ApprovalRequest{ID: "approval", Summary: "Create todo", Requester: "requester"}
track, err := sender.SendApprovalCard(context.Background(), "owner", req)
if err != nil || track != "dws_approval_approval" {
t.Fatalf("SendApprovalCard() = %q, %v", track, err)
}
if err := sender.UpdateApprovalCard(context.Background(), track, "done"); err != nil {
t.Fatal(err)
}
rec.fail["PUT /v1.0/card/instances"] = 500
if err := sender.UpdateApprovalCard(context.Background(), track, "failed"); err == nil {
t.Fatal("approval update HTTP failure was ignored")
}
rec.fail = map[string]int{"POST /v1.0/card/instances": 500}
if _, err := sender.SendApprovalCard(context.Background(), "owner", req); err == nil {
t.Fatal("approval create HTTP failure was ignored")
}
}
func TestApprovalAutoRetryLifecycleCoverage(t *testing.T) {
var nilOrchestrator *approvalOrchestrator
nilOrchestrator.startAutoRetry(context.Background(), time.Millisecond)
(&approvalOrchestrator{}).startAutoRetry(context.Background(), time.Millisecond)
notifier := &fakeNotifier{}
orchestrator := &approvalOrchestrator{notifier: notifier, gate: newApprovalGate("")}
orchestrator.startAutoRetry(context.Background(), 0)
ctx, cancel := context.WithCancel(context.Background())
orchestrator.startAutoRetry(ctx, time.Millisecond)
time.Sleep(3 * time.Millisecond)
cancel()
time.Sleep(time.Millisecond)
}
// cardClickReq builds a card callback as DingTalk delivers it: the tapped
// button's action id plus its private params (approval id + decision).
func cardClickReq(outTrackID, approvalID, decision, actionID, userID string) *card.CardRequest {
return &card.CardRequest{
OutTrackId: outTrackID,
UserId: userID,
CardActionData: card.PrivateCardActionData{
CardPrivateData: card.CardPrivateData{
ActionIdList: []string{actionID},
Params: map[string]any{
approvalParamID: approvalID,
approvalParamDecision: decision,
},
},
},
}
}
// TestDecodeCardAction verifies the action parser pulls the approval id and the
// approve/reject decision from a button tap, via params and via the action id
// fallback.
func TestDecodeCardAction(t *testing.T) {
approve := cardClickReq("t1", "appr-1", approvalDecisionApprove, approvalActionApprove, "owner")
id, ok2, ok := decodeCardAction(approve)
if !ok || !ok2 || id != "appr-1" {
t.Fatalf("approve decode: id=%q approve=%v ok=%v", id, ok2, ok)
}
reject := cardClickReq("t2", "appr-2", approvalDecisionReject, approvalActionReject, "owner")
id, ok2, ok = decodeCardAction(reject)
if !ok || ok2 || id != "appr-2" {
t.Fatalf("reject decode: id=%q approve=%v ok=%v", id, ok2, ok)
}
// Decision param missing → fall back to the tapped action id.
fallback := &card.CardRequest{
OutTrackId: "t3",
CardActionData: card.PrivateCardActionData{
CardPrivateData: card.CardPrivateData{
ActionIdList: []string{approvalActionApprove},
Params: map[string]any{approvalParamID: "appr-3"},
},
},
}
id, ok2, ok = decodeCardAction(fallback)
if !ok || !ok2 || id != "appr-3" {
t.Fatalf("fallback decode: id=%q approve=%v ok=%v", id, ok2, ok)
}
// A non-approval card → not ok.
if _, _, ok := decodeCardAction(&card.CardRequest{OutTrackId: "x"}); ok {
t.Fatal("non-approval card should not decode as a decision")
}
}
// TestHandleCardCallback_DrivesDecide verifies a button callback flips the gate
// state and that an approval id missing from params is recovered by outTrackId.
func TestHandleCardCallback_DrivesDecide(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("c1")
o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner")
app := gate.Submit(ApprovalRequest{Summary: "x"})
gate.setOutTrackID(app.ID, "track-x")
// Callback carries no approval id in params → associate by outTrackId.
req := &card.CardRequest{
OutTrackId: "track-x",
UserId: "owner",
CardActionData: card.PrivateCardActionData{
CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionApprove}},
},
}
if _, err := o.handleCardCallback(context.Background(), req); err != nil {
t.Fatalf("callback err: %v", err)
}
if got := gate.Get(app.ID); !got.approved() {
t.Fatalf("state = %s, want approved after approve tap", got.State)
}
}
// TestHandleReply_RejectDoesNotExecute is the reject-path guarantee: a rejected
// approval must NOT run the planned command.
func TestHandleReply_RejectDoesNotExecute(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("c2")
sender := &fakeCardSender{}
runner := &fakeRunner{}
o := newApprovalOrchestrator(gate, sender, runner, "owner")
o.awaitWindow = time.Second
var replies []string
var rmu sync.Mutex
reply := func(_ context.Context, _, text string) error {
rmu.Lock()
replies = append(replies, text)
rmu.Unlock()
return nil
}
// The owner rejects shortly after the card is sent.
go func() {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
sender.mu.Lock()
n := len(sender.sent)
var id string
if n > 0 {
id = sender.sent[0].ID
}
sender.mu.Unlock()
if id != "" {
gate.Decide(id, false, "owner")
return
}
time.Sleep(5 * time.Millisecond)
}
}()
agentReply := `好的。[[ACTION:todo.create title="交方案"]]`
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
if !handled {
t.Fatal("an action reply must be handled by the gate")
}
if out != "" {
t.Fatalf("handled reply should return empty connector reply, got %q", out)
}
if runner.count() != 0 {
t.Fatalf("reject path must NOT execute, runner ran %d times", runner.count())
}
if gate.Get(gate.list()[0].ID).State != approvalRejected {
t.Fatal("request should be rejected")
}
rmu.Lock()
joined := strings.Join(replies, " | ")
rmu.Unlock()
if !strings.Contains(joined, "没批准") {
t.Fatalf("reject reply not posted: %q", joined)
}
}
// TestToPlannedAction_Actions covers the action verbs the twin can execute:
// each maps to the right product/tool, and missing required args degrade to
// "not an action" (ok=false) so a malformed marker never submits a no-op.
func TestToPlannedAction_Actions(t *testing.T) {
cases := []struct {
name string
act detectedAction
wantOK bool
wantProduct string
wantTool string
}{
{"todo", detectedAction{Verb: "todo.create", Args: map[string]string{"title": "交方案"}}, true, "todo", "create_personal_todo"},
{"calendar", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会", "start": "2026-06-20T10:00:00+08:00", "end": "2026-06-20T11:00:00+08:00"}}, true, "calendar", "create_calendar_event"},
{"calendar missing time", detectedAction{Verb: "calendar.create", Args: map[string]string{"title": "评审会"}}, false, "", ""},
{"doc", detectedAction{Verb: "doc.create", Args: map[string]string{"name": "周报"}}, true, "doc", "create_document"},
{"doc missing name", detectedAction{Verb: "doc.create", Args: map[string]string{}}, false, "", ""},
{"unknown verb", detectedAction{Verb: "drive.delete", Args: map[string]string{}}, false, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pa, summary, ok := toPlannedAction(tc.act, "owner")
if ok != tc.wantOK {
t.Fatalf("ok=%v want %v", ok, tc.wantOK)
}
if !ok {
return
}
if pa.Product != tc.wantProduct || pa.Tool != tc.wantTool {
t.Fatalf("got %s/%s want %s/%s", pa.Product, pa.Tool, tc.wantProduct, tc.wantTool)
}
if summary == "" {
t.Fatal("summary should not be empty for a valid action")
}
// Every twin action is write-class → must be gated.
if classifyPlannedAction(pa) != CmdClassWrite {
t.Fatalf("%s should classify as write (gated), got %s", tc.name, classifyPlannedAction(pa))
}
})
}
}
// TestClassifyPlannedAction verifies the gate's read/write bridge: it classifies
// on the human command path first and falls back to product + RPC tool name.
func TestClassifyPlannedAction(t *testing.T) {
cases := []struct {
name string
pa plannedAction
want CmdClass
}{
{
name: "write via legacy path",
pa: plannedAction{Product: "todo", Tool: "create_personal_todo", LegacyPath: "todo task create"},
want: CmdClassWrite,
},
{
name: "read via legacy path",
pa: plannedAction{Product: "todo", Tool: "list_personal_todo", LegacyPath: "todo task list"},
want: CmdClassRead,
},
{
name: "empty legacy path falls back to product+tool",
pa: plannedAction{Product: "todo", Tool: "create_personal_todo"},
want: CmdClassWrite,
},
{
name: "unrecognised path falls back to tool verb",
pa: plannedAction{Product: "todo", Tool: "delete_personal_todo", LegacyPath: "todo xyz123"},
want: CmdClassWrite,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := classifyPlannedAction(tc.pa); got != tc.want {
t.Fatalf("classifyPlannedAction(%+v) = %s, want %s", tc.pa, got, tc.want)
}
})
}
}
// TestHandleReply_ReadClassBypassesGate proves the "写类才拦" wiring: when the
// planned command classifies as read-only, the gate does not engage — no card is
// sent, nothing runs, and the cleaned reply goes out normally (handled=false).
// We force the todo.create path to read-class via an override so the test drives
// the real handleReply → classifyPlannedAction path end to end.
func TestHandleReply_ReadClassBypassesGate(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
SetCmdClassOverride("todo task create", CmdClassRead)
defer SetCmdClassOverride("todo task create", CmdClassUnknown)
gate := newApprovalGate("c3")
sender := &fakeCardSender{}
runner := &fakeRunner{}
o := newApprovalOrchestrator(gate, sender, runner, "owner")
reply := func(_ context.Context, _, _ string) error { return nil }
agentReply := `好的,已为你查到。[[ACTION:todo.create title="交方案"]]`
out, handled := o.handleReply(context.Background(), "requester", "conv-1", agentReply, reply)
if handled {
t.Fatal("a read-class action must NOT be handled by the gate")
}
if !strings.Contains(out, "已为你查到") || strings.Contains(out, "[[ACTION") {
t.Fatalf("read-class reply should be the cleaned text without the marker, got %q", out)
}
sender.mu.Lock()
nSent := len(sender.sent)
sender.mu.Unlock()
if nSent != 0 {
t.Fatalf("read-class action must not send a confirmation card, sent %d", nSent)
}
if runner.count() != 0 {
t.Fatalf("read-class action must not execute, runner ran %d times", runner.count())
}
if len(gate.list()) != 0 {
t.Fatalf("read-class action must not create an approval request, got %d", len(gate.list()))
}
}
// TestParseDecisionWord checks whole-message decision parsing: a bare keyword
// decides, anything else (including a keyword embedded in a sentence) does not.
func TestParseDecisionWord(t *testing.T) {
cases := []struct {
in string
wantApprove bool
wantOk bool
}{
{"同意", true, true},
{" 同意 ", true, true},
{"通过", true, true},
{"Yes", true, true},
{"OK", true, true},
{"拒绝", false, true},
{"no", false, true},
{"不同意", false, true},
{"同意他的看法", false, false}, // embedded, not a bare decision
{"帮我创建个待办", false, false},
{"", false, false},
}
for _, tc := range cases {
gotApprove, gotOk := parseDecisionWord(tc.in)
if gotOk != tc.wantOk || (tc.wantOk && gotApprove != tc.wantApprove) {
t.Fatalf("parseDecisionWord(%q) = (%v,%v), want (%v,%v)", tc.in, gotApprove, gotOk, tc.wantApprove, tc.wantOk)
}
}
}
// TestPendingForConv finds the pending request for a conversation and stops
// returning it once decided.
func TestPendingForConv(t *testing.T) {
gate := newApprovalGate("") // in-memory
a := gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "a"})
gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "b"})
if got := gate.pendingForConv("c1"); got == nil || got.ID != a.ID {
t.Fatalf("pendingForConv(c1) = %v, want request %s", got, a.ID)
}
if got := gate.pendingForConv("none"); got != nil {
t.Fatalf("pendingForConv(none) = %v, want nil", got)
}
gate.Decide(a.ID, true, "owner")
if got := gate.pendingForConv("c1"); got != nil {
t.Fatalf("decided request must not be pending, got %v", got)
}
}
// TestHandleReply_TextMode_OwnerApproves is the private text-approval happy
// path: an action privately DMs the OWNER (the requester sees nothing, no
// execution yet); a non-owner "同意" is ignored; the owner's "同意" — sent from
// their own chat, NOT the request conversation — decides and executes.
func TestHandleReply_TextMode_OwnerApproves(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ct1")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
// A group requester (not the owner) asks for an action.
out, handled := o.handleReply(context.Background(), "requester", "conv-group", `好的。[[ACTION:todo.create title="交方案"]]`, nil)
if !handled || out != "" {
t.Fatalf("text-mode action: handled=%v out=%q, want handled with empty out (requester sees nothing)", handled, out)
}
if runner.count() != 0 {
t.Fatalf("must not execute before the owner decides, ran %d", runner.count())
}
if len(gate.list()) != 1 {
t.Fatalf("expected one pending request, got %d", len(gate.list()))
}
// The approval prompt went privately to the OWNER, never to the requester.
if !notifier.toUser("owner") {
t.Fatalf("approval prompt was not DMed to the owner: %+v", notifier.sent)
}
if notifier.toUser("requester") {
t.Fatal("the requester must NOT be notified of the approval")
}
// A non-owner saying 同意 must not decide or execute.
if o.handleOwnerDecision(context.Background(), "intruder", "同意") {
t.Fatal("non-owner must not be able to approve")
}
if runner.count() != 0 {
t.Fatalf("non-owner approval must not execute, ran %d", runner.count())
}
// The owner saying 同意 decides and executes; the requester then gets the
// outcome.
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
t.Fatal("owner approval must be consumed")
}
if runner.count() != 1 {
t.Fatalf("owner approval must execute exactly once, ran %d", runner.count())
}
if gate.list()[0].State != approvalExecuted {
t.Fatalf("request state = %s, want executed", gate.list()[0].State)
}
if !notifier.toUser("requester") {
t.Fatal("the requester should get the final outcome after execution")
}
}
// TestHandleReply_TextMode_OwnerSelfAutoApproves: when the requester IS the
// owner, the action runs immediately with NO confirmation round-trip, but is
// still recorded (auto_approved=true, decided-by owner, executed) for audit.
func TestHandleReply_TextMode_OwnerSelfAutoApproves(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctself")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
var replies []string
reply := func(_ context.Context, _, text string) error { replies = append(replies, text); return nil }
// The OWNER asks for the action themselves.
out, handled := o.handleReply(context.Background(), "owner", "conv-owner", `好。[[ACTION:todo.create title="自己的待办"]]`, reply)
if !handled || out != "" {
t.Fatalf("owner self-request: handled=%v out=%q", handled, out)
}
// Executed immediately — no second confirmation asked.
if runner.count() != 1 {
t.Fatalf("owner self-request must execute immediately, ran %d", runner.count())
}
if notifier.count() != 0 {
t.Fatalf("owner self-request must NOT send an approval DM, sent %d", notifier.count())
}
// Still recorded for audit: auto_approved + executed + decided by owner.
rec := gate.list()[0]
if !rec.AutoApproved || rec.State != approvalExecuted || rec.DecidedBy != "owner" {
t.Fatalf("audit record wrong: autoApproved=%v state=%s decidedBy=%s", rec.AutoApproved, rec.State, rec.DecidedBy)
}
if !strings.Contains(strings.Join(replies, "|"), "已为你完成") {
t.Fatalf("owner should get the result, replies=%v", replies)
}
}
// fakeAudit records the requests handed to the audit sink.
type fakeAudit struct {
mu sync.Mutex
recs []*ApprovalRequest
}
func (a *fakeAudit) record(_ context.Context, req *ApprovalRequest) {
a.mu.Lock()
defer a.mu.Unlock()
cp := *req
a.recs = append(a.recs, &cp)
}
func (a *fakeAudit) count() int { a.mu.Lock(); defer a.mu.Unlock(); return len(a.recs) }
// TestAudit_RecordsTerminalOutcomes verifies the audit sink gets one terminal
// record per action: an auto-executed owner request, and an approved request.
func TestAudit_RecordsTerminalOutcomes(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("cta")
audit := &fakeAudit{}
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
o.audit = audit
reply := func(_ context.Context, _, _ string) error { return nil }
// Owner self-request → auto-executed → audited once, as executed.
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自己"]]`, reply)
if audit.count() != 1 {
t.Fatalf("auto-exec must produce one audit record, got %d", audit.count())
}
if audit.recs[0].State != approvalExecuted || !audit.recs[0].AutoApproved {
t.Fatalf("auto audit record: state=%s auto=%v", audit.recs[0].State, audit.recs[0].AutoApproved)
}
// Someone else's request, approved by the owner → audited as executed.
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="别人"]]`, reply)
if audit.count() != 1 {
t.Fatal("a pending (not yet decided) request must not be audited yet")
}
o.handleOwnerDecision(context.Background(), "owner", "同意")
if audit.count() != 2 {
t.Fatalf("owner approval must produce a second audit record, got %d", audit.count())
}
if audit.recs[1].State != approvalExecuted || audit.recs[1].DecidedBy != "owner" {
t.Fatalf("approved audit record: state=%s decidedBy=%s", audit.recs[1].State, audit.recs[1].DecidedBy)
}
}
// TestDeferredExecution_HoldsAndRetries is the graceful-degradation path: when
// execution fails (identity not ready), the request is DEFERRED (not lost, not
// failed), the owner is privately notified; after the owner recovers and replies
// "重试", the backlog is replayed, executes, and the requester gets the result.
func TestDeferredExecution_HoldsAndRetries(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctdef")
runner := &fakeRunner{fail: true} // identity not ready → execution fails
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
// A non-owner request, approved by the owner, but execution fails → deferred.
o.handleReply(context.Background(), "requester", "conv-g", `[[ACTION:todo.create title="待恢复"]]`, nil)
if !o.handleOwnerDecision(context.Background(), "owner", "同意") {
t.Fatal("owner approval must be consumed")
}
rec := gate.list()[0]
if rec.State != approvalDeferred {
t.Fatalf("failed execution must DEFER (not fail), got state=%s", rec.State)
}
// Owner was told to recover; requester was NOT told it failed (task not lost).
if !notifier.toUser("owner") {
t.Fatal("owner should be notified of the deferred action")
}
if notifier.toUser("requester") {
t.Fatal("requester must NOT be told it failed — their task is held, not lost")
}
// A bare non-retry word from the owner should pass through (not consumed).
if o.handleOwnerDecision(context.Background(), "owner", "在吗") {
t.Fatal("ordinary owner message must not be consumed")
}
// Owner recovers (identity now works) and replies "重试" → backlog flushes.
runner.setFail(false)
if !o.handleOwnerDecision(context.Background(), "owner", "重试") {
t.Fatal("retry word must be consumed")
}
if gate.list()[0].State != approvalExecuted {
t.Fatalf("after retry the deferred request must execute, got %s", gate.list()[0].State)
}
if !notifier.toUser("requester") {
t.Fatal("requester should get the outcome after the backlog is flushed")
}
}
// TestConfirmPolicy_Auto: a non-owner request runs without asking under "auto".
func TestConfirmPolicy_Auto(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctauto2")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
o.confirmPolicy = "auto"
_, handled := o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, func(_ context.Context, _, _ string) error { return nil })
if !handled {
t.Fatal("auto policy should handle (run) the action")
}
if runner.count() != 1 {
t.Fatalf("auto policy must execute without asking, ran %d", runner.count())
}
if gate.list()[0].State != approvalExecuted {
t.Fatalf("state=%s want executed", gate.list()[0].State)
}
}
// TestConfirmPolicy_Manual: a non-owner request asks the owner (default).
func TestConfirmPolicy_Manual(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctman")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier) // no policy = manual
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="x"]]`, nil)
if runner.count() != 0 {
t.Fatal("manual policy must NOT execute before the owner decides")
}
if gate.list()[0].State != approvalPending {
t.Fatalf("state=%s want pending (awaiting owner)", gate.list()[0].State)
}
if !notifier.toUser("owner") {
t.Fatal("manual policy should DM the owner for approval")
}
}
// TestConfirmPolicy_Remember: ask once per verb, then reuse the decision.
func TestConfirmPolicy_Remember(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctrem")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
o.confirmPolicy = "remember"
// First todo.create from a non-owner → asks (pending).
o.handleReply(context.Background(), "someone", "c1", `[[ACTION:todo.create title="第一次"]]`, nil)
if gate.latestPending() == nil {
t.Fatal("first request should ask the owner")
}
// Owner approves → remembered, executes.
o.handleOwnerDecision(context.Background(), "owner", "同意")
if runner.count() != 1 {
t.Fatalf("approved request should execute, ran %d", runner.count())
}
// Second todo.create → reused decision, auto-runs (no new pending).
_, handled := o.handleReply(context.Background(), "someone", "c2", `[[ACTION:todo.create title="第二次"]]`, func(_ context.Context, _, _ string) error { return nil })
if !handled || runner.count() != 2 {
t.Fatalf("remembered approve should auto-run the 2nd request, handled=%v ran=%d", handled, runner.count())
}
}
// TestScopeEnforcement: an action outside the role's allowlist is refused up
// front (the requester is told, nothing reaches the gate); an in-scope action
// proceeds; an empty allowlist allows everything.
func TestScopeEnforcement(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctscope")
runner := &fakeRunner{}
o := newTextApprovalOrchestrator(gate, runner, "owner", &fakeNotifier{})
o.allowedScopes = []string{"todo"} // HR-style: only todo
// doc.create is out of scope → refused, NOT gated, requester told.
out, handled := o.handleReply(context.Background(), "requester", "c1", `[[ACTION:doc.create name="周报"]]`, nil)
if handled {
t.Fatal("out-of-scope action must not be handled by the gate")
}
if !strings.Contains(out, "没有") || !strings.Contains(out, "doc") {
t.Fatalf("requester should be told it's out of scope, got %q", out)
}
if len(gate.list()) != 0 {
t.Fatal("out-of-scope action must not create an approval request")
}
// todo.create is in scope → proceeds (owner self-request auto-executes).
o.handleReply(context.Background(), "owner", "c2", `[[ACTION:todo.create title="排期"]]`, func(_ context.Context, _, _ string) error { return nil })
if len(gate.list()) != 1 {
t.Fatalf("in-scope action should be processed, got %d requests", len(gate.list()))
}
// scopeAllows: empty allowlist = allow all.
o.allowedScopes = nil
if !o.scopeAllows("anything") {
t.Fatal("empty allowlist must allow all products")
}
}
// TestAutoFlushDeferred verifies the background retry: silent while the identity
// is still wrong (no spam), and it drains + notifies once execution works.
func TestAutoFlushDeferred(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctauto")
runner := &fakeRunner{fail: true}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
// Defer one (identity not ready).
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="自动恢复"]]`, func(_ context.Context, _, _ string) error { return nil })
deferNotices := notifier.count() // the deferred DM
if gate.list()[0].State != approvalDeferred {
t.Fatalf("want deferred, got %s", gate.list()[0].State)
}
// Auto-retry while still failing → stays deferred, NO new owner message.
o.autoFlushDeferred(context.Background())
if gate.list()[0].State != approvalDeferred {
t.Fatal("should still be deferred while identity is wrong")
}
if notifier.count() != deferNotices {
t.Fatalf("auto-retry must stay silent when nothing completes (sent %d, was %d)", notifier.count(), deferNotices)
}
// Identity recovers → auto-retry drains it and notifies once.
runner.setFail(false)
o.autoFlushDeferred(context.Background())
if gate.list()[0].State != approvalExecuted {
t.Fatalf("auto-retry should execute once identity is back, got %s", gate.list()[0].State)
}
notifier.mu.Lock()
last := notifier.sent[len(notifier.sent)-1].text
notifier.mu.Unlock()
if !strings.Contains(last, "已自动恢复") || !strings.Contains(last, "自动恢复") {
t.Fatalf("owner should get an auto-recovery notice, got %q", last)
}
}
// TestIsTransientSheetErr distinguishes retryable throttles from permanent errors.
func TestIsTransientSheetErr(t *testing.T) {
for _, e := range []string{"system error: THREADPOOL_BUSY", "server busy", "request timeout", "HTTP 429 too many requests"} {
if !isTransientSheetErr(fmt.Errorf("%s", e)) {
t.Fatalf("%q should be transient", e)
}
}
for _, e := range []string{"invalid nodeId", "permission denied", "not found"} {
if isTransientSheetErr(fmt.Errorf("%s", e)) {
t.Fatalf("%q should NOT be transient", e)
}
}
if isTransientSheetErr(nil) {
t.Fatal("nil is not transient")
}
}
// TestFlushDeferred_ReportsCompletedItems verifies the owner gets a per-item
// completion list (not just a count) when the backlog is flushed.
func TestFlushDeferred_ReportsCompletedItems(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ctflush")
runner := &fakeRunner{fail: true}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
o.handleReply(context.Background(), "owner", "conv-o", `[[ACTION:todo.create title="我的待办"]]`, func(_ context.Context, _, _ string) error { return nil })
if gate.list()[0].State != approvalDeferred {
t.Fatalf("want deferred, got %s", gate.list()[0].State)
}
runner.setFail(false)
o.handleOwnerDecision(context.Background(), "owner", "重试")
notifier.mu.Lock()
var last string
if len(notifier.sent) > 0 {
last = notifier.sent[len(notifier.sent)-1].text
}
notifier.mu.Unlock()
if !strings.Contains(last, "已补做 1 个") || !strings.Contains(last, "创建待办:我的待办") {
t.Fatalf("flush summary should list the completed item, got %q", last)
}
}
// TestIsRetryWord checks whole-message retry-keyword matching.
func TestIsRetryWord(t *testing.T) {
for _, w := range []string{"重试", " 恢复 ", "retry", "继续"} {
if !isRetryWord(w) {
t.Fatalf("%q should be a retry word", w)
}
}
for _, w := range []string{"重试一下他的方案", "", "你好"} {
if isRetryWord(w) {
t.Fatalf("%q should NOT be a retry word", w)
}
}
}
// TestHandleReply_TextMode_OwnerRejects: the owner's "拒绝" declines without
// executing.
func TestHandleReply_TextMode_OwnerRejects(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ct2")
runner := &fakeRunner{}
notifier := &fakeNotifier{}
o := newTextApprovalOrchestrator(gate, runner, "owner", notifier)
o.handleReply(context.Background(), "requester", "conv-group", `[[ACTION:todo.create title="x"]]`, nil)
if !o.handleOwnerDecision(context.Background(), "owner", "拒绝") {
t.Fatal("owner rejection must be consumed")
}
if runner.count() != 0 {
t.Fatalf("rejection must NOT execute, ran %d", runner.count())
}
if gate.list()[0].State != approvalRejected {
t.Fatalf("state = %s, want rejected", gate.list()[0].State)
}
}
// TestHandleOwnerDecision_NotADecision: an ordinary owner message (not a bare
// keyword) is not consumed, so it still reaches the agent.
func TestHandleOwnerDecision_NotADecision(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
gate := newApprovalGate("ct3")
o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{})
gate.Submit(ApprovalRequest{ConvID: "conv-1", Summary: "x", State: approvalPending})
if o.handleOwnerDecision(context.Background(), "owner", "顺便帮我查下天气") {
t.Fatal("a non-decision owner message must pass through to the agent")
}
}
// TestLatestPending returns the newest pending request across conversations.
func TestLatestPending(t *testing.T) {
gate := newApprovalGate("")
gate.Submit(ApprovalRequest{ConvID: "c1", Summary: "old", CreatedAt: time.Unix(100, 0)})
newer := gate.Submit(ApprovalRequest{ConvID: "c2", Summary: "new", CreatedAt: time.Unix(200, 0)})
if got := gate.latestPending(); got == nil || got.ID != newer.ID {
t.Fatalf("latestPending = %v, want newest %s", got, newer.ID)
}
gate.Decide(newer.ID, true, "owner")
if got := gate.latestPending(); got == nil || got.Summary != "old" {
t.Fatalf("after deciding newest, latestPending should fall back to the older pending, got %v", got)
}
}
// list is a tiny test helper to enumerate the gate's requests.
func (g *approvalGate) list() []*ApprovalRequest {
g.mu.Lock()
defer g.mu.Unlock()
out := make([]*ApprovalRequest, 0, len(g.reqs))
for _, r := range g.reqs {
cp := *r
out = append(out, &cp)
}
return out
}