From 2fee513d34e4dacd4121b5fb4631b5f953913031 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 7 Oct 2026 20:59:23 +0800 Subject: [PATCH] test(mock): verify HTTP hangup snapshots and MQ payload boundaries --- deploys/test/saas-mock/README.md | 7 ++ deploys/test/saas-mock/hangup_config_test.go | 126 +++++++++++++++++++ deploys/test/saas-mock/publish_test.go | 10 ++ docs/evidence/hangup-keyword-closing.md | 10 ++ 4 files changed, 153 insertions(+) create mode 100644 deploys/test/saas-mock/hangup_config_test.go diff --git a/deploys/test/saas-mock/README.md b/deploys/test/saas-mock/README.md index dee9912..5c2a9c6 100644 --- a/deploys/test/saas-mock/README.md +++ b/deploys/test/saas-mock/README.md @@ -6,6 +6,13 @@ 准备仅自己可读的目录,包含 `sip.json`、`providers.json`、`quota.json` 和 `tasks/*.json`;所有 JSON 文件须为普通文件且权限为 `0600`。分别对应 [`contracts/local/`](../../../contracts/local/) 的 `sip_config`、`ai_providers`、`tenant_quota`、`task_config`;每份快照的 `dispatcher_id` 必须相同,任务须属于同一租户且文件名为 `.json`。启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/local/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。 +## 结束语配置与 MQ 数据边界 + +- `tasks/.json` 使用现行对象数组形式的 `agent.conversation.hangup_keywords`;配置示例见 [`config-read-task-full.json`](../../../contracts/local/examples/config-read-task-full.json),唯一字段定义见 [`config-read.schema.json`](../../../contracts/local/config-read.schema.json)。旧字符串数组、缺失或空的组名/触发词/结束语会导致启动拒绝。 +- 新配置由正式 HTTP 任务接口返回;Mock 不自行生成、覆盖或注入结束语。Agent 使用该任务快照合成结束语,播完后挂断。 +- 单次投递的 `call.execute.payload` 仍仅包含 `task_id` 和原始 `callee`,不得增加 `agent`、`hangup_keywords` 或 `closingRemark`;任务、线路、AI 参数和结束语均由 HTTP 快照决定。MQ 字段以 [`mq.schema.json`](../../../contracts/local/mq.schema.json) 为准。 +- 本地 HTTP 与真实临时 RabbitMQ 测试均核对这一区分;本地测试不更新测试机私有快照,也不授权实际呼叫。 + ## 运行 - 可先以 `go run ./deploys/test/saas-mock --validate-only --data <私有目录> --dispatcher-id ` 离线校验快照;不连接 RabbitMQ/HTTPS。 diff --git a/deploys/test/saas-mock/hangup_config_test.go b/deploys/test/saas-mock/hangup_config_test.go new file mode 100644 index 0000000..bb86dc4 --- /dev/null +++ b/deploys/test/saas-mock/hangup_config_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +func TestSaaSMockServesApprovedHangupGroupsOverHTTP(t *testing.T) { + data, err := loadDataset(testDataDir(t), testDispatcher) + if err != nil { + t.Fatal(err) + } + server := httptest.NewTLSServer(data.handler("test-only-secret")) + defer server.Close() + client, err := configread.NewClient(server.URL, testDispatcher, "test-only-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + sip, err := client.ReadSIP(ctx) + if err != nil { + t.Fatal(err) + } + providers, err := client.ReadProviders(ctx) + if err != nil { + t.Fatal(err) + } + snapshot, err := client.ReadTask(ctx, "task-full", 1001, sip, providers) + if err != nil { + t.Fatal(err) + } + bound, err := ai.Bind(snapshot.Task, snapshot.Providers) + if err != nil { + t.Fatal(err) + } + if len(bound.HangupKeywords) != 1 || bound.HangupKeywords[0].Name != "结束通话" || len(bound.HangupKeywords[0].Triggers) != 1 || bound.HangupKeywords[0].Triggers[0] != "不用了" || bound.HangupKeywords[0].ClosingRemark != "好的,祝您生活愉快。" { + t.Fatalf("HTTP task snapshot lost the approved hangup group: %+v", bound.HangupKeywords) + } +} + +// Both unit and live-broker checks use this to enforce the HTTP/MQ boundary. +func verifyExecutePayload(body []byte, taskID, callee string) error { + var message struct { + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(body, &message); err != nil { + return err + } + if len(message.Payload) != 2 || message.Payload["task_id"] != taskID || message.Payload["callee"] != callee { + return errors.New("Mock MQ command must contain only task_id and the original callee") + } + return nil +} + +func TestSaaSMockMQRejectsInlineHangupConfiguration(t *testing.T) { + data, err := loadDataset(testDataDir(t), testDispatcher) + if err != nil { + t.Fatal(err) + } + _, body, err := buildExecute(data, "event-config-boundary", "task-full", "13900000000", time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if err := verifyExecutePayload(body, "task-full", "13900000000"); err != nil { + t.Fatal(err) + } + for _, field := range []string{"agent", "hangup_keywords", "closingRemark"} { + t.Run(field, func(t *testing.T) { + var message map[string]any + if err := json.Unmarshal(body, &message); err != nil { + t.Fatal(err) + } + message["payload"].(map[string]any)[field] = map[string]any{"name": "挂机1", "triggers": []string{"再见"}, "closingRemark": "好的,再见。"} + bad, err := json.Marshal(message) + if err != nil { + t.Fatal(err) + } + if err := contract.ValidateCurrent("mq", bad); err == nil { + t.Fatal("MQ accepted inline configuration instead of the task snapshot") + } + }) + } +} + +func TestSaaSMockRejectsLegacyAndIncompleteHangupSnapshots(t *testing.T) { + for name, groups := range map[string]any{ + "legacy-strings": []string{"不用了"}, + "missing-closing": []any{map[string]any{"name": "挂机1", "triggers": []string{"不用了"}}}, + "empty-triggers": []any{map[string]any{"name": "挂机1", "triggers": []string{}, "closingRemark": "好的,再见。"}}, + "blank-closing": []any{map[string]any{"name": "挂机1", "triggers": []string{"不用了"}, "closingRemark": " "}}, + } { + t.Run(name, func(t *testing.T) { + root := testDataDir(t) + path := filepath.Join(root, "tasks", "task-full.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var task map[string]any + if err := json.Unmarshal(body, &task); err != nil { + t.Fatal(err) + } + task["agent"].(map[string]any)["conversation"].(map[string]any)["hangup_keywords"] = groups + body, err = json.Marshal(task) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + if _, err := loadDataset(root, testDispatcher); err == nil { + t.Fatal("removed or incomplete hangup structure was accepted") + } + }) + } +} diff --git a/deploys/test/saas-mock/publish_test.go b/deploys/test/saas-mock/publish_test.go index 1c799e4..2e04b61 100644 --- a/deploys/test/saas-mock/publish_test.go +++ b/deploys/test/saas-mock/publish_test.go @@ -209,6 +209,10 @@ func TestSaaSMockAwaitsExactResultBeforeStoppingCapture(t *testing.T) { producer <- fmt.Errorf("wrong command received") return } + if err := verifyExecutePayload(command.Body, "task-full", "15003164745"); err != nil { + producer <- err + return + } if err := command.Ack(false); err != nil { producer <- err return @@ -296,6 +300,9 @@ func TestSaaSMockPublishesOneCommandOnProvisionedRabbitMQ(t *testing.T) { if err := contract.ValidateCurrent("mq", message.Body); err != nil || !strings.Contains(string(message.Body), eventID) || message.DeliveryMode != amqp.Persistent { t.Fatalf("broker delivered wrong command identity or persistence: err=%v", err) } + if err := verifyExecutePayload(message.Body, "task-full", "15003164745"); err != nil { + t.Fatal(err) + } if err := message.Ack(false); err != nil { t.Fatal(err) } @@ -320,6 +327,9 @@ func TestSaaSMockBuildsOneApprovedCommandForTheBoundTask(t *testing.T) { if err := contract.ValidateCurrent("mq", body); err != nil { t.Fatalf("call command violates the formal contract: %v", err) } + if err := verifyExecutePayload(body, "task-full", "15003164745"); err != nil { + t.Fatal(err) + } var event struct { EventID string `json:"event_id"` Type string `json:"event_type"` diff --git a/docs/evidence/hangup-keyword-closing.md b/docs/evidence/hangup-keyword-closing.md index 4bce173..92e365d 100644 --- a/docs/evidence/hangup-keyword-closing.md +++ b/docs/evidence/hangup-keyword-closing.md @@ -23,6 +23,16 @@ P08 表保留当时的历史结果;其中旧 `TestKeywordHangupOnlyFinalUserLiteralAndOnlyOnce` 和 `TestKeywordHangupConcurrentDuplicateFinalTriggersOnce` 的现行替代分别为上表的顺序组匹配与并发预留测试。本变更涉及 A01/A04/A05/A08/A11 和 K09;其他业务边界不变,由完整回归及隔离 MQ/HTTPS/双向 TLS 测试核验。 +## SaaS Mock HTTP 与 MQ 补充验证 + +使用者确认新结束语配置继续通过 HTTP 任务快照读取,不修改 `call.execute` 的两字段 payload;授权提交并 push 全部变更,不部署、不投递实际呼叫。SaaS Mock 的测试快照直接引用现行任务正例,没有另一份手写字段定义。 + +- `TestSaaSMockServesApprovedHangupGroupsOverHTTP`:正式 HTTP 读取并绑定后,组名、触发词与结束语完整保留。 +- `TestSaaSMockRejectsLegacyAndIncompleteHangupSnapshots`:旧数组、缺结束语、空触发词、空白结束语拒绝加载。 +- `TestSaaSMockMQRejectsInlineHangupConfiguration`:MQ payload 内增加 `agent`、`hangup_keywords` 或 `closingRemark` 直接违反现行合同。 +- `TestBuildExecuteOnlyUsesApprovedTaskAndTarget` 及隔离 RabbitMQ 的单次投递、派发回执测试:投递前和实际队列收到的消息均核对 payload 只有 `task_id` 和原始 `callee`;配置不随消息重复携带。 +- Mock 使用说明同步更新,未修改测试机私有快照、未连接真实呼叫 MQ。 + ## 本轮结果 - TDD:新配置和播放顺序测试先失败,完成实现后通过。