package dispatcher import ( "encoding/json" "errors" "strings" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/configread" ) func policySnapshot(t *testing.T) configread.Snapshot { t.Helper() var snapshot configread.Snapshot if err := json.Unmarshal(configExample(t, "config-read-task-asr"), &snapshot.Task); err != nil { t.Fatal(err) } sip := string(configExample(t, "config-read-sip")) for _, replacement := range [][2]string{{`"transport":null`, `"transport":"udp"`}, {`"auth_mode":null`, `"auth_mode":"ip"`}, {`"registration_required":null`, `"registration_required":false`}, {`"max_concurrent_calls":null`, `"max_concurrent_calls":2`}} { sip = strings.Replace(sip, replacement[0], replacement[1], 1) } if err := json.Unmarshal([]byte(sip), &snapshot.SIP); err != nil { t.Fatal(err) } if err := json.Unmarshal(configExample(t, "config-read-quota"), &snapshot.Quota); err != nil { t.Fatal(err) } var providerList struct { Providers []configread.Provider `json:"providers"` } if err := json.Unmarshal(configExample(t, "config-read-providers"), &providerList); err != nil { t.Fatal(err) } snapshot.Providers = make(map[string]configread.Provider, len(providerList.Providers)) for _, provider := range providerList.Providers { snapshot.Providers[provider.Code] = provider } return snapshot } func monday(hour, minute int) time.Time { return time.Date(2026, 9, 21, hour, minute, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) } func TestSelectTrunkSaaSEventCalleeScheduleAndCaller(t *testing.T) { snapshot := policySnapshot(t) at := monday(9, 30) selected, err := SelectTrunk(snapshot, "15003164745", at, map[string]int64{}, nil, map[string]int64{"trunk-mock": 8}) if err != nil { t.Fatal(err) } if selected.TrunkID != "trunk-mock" || selected.CallerID != "BD00000000" || selected.Callee != "15003164745" || selected.DialedCallee != "15003164745" || selected.Deadline.After(at.Add(120*time.Second)) || !selected.Deadline.After(at) || selected.MaxCallDurationMS != snapshot.Task.MaxCallDurationMS { t.Fatalf("selected route/prefix/caller/deadline invalid: %+v", selected) } for _, number := range []string{"15803300952", "13900000000", "15003164746"} { choice, err := SelectTrunk(snapshot, number, at, nil, nil, map[string]int64{"trunk-mock": 8}) if err != nil || choice.Callee != number || choice.DialedCallee != number { t.Fatalf("SaaS event number changed or rejected: number=%q choice=%+v err=%v", number, choice, err) } } for _, number := range []string{"", "abc", "15803300952x", "15803300952\n", strings.Repeat("7", 33)} { if _, err := SelectTrunk(snapshot, number, at, nil, nil, map[string]int64{"trunk-mock": 8}); !errors.Is(err, ErrCalleeRejected) { t.Fatalf("invalid raw dial route %q not rejected: %v", number, err) } } for _, at := range []time.Time{monday(8, 59), monday(20, 0)} { if _, err := SelectTrunk(snapshot, "15003164745", at, nil, nil, map[string]int64{"trunk-mock": 8}); !errors.Is(err, ErrRuleWait) { t.Fatalf("outside half-open schedule did not wait: %v", err) } } } 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 if err := json.Unmarshal(snapshot.SIP.Trunks, &trunks); err != nil { t.Fatal(err) } second := trunks[0] second.TrunkID, second.CallerID = "trunk-second", "mbkq" trunks = append(trunks, second) var err error snapshot.SIP.Trunks, err = json.Marshal(trunks) if err != nil { t.Fatal(err) } snapshot.Task.AllowedTrunks = []configread.AllowedTrunk{{TrunkID: second.TrunkID, Concurrency: 2}} chosen, err := SelectTrunk(snapshot, "15003164745", monday(9, 30), nil, nil, map[string]int64{second.TrunkID: 8}) if err != nil || chosen.TrunkID != second.TrunkID || chosen.CallerID != second.CallerID { t.Fatalf("second line did not keep its own approved caller: %+v, %v", chosen, err) } trunks[1].CallerID = "" snapshot.SIP.Trunks, err = json.Marshal(trunks) if err != nil { t.Fatal(err) } if _, err := SelectTrunk(snapshot, "15003164745", monday(9, 30), nil, nil, map[string]int64{second.TrunkID: 8}); !errors.Is(err, ErrRuleInvalid) { t.Fatalf("second line with missing caller did not fail closed: %v", err) } } func TestSelectTrunkFailsClosedForUnknownLineAndLimits(t *testing.T) { snapshot := policySnapshot(t) at := monday(9, 30) if _, err := SelectTrunk(snapshot, "15003164745", at, nil, nil, nil); !errors.Is(err, ErrRuleWait) { t.Fatalf("unloaded line did not hold command: %v", err) } if _, err := SelectTrunk(snapshot, "15003164745", at, map[string]int64{"trunk-mock": 2}, nil, map[string]int64{"trunk-mock": 8}); !errors.Is(err, ErrRuleWait) { t.Fatalf("full trunk did not hold command: %v", err) } sip := string(configExample(t, "config-read-sip")) if err := json.Unmarshal([]byte(sip), &snapshot.SIP); err != nil { t.Fatal(err) } if _, err := SelectTrunk(snapshot, "15003164745", at, nil, nil, map[string]int64{"trunk-mock": 8}); !errors.Is(err, ErrRuleInvalid) { t.Fatalf("unknown transport/auth/quota was not fail-closed: %v", err) } } func TestSelectTrunkCapsCallByAIAndPreservesPrefix(t *testing.T) { snapshot := policySnapshot(t) snapshot.Task.Agent.Mode = "full_ai" snapshot.Task.Agent.Raw = []byte(`{"mode":"full_ai","conversation":{"max_duration_ms":60000}}`) snapshot.SIP.Trunks = []byte(strings.Replace(string(snapshot.SIP.Trunks), `"dial_prefix":""`, `"dial_prefix":"7089"`, 1)) at := monday(9, 30) selected, err := SelectTrunk(snapshot, "15830461047", at, nil, nil, map[string]int64{"trunk-mock": 8}) if err != nil { t.Fatal(err) } if selected.DialedCallee != "708915830461047" || selected.Callee != "15830461047" || !selected.Deadline.Equal(at.Add(time.Minute)) || selected.MaxCallDurationMS != 60000 { t.Fatalf("wrong prefix/AI duration bound: %+v", selected) } }