Align task schedule optional fields with shared schema
This commit is contained in:
@@ -210,6 +210,9 @@ func TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T) {
|
||||
taskSchedule := taskConfig["schedule"].(map[string]any)
|
||||
taskSchedule["starts_at"] = "2000-01-01T00:00:00+08:00"
|
||||
taskSchedule["weekly_windows"] = weekly
|
||||
// Exercise the current contract through HTTP, persistence, MQ and mTLS.
|
||||
delete(taskSchedule, "ends_at")
|
||||
delete(taskSchedule, "excluded_dates")
|
||||
if examples["config-read-task-asr.json"], err = json.Marshal(taskConfig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+1
-1
Submodule contracts/schema updated: b502ad2d47...adc49ace34
@@ -0,0 +1,59 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskScheduleOptionalFieldsSchema(t *testing.T) {
|
||||
schema, err := CompileCurrent("http-task-detail.schema.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []string{"asr", "full"} {
|
||||
fixture, err := Files.ReadFile("schema/examples/config-read-task-" + mode + ".json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
omit []string
|
||||
set map[string]any
|
||||
valid bool
|
||||
}{
|
||||
{name: "explicit null end and empty exclusions", valid: true},
|
||||
{name: "omitted end", omit: []string{"ends_at"}, valid: true},
|
||||
{name: "omitted exclusions", omit: []string{"excluded_dates"}, valid: true},
|
||||
{name: "both omitted", omit: []string{"ends_at", "excluded_dates"}, valid: true},
|
||||
{name: "explicit end and exclusions", set: map[string]any{"ends_at": "2026-12-31T20:00:00+08:00", "excluded_dates": []any{"2026-10-01"}}, valid: true},
|
||||
{name: "missing start", omit: []string{"starts_at"}},
|
||||
{name: "missing time zone", omit: []string{"time_zone"}},
|
||||
{name: "missing weekly windows", omit: []string{"weekly_windows"}},
|
||||
{name: "null exclusions", set: map[string]any{"excluded_dates": nil}},
|
||||
{name: "wrong exclusions type", set: map[string]any{"excluded_dates": "2026-10-01"}},
|
||||
{name: "invalid excluded date", set: map[string]any{"excluded_dates": []any{"2026-02-30"}}},
|
||||
{name: "duplicate exclusions", set: map[string]any{"excluded_dates": []any{"2026-10-01", "2026-10-01"}}},
|
||||
{name: "invalid end", set: map[string]any{"ends_at": "not-a-time"}},
|
||||
{name: "empty end", set: map[string]any{"ends_at": ""}},
|
||||
{name: "numeric end", set: map[string]any{"ends_at": 123}},
|
||||
{name: "unknown schedule property", set: map[string]any{"unknown": true}},
|
||||
} {
|
||||
t.Run(mode+"/"+tc.name, func(t *testing.T) {
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(fixture, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schedule := doc["schedule"].(map[string]any)
|
||||
for _, field := range tc.omit {
|
||||
delete(schedule, field)
|
||||
}
|
||||
for field, value := range tc.set {
|
||||
schedule[field] = value
|
||||
}
|
||||
if err := schema.Validate(doc); (err == nil) != tc.valid {
|
||||
t.Fatalf("valid=%t, want %t: %v", err == nil, tc.valid, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,3 +83,9 @@ Mock、hash 或离线验证不替代 SaaS、供应商或生产签收。
|
||||
AI 连接的 HTTP 与 WebSocket 地址按调用用途分别使用,缺失时明确拒绝、不互相回退。字段定义仍只在共享仓库维护。
|
||||
|
||||
本轮本地验证与外部边界见 [`evidence/schema-task-trunk-concurrency-20261008.md`](evidence/schema-task-trunk-concurrency-20261008.md)。没有部署、真实拨号或真实 AI 请求。
|
||||
|
||||
## 2026-10-09 本地适配
|
||||
|
||||
共享版本 `adc49ace34a0cdfd21c4de5a20dc6dfc1ab9b9df` 的任务计划可选字段变更已用于本项目合同引用;现有调度行为与新合同一致,无需改写业务逻辑。合同正反例、配置读取、选线边界及省略字段的隔离端到端验证已补齐。
|
||||
|
||||
本轮差异、验证结果及 A01–A12/K01–K16 对照见 [`evidence/schema-optional-task-schedule-20261009.md`](evidence/schema-optional-task-schedule-20261009.md)。仅暂存了用户明确批准的合同指针,没有提交、推送、部署、真实拨号或真实服务请求。
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 任务计划可选字段:本地合同适配与验证(2026-10-09)
|
||||
|
||||
## 基线与唯一差异
|
||||
|
||||
- 起始主仓库 `main`:`6e028d4`,更新前工作区干净,已执行远程同步,未发生主仓库快进。
|
||||
- 验证时主仓库另有独立提交 `ef1c625`,仅调整 `AGENTS.md` 和独立 sip-call 部署证据,与本次修改文件无交叠;该提交及文件原样保留,本次未创建提交。
|
||||
- 共享合同:`b502ad2` → `adc49ace34a0cdfd21c4de5a20dc6dfc1ab9b9df`;`make contracts-update` 成功。
|
||||
- 唯一变化:`http-task-detail.schema.json` 的任务计划不再要求提供 `ends_at`、`excluded_dates`。属性类型、其他必填项、任务/线路时段、授权、并发及消息规则均未改变。
|
||||
- 定义仍仅由 [`contracts/schema/`](../../contracts/schema/README.md) 提供,本项目没有修改共享仓库文件或添加平行 Schema。
|
||||
- 用户追加批准:**仅暂存 `contracts/schema` 指针**,以满足现有合同验收要求;其他改动未暂存,不提交、不推送。此前未暂存指针时 `make check` 明确失败,未更改或绕过检查规则。
|
||||
|
||||
## 差异对应处理
|
||||
|
||||
| 变更或影响 | 处理结果与依据 |
|
||||
| --- | --- |
|
||||
| 两个字段分别省略或同时省略 | `contracts/task_schedule_schema_test.go` 分别核验 ASR-only 与完整 AI 任务;显式 null 结束时间和空排除日期继续有效。 |
|
||||
| HTTP 读取与冻结快照 | `TestReadTaskSnapshot` 覆盖两种任务的四种字段组合,核验原始响应原样保存,不补结束时间或排除日期。 |
|
||||
| 实际选线与通话期限 | `TestSelectTrunkOptionalTaskSchedule` 核验省略字段后的准入与期限;开始时间、每周窗口、显式结束时间及排除日期仍生效,SIP 加载和线路并发仍约束执行。 |
|
||||
| 非法输入与边界 | 缺失其他必填项、非法结束时间、错误日期、重复日期、错误类型和未知字段仍拒绝;结束早于开始、到达结束边界等由现有调度测试核验。 |
|
||||
| 隔离完整链路 | `TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent` 的模拟 SaaS 任务同时省略两个字段,经配置读取、持久化、RabbitMQ、双向 TLS Agent 和模拟 HTTPS 对象上传实际取得最终结果。 |
|
||||
| 业务实现 | `internal/callwindow/policy.go` 已将未提供结束时间视为无额外结束限制、未提供排除日期视为无排除日期;读取层原样保留计划,Dispatcher 已调用该逻辑。无需修改运行逻辑,不新增兼容、兜底、默认值或数据迁移。 |
|
||||
| 文档 | `docs/contracts.md` 增加本次适配入口;既有日期证据保留历史事实,不把旧合同路径或旧覆盖率当作本次依据。 |
|
||||
|
||||
## 测试先失败再通过
|
||||
|
||||
新增 `TestTaskScheduleOptionalFieldsSchema` 后,在独立临时测试目录中仅用旧提交的任务 Schema 执行测试:ASR-only 与完整 AI 的“省略结束时间”“省略排除日期”“同时省略”六项均因缺失必填属性失败。
|
||||
|
||||
同一临时测试目录换成已更新的任务 Schema 后,该测试全部通过。真实工作区的共享合同未回退、未被临时改写;临时目录仅用于负例验证,完成后删除,不构成运行时备用合同。
|
||||
|
||||
## 本地验证结果
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| `go test ./contracts ./internal/configread ./internal/callwindow ./internal/dispatcher -count=1` | PASS |
|
||||
| `make check` | PASS:格式、Proto 生成及 7 文件清单、合同来源/离线引用/历史来源、`go mod verify`、`go vet ./...`、`go test -race ./...`、构建及实际隔离 MQ 测试。 |
|
||||
| `make coverage` | PASS:排除生成文件后的业务语句覆盖率 **70.6%**,达到 ≥65%。 |
|
||||
| `make release-check-local` | PASS:当前合同固定提交与本地制品/打包检查;`source_dirty=true`、`production_approval=false`。 |
|
||||
|
||||
本次实际运行并通过的隔离 MQ 测试:
|
||||
|
||||
- `TestBrokerSharedResultQueueAndNoConfigure`
|
||||
- `TestRuntimeIsolatedControlBacklogExecuteAndSharedResult`
|
||||
- `TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent`
|
||||
- `TestSaaSMockPublishesOneCommandOnProvisionedRabbitMQ`
|
||||
- `TestSaaSMockAwaitsExactResultBeforeStoppingCapture`
|
||||
|
||||
本地发布检查报告制品 SHA-256:`025018369e0f2e3d50aecae3edd35e9dcf21e7f601e8200c1acdfefb50b1590b`。此检查制品和临时打包产物由检查脚本删除,未安装或部署。
|
||||
|
||||
## A01–A12 本次对照
|
||||
|
||||
| 项目 | 受影响范围与本次依据 |
|
||||
| --- | --- |
|
||||
| A01 合同一致性 | 直接影响;新 Schema 正反例及 `TestCurrentContractExamples` 通过,固定共享提交,无重复定义。 |
|
||||
| A02 代次名称 | 不适用新增变更:仅消费字段可选性变化,未引入新运行入口、协议代次或旧路径。 |
|
||||
| A03 固定通信名称 | 无业务变更;`TestCurrentContractTopology` 与实际隔离 MQ 回归通过。 |
|
||||
| A04 来源与生成一致性 | 直接影响合同提交;`make check` 的共享来源、引用及 Proto 检查通过。 |
|
||||
| A05 配置读取与失效 | 直接影响;`TestReadTaskSnapshot` 的八种组合通过;`TestReadTaskDoesNotReuseConfigAfterHTTPFailure` 随完整回归通过,无旧快照回退。 |
|
||||
| A06 队列归属与路由 | 无业务变更;实际 `TestBrokerSharedResultQueueAndNoConfigure` 通过,未赋予 Dispatcher 建队列权限。 |
|
||||
| A07 控制与发现竞态 | 无业务变更;`TestControlStopCannotResumeOrDispatchPending`、`TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot` 等随完整回归通过。 |
|
||||
| A08 执行与 AI | 直接影响任务计划;新选线测试与省略字段的隔离完整链路通过。AI 连接、参数及对话行为未改。 |
|
||||
| A09 结果与录音 | 无业务变更;省略字段的完整链路取得最终结果;`TestFinalResultNeedsConfirmedEndAndRemainsExactlyOne` 等随完整回归通过。 |
|
||||
| A10 数据保护与恢复 | 无业务变更;`TestRecordingRecoveryUnknownPUTNeverRetriesAfterRestart` 等随完整回归通过;未改业务表、恢复文件或未知占用。 |
|
||||
| A11 测试与构建 | 直接影响;新合同测试先失败再通过;完整本地验收、70.6% 覆盖率、发布检查通过。 |
|
||||
| A12 交付边界 | 仅本地改动与验证,未部署或调用真实业务服务,未签收生产。 |
|
||||
|
||||
## K01–K16 本次对照
|
||||
|
||||
| 项目 | 受影响范围与本次依据 |
|
||||
| --- | --- |
|
||||
| K01 队列拓扑与所有权 | 无业务变更;实际隔离 MQ 与 `TestCurrentContractTopology` 通过。 |
|
||||
| K02 归属与事件身份 | 无业务变更;`TestCurrentContractExamples`、原身份持久执行测试随完整回归通过。 |
|
||||
| K03 分页发现 | 无业务变更;`TestReadAllTasksContinuesAfterShortPageUntilEmptyPage` 随完整回归通过。 |
|
||||
| K04 停止不可恢复 | 无业务变更;`TestControlStopCannotResumeOrDispatchPending` 随完整回归通过。 |
|
||||
| K05 暂停控制 | 无业务变更;`TestControlPauseDefaultHangupAckAfterAgentDispatch` 随完整回归通过。 |
|
||||
| K06 派发与通话事实 | 无业务变更;`TestExecuteWaitsForRulesThenDispatchesOriginalIdentity` 随完整回归通过;隔离完整链路取得原事件最终结果。 |
|
||||
| K07 SIP 加载屏障 | 无业务变更;新选线测试确认未加载时不放行;SIP 通知持久屏障测试随完整回归通过。 |
|
||||
| K08 AI 授权绑定 | 无业务变更;`TestBindCurrentRejectsUnauthorizedProviderBeforeCall` 随完整回归通过,未发真实 AI 请求。 |
|
||||
| K09 关键词结束 | 无业务变更;`TestKeywordHangupOnlyFinalUserLiteralAndOrderedGroups` 随完整回归通过。 |
|
||||
| K10 上传恢复 | 无业务变更;`TestRecordingRecoveryExpiresAfterExactly48HoursWithoutResult` 随完整回归通过;未更改恢复窗口。 |
|
||||
| K11 SIP 事实 | 无业务变更;`TestFinalResultPayloadRejectsMissingOrInventedCallFacts` 随完整回归通过,未新增或伪造真实 SIP 证据。 |
|
||||
| K12 呼出规则 | 直接影响;`TestSelectTrunkOptionalTaskSchedule` 核验省略字段、开始/结束边界、每周时段、排除日期及并发/加载门槛;缺省不等于绕过其他规则。 |
|
||||
| K13 单号拒绝 | 无业务变更;`TestExecuteRejectsInvalidCalleeWithoutStoppingTask` 随完整回归通过。 |
|
||||
| K14 通话结果与未知执行 | 无业务变更;`TestFinalResultPayloadNoAnswerHasNoFabricatedTranscriptOrAsset` 随完整回归通过,未知占用未清除。 |
|
||||
| K15 录音生成失败 | 无业务变更;`TestRecordingDeliveryCaptureFailureDescribesFailureWithoutInventingUpload` 随完整回归通过。 |
|
||||
| K16 双文件保存失败 | 无业务变更;`TestRecordingDeliveryRecoveryDiskFailureNeverReportsUploaded` 随完整回归通过。 |
|
||||
|
||||
## 未验证范围
|
||||
|
||||
没有真实 SaaS/management 的配置签收、真实运营商 SIP 接通、Asterisk 实际加载、真实 ASR/LLM/TTS、OSS、主机重启、容量或生产验收。本次 RabbitMQ、HTTP/HTTPS、双向 TLS、上传与通话均为本机隔离测试,不授权或证明任何真实拨号。
|
||||
|
||||
未操作测试机,未读取私有配置,未清理或迁移既有数据,未修改其他项目。共享仓库本次仅同步读取,未替 SaaS 开发人员确认应用已同步或已完成联调。
|
||||
@@ -37,68 +37,93 @@ func approvedSIP(t *testing.T) SIP {
|
||||
|
||||
func TestReadTaskSnapshot(t *testing.T) {
|
||||
for _, mode := range []string{"asr", "full"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
responses := map[string][]byte{
|
||||
"/internal/v1/dispatcher/sip": example(t, "config-read-sip"),
|
||||
"/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"),
|
||||
"/internal/v1/dispatcher/task/task-" + mode: []byte(strings.Replace(string(example(t, "config-read-task-"+mode)), `"temperature":0.7`, `"temperature":0`, 1)),
|
||||
"/internal/v1/dispatcher/tenant/1001/quota": example(t, "config-read-quota"),
|
||||
}
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-ID") != "c046b893-8628-4589-ae50-619d049248a6" || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "test-secret" {
|
||||
t.Errorf("unexpected method/headers: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
omit []string
|
||||
}{
|
||||
{"explicit schedule fields", nil},
|
||||
{"omitted end", []string{"ends_at"}},
|
||||
{"omitted exclusions", []string{"excluded_dates"}},
|
||||
{"both omitted", []string{"ends_at", "excluded_dates"}},
|
||||
} {
|
||||
t.Run(mode+"/"+tc.name, func(t *testing.T) {
|
||||
var task map[string]any
|
||||
if err := json.Unmarshal(example(t, "config-read-task-"+mode), &task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, ok := responses[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
for _, field := range tc.omit {
|
||||
delete(task["schedule"].(map[string]any), field)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providers, err := client.ReadProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := client.ReadTask(context.Background(), "task-"+mode, 1001, approvedSIP(t), providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 3 || snapshot.SIP.Revision != 8 || snapshot.Task.TenantID != 1001 || snapshot.Quota.TenantID != 1001 {
|
||||
t.Fatalf("unexpected request count or identity: calls=%d snapshot=%+v", calls, snapshot)
|
||||
}
|
||||
if got := snapshot.Providers["volcengine"].Credential; got != "sk-******" {
|
||||
t.Fatalf("credential not passed unchanged: %q", got)
|
||||
}
|
||||
if mode == "asr" && (snapshot.Task.Agent.Mode != "asr_only" || strings.Contains(string(snapshot.Task.Agent.Raw), `"llm"`)) {
|
||||
t.Fatalf("ASR-only Agent picked up an LLM: %s", snapshot.Task.Agent.Raw)
|
||||
}
|
||||
if mode == "full" {
|
||||
var agent struct {
|
||||
LLM struct {
|
||||
Params struct {
|
||||
Temperature *float64 `json:"temperature"`
|
||||
} `json:"params"`
|
||||
} `json:"llm"`
|
||||
Conversation struct {
|
||||
AllowInterrupt *bool `json:"allow_interrupt"`
|
||||
} `json:"conversation"`
|
||||
taskBody, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Params.Temperature == nil || *agent.LLM.Params.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt {
|
||||
t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw)
|
||||
taskBody = []byte(strings.Replace(string(taskBody), `"temperature":0.7`, `"temperature":0`, 1))
|
||||
responses := map[string][]byte{
|
||||
"/internal/v1/dispatcher/sip": example(t, "config-read-sip"),
|
||||
"/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"),
|
||||
"/internal/v1/dispatcher/task/task-" + mode: taskBody,
|
||||
"/internal/v1/dispatcher/tenant/1001/quota": example(t, "config-read-quota"),
|
||||
}
|
||||
}
|
||||
})
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-ID") != "c046b893-8628-4589-ae50-619d049248a6" || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "test-secret" {
|
||||
t.Errorf("unexpected method/headers: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body, ok := responses[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providers, err := client.ReadProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := client.ReadTask(context.Background(), "task-"+mode, 1001, approvedSIP(t), providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(snapshot.Task.Raw) != string(taskBody) {
|
||||
t.Fatal("task snapshot changed the HTTP response or inserted omitted schedule fields")
|
||||
}
|
||||
if calls != 3 || snapshot.SIP.Revision != 8 || snapshot.Task.TenantID != 1001 || snapshot.Quota.TenantID != 1001 {
|
||||
t.Fatalf("unexpected request count or identity: calls=%d snapshot=%+v", calls, snapshot)
|
||||
}
|
||||
if got := snapshot.Providers["volcengine"].Credential; got != "sk-******" {
|
||||
t.Fatalf("credential not passed unchanged: %q", got)
|
||||
}
|
||||
if mode == "asr" && (snapshot.Task.Agent.Mode != "asr_only" || strings.Contains(string(snapshot.Task.Agent.Raw), `"llm"`)) {
|
||||
t.Fatalf("ASR-only Agent picked up an LLM: %s", snapshot.Task.Agent.Raw)
|
||||
}
|
||||
if mode == "full" {
|
||||
var agent struct {
|
||||
LLM struct {
|
||||
Params struct {
|
||||
Temperature *float64 `json:"temperature"`
|
||||
} `json:"params"`
|
||||
} `json:"llm"`
|
||||
Conversation struct {
|
||||
AllowInterrupt *bool `json:"allow_interrupt"`
|
||||
} `json:"conversation"`
|
||||
}
|
||||
if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Params.Temperature == nil || *agent.LLM.Params.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt {
|
||||
t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,67 @@ func TestSelectTrunkSaaSEventCalleeScheduleAndCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTrunkOptionalTaskSchedule(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
set map[string]any
|
||||
at time.Time
|
||||
deadline time.Time
|
||||
wantErr error
|
||||
}{
|
||||
{name: "both omitted", at: monday(9, 30), deadline: monday(9, 32)},
|
||||
{name: "null end", set: map[string]any{"ends_at": nil}, at: monday(9, 30), deadline: monday(9, 32)},
|
||||
{name: "empty exclusions", set: map[string]any{"excluded_dates": []string{}}, at: monday(9, 30), deadline: monday(9, 32)},
|
||||
{name: "weekly end still bounds call", at: monday(19, 59), deadline: monday(20, 0)},
|
||||
{name: "weekly end remains exclusive", at: monday(20, 0), wantErr: ErrRuleWait},
|
||||
{name: "before start", set: map[string]any{"starts_at": "2026-09-21T10:00:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleWait},
|
||||
{name: "at start", set: map[string]any{"starts_at": "2026-09-21T09:30:00+08:00"}, at: monday(9, 30), deadline: monday(9, 32)},
|
||||
{name: "explicit end bounds call", set: map[string]any{"ends_at": "2026-09-21T09:31:00+08:00"}, at: monday(9, 30), deadline: monday(9, 31)},
|
||||
{name: "at explicit end", set: map[string]any{"ends_at": "2026-09-21T09:30:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleWait},
|
||||
{name: "explicit excluded date", set: map[string]any{"excluded_dates": []string{"2026-09-21"}}, at: monday(9, 30), wantErr: ErrRuleWait},
|
||||
{name: "other date not excluded", set: map[string]any{"excluded_dates": []string{"2026-09-22"}}, at: monday(9, 30), deadline: monday(9, 32)},
|
||||
{name: "invalid end", set: map[string]any{"ends_at": "not-a-time"}, at: monday(9, 30), wantErr: ErrRuleInvalid},
|
||||
{name: "end before start", set: map[string]any{"ends_at": "2026-09-20T20:00:00+08:00"}, at: monday(9, 30), wantErr: ErrRuleInvalid},
|
||||
{name: "invalid excluded date", set: map[string]any{"excluded_dates": []string{"2026-02-30"}}, at: monday(9, 30), wantErr: ErrRuleInvalid},
|
||||
{name: "duplicate excluded dates", set: map[string]any{"excluded_dates": []string{"2026-09-22", "2026-09-22"}}, at: monday(9, 30), wantErr: ErrRuleInvalid},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
snapshot := policySnapshot(t)
|
||||
var schedule map[string]any
|
||||
if err := json.Unmarshal(snapshot.Task.Schedule, &schedule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(schedule, "ends_at")
|
||||
delete(schedule, "excluded_dates")
|
||||
for field, value := range tc.set {
|
||||
schedule[field] = value
|
||||
}
|
||||
var err error
|
||||
snapshot.Task.Schedule, err = json.Marshal(schedule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := map[string]int64{"trunk-mock": 8}
|
||||
selected, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, nil, loaded)
|
||||
if tc.wantErr != nil {
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("schedule must be rejected with %v, got %v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || !selected.Deadline.Equal(tc.deadline) {
|
||||
t.Fatalf("deadline=%v, want %v, err=%v", selected.Deadline, tc.deadline, err)
|
||||
}
|
||||
if _, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, nil, nil); !errors.Is(err, ErrRuleWait) {
|
||||
t.Fatalf("omitted schedule fields bypassed SIP loading: %v", err)
|
||||
}
|
||||
if _, err := SelectTrunk(snapshot, "15003164745", tc.at, nil, map[string]int64{"trunk-mock": 2}, loaded); !errors.Is(err, ErrRuleWait) {
|
||||
t.Fatalf("omitted schedule fields bypassed task-line concurrency: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectTrunkUsesEachLinesOwnCaller(t *testing.T) {
|
||||
snapshot := policySnapshot(t)
|
||||
var trunks []trunkConfig
|
||||
|
||||
Reference in New Issue
Block a user