test(mock): verify HTTP hangup snapshots and MQ payload boundaries

This commit is contained in:
2026-10-07 20:59:23 +08:00
parent 2435fef5c0
commit 2fee513d34
4 changed files with 153 additions and 0 deletions
+7
View File
@@ -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` 必须相同,任务须属于同一租户且文件名为 `<task_id>.json`。启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/local/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。
## 结束语配置与 MQ 数据边界
- `tasks/<task_id>.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 <UUID>` 离线校验快照;不连接 RabbitMQ/HTTPS。
@@ -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")
}
})
}
}
+10
View File
@@ -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"`
+10
View File
@@ -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:新配置和播放顺序测试先失败,完成实现后通过。