Files
multica/server/internal/integrations/dingtalk/ack_batch_test.go
苗大 3bacc41f84 MUL-7573 fix(lark): clear the Typing reaction without trusting in-process state (#8656)
* fix(lark): persist and reconcile typing cleanup across replicas

- preserve terminal trigger targets and per-input cleanup ownership
- reconcile late Adds and source deletion without delaying replies
- recover interrupted ledger indexes and cover lifecycle boundaries

Refs: #8655

* fix(lark): restrict typing cleanup to the current application

- match both app operator type and the current App ID
- preserve foreign-app, human and unknown-owner reactions
- cover ownership filtering in fast and durable cleanup paths

Refs: #8655

* fix(lark): bound typing recovery and credential retention

- recover taskless indicators after lost debounce timers
- cap workspace ledger admission and redact terminal credentials
- isolate maintenance budgets and test retention and quota boundaries

Refs: #8655

* docs(lark): document typing cleanup recovery and rollout limits

- explain ownership, retention, quota and observable terminal outcomes
- document migration upgrades and safe application rollback

Refs: #8655

* fix(deps): require patched Go 1.26.9 and x/net 0.60

- Require the patched Go standard library to clear reachable vulnerabilities.
- Upgrade x/net to the matching HTTP/2 security release.
- Align local development prerequisites with the minimum Go version.
2026-10-08 20:20:15 -07:00

398 lines
14 KiB
Go

package dingtalk
import (
"context"
"errors"
"slices"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/integrations/channel"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/dbid"
)
type ackInputQueries struct {
rows map[pgtype.UUID]db.ChatMessage
read func(pgtype.UUID)
err error
}
func (q *ackInputQueries) GetChatMessage(_ context.Context, id pgtype.UUID) (db.ChatMessage, error) {
if q.read != nil {
q.read(id)
}
if q.err != nil {
return db.ChatMessage{}, q.err
}
row, ok := q.rows[id]
if !ok {
return row, pgx.ErrNoRows
}
return row, nil
}
func TestAckBatchUsesSealedOwnershipAndInputOrder(t *testing.T) {
for _, tc := range []struct {
name string
sealed, fresh, reversed bool
want []string
}{
{"pending batch", false, false, false, []string{"add:a", "recall:a", "add:b"}},
{"same sealed batch", true, false, false, []string{"add:a", "recall:a", "add:b"}},
{"late older hook", false, false, true, []string{"add:b"}},
{"new context", false, true, false, []string{"add:a", "add:b"}},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
var actions []string
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
verb := "add:"
if recall {
verb = "recall:"
}
actions = append(actions, verb+msg.MessageID)
return nil
}
for i, name := range []string{"a", "b"} {
id := sessionUUID(byte(92 + i))
row := db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true, ChannelContextRevision: pgtype.Int8{Int64: 1, Valid: true}, CreatedAt: pgtype.Timestamptz{Time: time.Unix(int64(i), 0), Valid: true}}
if tc.sealed {
row.TaskID = sessionUUID(94)
}
if tc.fresh && i == 1 {
row.ChannelContextRevision.Int64 = 2
}
q.rows[id] = row
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage(name))
}
order := []string{"a", "b"}
if tc.reversed {
slices.Reverse(order)
}
for _, name := range order {
n.OnIngested(ctx, inst, groupReactionMessage(name), sid, sid)
}
if !slices.Equal(actions, tc.want) {
t.Fatalf("actions=%v want=%v", actions, tc.want)
}
})
}
}
func TestAckBatchDoesNotMoveAcrossTaskBoundary(t *testing.T) {
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
visible := map[string]bool{}
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
if recall {
delete(visible, msg.MessageID)
} else {
visible[msg.MessageID] = true
}
return nil
}
for i, name := range []string{"a", "b"} {
id := sessionUUID(byte(92 + i))
q.rows[id] = db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true, ChannelContextRevision: pgtype.Int8{Int64: 1, Valid: true}}
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage(name))
}
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
a := q.rows[sessionUUID(92)]
a.TaskID = sessionUUID(94)
q.rows[a.ID] = a
n.OnIngested(context.Background(), inst, groupReactionMessage("b"), sid, sid)
if len(visible) != 2 {
t.Fatalf("new batch removed old receipt: %v", visible)
}
n.onInputsSettled(context.Background(), sid, []db.ChatMessage{a})
if len(visible) != 1 || !visible["b"] {
t.Fatalf("old terminal cleared new batch: %v", visible)
}
}
func TestAckTerminalBeforeIngestDoesNotRecreateReceipt(t *testing.T) {
inst, sid, id := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91), sessionUUID(92)
row := db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
q := &ackInputQueries{rows: map[pgtype.UUID]db.ChatMessage{id: row}}
n := NewAckNotifier(nil, nil, nil, q)
calls := 0
n.sendReaction = func(context.Context, engine.ResolvedInstallation, channel.InboundMessage, string, bool) error {
calls++
return nil
}
// Even the local source capture may follow a fast terminal event.
n.onInputsSettled(context.Background(), sid, []db.ChatMessage{row})
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage("a"))
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
if calls != 0 || len(n.active) != 0 {
t.Fatalf("late hook recreated receipt: calls=%d active=%v", calls, n.active)
}
}
func TestAckBatchRetriesWhenInputSealsDuringLookup(t *testing.T) {
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
visible := map[string]bool{}
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
if recall {
delete(visible, msg.MessageID)
} else {
visible[msg.MessageID] = true
}
return nil
}
for i, name := range []string{"a", "b"} {
id := sessionUUID(byte(92 + i))
q.rows[id] = db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage(name))
}
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
q.read = func(id pgtype.UUID) {
if id == sessionUUID(92) {
q.read = nil
for id, row := range q.rows {
row.TaskID = sessionUUID(94)
q.rows[id] = row
}
}
}
n.OnIngested(context.Background(), inst, groupReactionMessage("b"), sid, sid)
if len(visible) != 1 || !visible["b"] {
t.Fatalf("sealing race left wrong receipts: %v", visible)
}
}
func TestAckBatchRecallsSupersededInFlightAdd(t *testing.T) {
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
for i, name := range []string{"a", "b"} {
id := sessionUUID(byte(92 + i))
q.rows[id] = db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage(name))
}
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
var mu sync.Mutex
visible := map[string]bool{}
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
if msg.MessageID == "a" && !recall {
close(started)
<-release
}
mu.Lock()
defer mu.Unlock()
if recall {
delete(visible, msg.MessageID)
} else {
visible[msg.MessageID] = true
}
return nil
}
go func() {
defer close(done)
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("add did not begin")
}
n.OnIngested(context.Background(), inst, groupReactionMessage("b"), sid, sid)
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("late add did not finish")
}
mu.Lock()
defer mu.Unlock()
if len(visible) != 1 || !visible["b"] {
t.Fatalf("old add reappeared: %v", visible)
}
}
func TestAckBatchQueryFailureSkipsReceipt(t *testing.T) {
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{err: errors.New("unavailable")}
n := NewAckNotifier(nil, nil, nil, q)
n.client.rememberReplySource(inst.ID, sessionUUID(92), sid, groupReactionMessage("a"))
n.sendReaction = func(context.Context, engine.ResolvedInstallation, channel.InboundMessage, string, bool) error {
t.Fatal("query failure guessed a batch")
return nil
}
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
if len(n.active) != 0 {
t.Fatal("failed query retained receipt")
}
}
func TestAckBatchRetriesFailedRecallOnNextMessage(t *testing.T) {
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
visible := map[string]bool{}
failed := false
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
if recall && msg.MessageID == "a" && !failed {
failed = true
return errors.New("temporary recall failure")
}
if recall {
delete(visible, msg.MessageID)
} else {
visible[msg.MessageID] = true
}
return nil
}
for i, name := range []string{"a", "b", "c"} {
id := sessionUUID(byte(92 + i))
q.rows[id] = db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage(name))
n.OnIngested(context.Background(), inst, groupReactionMessage(name), sid, sid)
if name == "b" && len(visible) != 2 {
t.Fatalf("fixture did not leave failed recall visible: %v", visible)
}
}
if len(visible) != 1 || !visible["c"] {
t.Fatalf("failed recall was not retried: %v", visible)
}
}
func TestAckTerminalDuringBatchLookupDoesNotRecreateReceipt(t *testing.T) {
inst, sid, id := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91), sessionUUID(92)
row := db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
q := &ackInputQueries{rows: map[pgtype.UUID]db.ChatMessage{id: row}}
n := NewAckNotifier(nil, nil, nil, q)
n.client.rememberReplySource(inst.ID, id, sid, groupReactionMessage("a"))
q.read = func(pgtype.UUID) {
q.read = nil
n.onInputsSettled(context.Background(), sid, []db.ChatMessage{row})
}
n.sendReaction = func(context.Context, engine.ResolvedInstallation, channel.InboundMessage, string, bool) error {
t.Fatal("terminal event during lookup recreated receipt")
return nil
}
n.OnIngested(context.Background(), inst, groupReactionMessage("a"), sid, sid)
}
func TestAckBatchRejectsUnattributableInput(t *testing.T) {
for _, kind := range []string{"cache miss", "other installation", "other conversation", "other session", "web input", "assistant input"} {
t.Run(kind, func(t *testing.T) {
inst, sid, id := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91), sessionUUID(92)
row := db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
q := &ackInputQueries{rows: map[pgtype.UUID]db.ChatMessage{}}
n := NewAckNotifier(nil, nil, nil, q)
msg := groupReactionMessage("a")
if kind != "cache miss" {
n.client.rememberReplySource(inst.ID, id, sid, msg)
}
switch kind {
case "other installation":
inst.ID = sessionUUID(93)
case "other conversation":
msg.Source.ChatID = "another-chat"
case "other session":
row.ChatSessionID = sessionUUID(94)
case "web input":
row.ChannelIngested = false
case "assistant input":
row.Role = "assistant"
}
q.rows[id] = row
n.sendReaction = func(context.Context, engine.ResolvedInstallation, channel.InboundMessage, string, bool) error {
t.Fatal("unattributable input received reaction")
return nil
}
n.OnIngested(context.Background(), inst, msg, sid, sid)
})
}
}
func TestAckTerminalFenceIsBounded(t *testing.T) {
n := NewAckNotifier(nil, nil, nil, nil)
sid := sessionUUID(91)
var first, last pgtype.UUID
for i := 0; i <= maxReplySources; i++ {
last = dbid.NewV7()
if i == 0 {
first = last
}
n.onInputsSettled(context.Background(), sid, []db.ChatMessage{{ID: last, ChatSessionID: sid, Role: "user", ChannelIngested: true}})
}
if len(n.settledInputs) != maxReplySources || n.settledInputs[first] || !n.settledInputs[last] {
t.Fatalf("terminal fence did not evict oldest entry: count=%d first=%v last=%v", len(n.settledInputs), n.settledInputs[first], n.settledInputs[last])
}
}
func TestAckConcurrentBatchKeepsLastInput(t *testing.T) {
const count = 24
inst, sid := engine.ResolvedInstallation{ID: sessionUUID(90)}, sessionUUID(91)
q := &ackInputQueries{rows: make(map[pgtype.UUID]db.ChatMessage)}
n := NewAckNotifier(nil, nil, nil, q)
var mu sync.Mutex
visible := make(map[string]bool)
n.sendReaction = func(_ context.Context, _ engine.ResolvedInstallation, msg channel.InboundMessage, _ string, recall bool) error {
mu.Lock()
defer mu.Unlock()
if recall {
delete(visible, msg.MessageID)
} else {
visible[msg.MessageID] = true
}
return nil
}
var messages []channel.InboundMessage
for i := 0; i < count; i++ {
id := sessionUUID(byte(100 + i))
msg := groupReactionMessage(string(rune('a' + i)))
messages = append(messages, msg)
q.rows[id] = db.ChatMessage{ID: id, ChatSessionID: sid, Role: "user", ChannelIngested: true}
n.client.rememberReplySource(inst.ID, id, sid, msg)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Make every hook begin from the same empty snapshot, then compete to
// publish. The last persisted input must win regardless of hook order.
ready := make(chan struct{})
seen := make(map[pgtype.UUID]bool)
q.read = func(id pgtype.UUID) {
mu.Lock()
if !seen[id] {
seen[id] = true
if len(seen) == count {
close(ready)
}
}
mu.Unlock()
select {
case <-ready:
case <-ctx.Done():
}
}
var hooks sync.WaitGroup
for _, msg := range messages {
hooks.Add(1)
go func() {
defer hooks.Done()
n.OnIngested(ctx, inst, msg, sid, sid)
}()
}
hooks.Wait()
if ctx.Err() != nil {
t.Fatal("concurrent hooks did not finish")
}
if len(visible) != 1 || !visible[messages[count-1].MessageID] {
t.Fatalf("concurrent batch did not converge to its last input: %v", visible)
}
}