package store import ( "encoding/json" "errors" "fmt" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "git.ipao.vip/rogee/go-sip/internal/configread" ) func trunkLimitSnapshot(t *testing.T, taskID string, revision, limit int64) configread.Snapshot { t.Helper() snapshot := currentStoreSnapshot(t) var task map[string]json.RawMessage if err := json.Unmarshal(snapshot.Task.Raw, &task); err != nil { t.Fatal(err) } set := func(key string, value any) { raw, err := json.Marshal(value) if err != nil { t.Fatal(err) } task[key] = raw } set("task_id", taskID) set("task_revision", revision) set("max_concurrent_calls", 100) set("allowed_trunk_ids", []configread.AllowedTrunk{{TrunkID: "trunk-mock", Concurrency: limit}}) raw, err := json.Marshal(task) if err != nil { t.Fatal(err) } if err := json.Unmarshal(raw, &snapshot.Task); err != nil { t.Fatal(err) } snapshot.SIP.Trunks = []byte(strings.Replace(string(snapshot.SIP.Trunks), `"max_concurrent_calls":null`, `"max_concurrent_calls":100`, 1)) snapshot.Quota.MaxConcurrentCalls = 100 return snapshot } func taskTrunkStore(t *testing.T, snapshots ...configread.Snapshot) (*Store, string) { t.Helper() path := filepath.Join(t.TempDir(), "state.db") s, err := Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.Close() }) tasks := make([]configread.DiscoveredTask, 0, len(snapshots)) for _, snapshot := range snapshots { tasks = append(tasks, configread.DiscoveredTask{TaskID: snapshot.Task.TaskID, TenantID: snapshot.Task.TenantID, TaskRevision: snapshot.Task.TaskRevision, Status: "running"}) } if err := s.ApplyDiscoverySnapshot(currentDispatcherID, tasks); err != nil { t.Fatal(err) } for _, snapshot := range snapshots { if err := s.SaveSnapshot(snapshot); err != nil { t.Fatal(err) } } if err := s.MarkReadyForSIP(currentDispatcherID, 8); err != nil { t.Fatal(err) } return s, path } func currentTaskReservation(t *testing.T, s *Store, taskID string) CallReservation { t.Helper() snapshot, err := s.ReadSnapshot(currentDispatcherID, 1001, taskID) if err != nil { t.Fatal(err) } selection := currentReservation() selection.TaskRevision = snapshot.Task.TaskRevision selection.TaskConfigSHA256, err = configread.TaskConfigurationSHA256(snapshot.Task) if err != nil { t.Fatal(err) } return selection } func recordTrunkCall(t *testing.T, s *Store, taskID, eventID string) ExecuteCommand { t.Helper() cmd := currentCall(eventID) cmd.TaskID = taskID if _, created, err := s.RecordExecute(cmd); err != nil || !created { t.Fatalf("record call: created=%t err=%v", created, err) } return cmd } func TestTaskTrunkReservationRejectsDisabledAndUnlistedLines(t *testing.T) { for _, tc := range []struct { name, selected string limit int64 }{ {"zero", "trunk-mock", 0}, {"unlisted", "another-trunk", 5}, } { t.Run(tc.name, func(t *testing.T) { s, _ := taskTrunkStore(t, trunkLimitSnapshot(t, "task-asr", 1, tc.limit)) cmd := recordTrunkCall(t, s, "task-asr", "call-disabled") selection := currentTaskReservation(t, s, "task-asr") selection.TrunkID = tc.selected at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, selection, at); !errors.Is(err, ErrNotReady) { t.Fatalf("disabled/unlisted task line reserved: %v", err) } }) } } func TestTaskTrunkLimitIsScopedToTaskAndUnknownSurvivesRestart(t *testing.T) { s, path := taskTrunkStore(t, trunkLimitSnapshot(t, "task-asr", 1, 1), trunkLimitSnapshot(t, "task-other", 1, 1)) at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) first := recordTrunkCall(t, s, "task-asr", "call-first") if err := s.ReserveExecute(first.DispatcherID, first.EventID, currentTaskReservation(t, s, "task-asr"), at); err != nil { t.Fatal(err) } other := recordTrunkCall(t, s, "task-other", "call-other") if err := s.ReserveExecute(other.DispatcherID, other.EventID, currentTaskReservation(t, s, "task-other"), at); err != nil { t.Fatalf("another task lost its independent per-line capacity: %v", err) } if err := s.MarkExecuteUnknown(first.DispatcherID, first.EventID); err != nil { t.Fatal(err) } if err := s.Close(); err != nil { t.Fatal(err) } s, err := Open(path) if err != nil { t.Fatal(err) } defer s.Close() if err := s.MarkReadyForSIP(currentDispatcherID, 8); err != nil { t.Fatal(err) } occupancy, err := s.TaskTrunkOccupancy(currentDispatcherID, 1001, "task-asr") if err != nil || occupancy["trunk-mock"] != 1 { t.Fatalf("task trunk occupancy after restart=%v err=%v", occupancy, err) } second := recordTrunkCall(t, s, "task-asr", "call-second") if err := s.ReserveExecute(second.DispatcherID, second.EventID, currentTaskReservation(t, s, "task-asr"), at); !errors.Is(err, ErrCapacity) { t.Fatalf("unknown call released task-line capacity on restart: %v", err) } if err := s.FinishExecute(first.DispatcherID, first.EventID); err != nil { t.Fatal(err) } if err := s.ReserveExecute(second.DispatcherID, second.EventID, currentTaskReservation(t, s, "task-asr"), at); err != nil { t.Fatalf("confirmed end did not release task-line capacity: %v", err) } } func TestTaskTrunkConcurrentReservationsCannotExceedLimit(t *testing.T) { s, _ := taskTrunkStore(t, trunkLimitSnapshot(t, "task-asr", 1, 5)) at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) commands := make([]ExecuteCommand, 20) for i := range commands { commands[i] = recordTrunkCall(t, s, "task-asr", fmt.Sprintf("call-%d", i)) } selection := currentTaskReservation(t, s, "task-asr") var accepted atomic.Int64 var wg sync.WaitGroup for _, cmd := range commands { wg.Go(func() { err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, selection, at) if err == nil { accepted.Add(1) } else if !errors.Is(err, ErrCapacity) { t.Errorf("reserve: %v", err) } }) } wg.Wait() if accepted.Load() != 5 { t.Fatalf("accepted=%d, want task-line limit 5", accepted.Load()) } } func TestTaskTrunkEditSameRevisionRechecksSelectionConfiguration(t *testing.T) { s, _ := taskTrunkStore(t, trunkLimitSnapshot(t, "task-asr", 1, 2)) at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) first := recordTrunkCall(t, s, "task-asr", "call-before-edit") oldSelection := currentTaskReservation(t, s, "task-asr") if err := s.ReserveExecute(first.DispatcherID, first.EventID, oldSelection, at); err != nil { t.Fatal(err) } if state, err := s.CompleteEdit(trunkLimitSnapshot(t, "task-asr", 1, 1), "edit-line-limit"); err != nil || state != "applied" { t.Fatalf("edit=%q err=%v", state, err) } second := recordTrunkCall(t, s, "task-asr", "call-after-edit") if err := s.ReserveExecute(second.DispatcherID, second.EventID, oldSelection, at); !errors.Is(err, ErrNotReady) { t.Fatalf("old selection revision was accepted after edit: %v", err) } selection := currentTaskReservation(t, s, "task-asr") if err := s.ReserveExecute(second.DispatcherID, second.EventID, selection, at); !errors.Is(err, ErrCapacity) { t.Fatalf("new lower task-line limit was ignored: %v", err) } if err := s.FinishExecute(first.DispatcherID, first.EventID); err != nil { t.Fatal(err) } if err := s.ReserveExecute(second.DispatcherID, second.EventID, selection, at); err != nil { t.Fatalf("capacity did not reopen after confirmed end: %v", err) } }