253 lines
10 KiB
Go
253 lines
10 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/contract"
|
|
)
|
|
|
|
func controlPayload(t *testing.T, s *Store, eventID string) map[string]any {
|
|
t.Helper()
|
|
var body []byte
|
|
if err := s.db.QueryRow(`SELECT body FROM dispatcher_outbox WHERE dispatcher_id=? AND event_id=?`, currentDispatcherID, eventID).Scan(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
t.Fatalf("invalid receipt: %s: %v", body, err)
|
|
}
|
|
var event struct {
|
|
Payload map[string]any `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(body, &event); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return event.Payload
|
|
}
|
|
|
|
func TestControlReceiptAllActionsReportDurableState(t *testing.T) {
|
|
for _, action := range []string{"start", "pause", "resume", "stop", "edit"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
snapshot := currentStoreSnapshot(t)
|
|
snapshot.SIP.Trunks = []byte(strings.Replace(string(snapshot.SIP.Trunks), `"max_concurrent_calls":null`, `"max_concurrent_calls":2`, 1))
|
|
state := "running"
|
|
var err error
|
|
switch action {
|
|
case "start":
|
|
err = s.CompleteStart(snapshot, "receipt")
|
|
case "edit":
|
|
if err = s.PrepareControl(currentDispatcherID, 1001, "task-asr", "pause"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = s.CompleteControl(currentDispatcherID, 1001, "task-asr", "pause", "prior"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = s.CompleteEdit(snapshot, "receipt")
|
|
state = "paused"
|
|
default:
|
|
if action == "resume" {
|
|
if err = s.PrepareControl(currentDispatcherID, 1001, "task-asr", "pause"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = s.CompleteControl(currentDispatcherID, 1001, "task-asr", "pause", "prior"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err = s.PrepareControl(currentDispatcherID, 1001, "task-asr", action); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = s.CompleteControl(currentDispatcherID, 1001, "task-asr", action, "receipt")
|
|
if action == "pause" {
|
|
state = "paused"
|
|
}
|
|
if action == "stop" {
|
|
state = "stopped"
|
|
}
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload := controlPayload(t, s, "receipt")
|
|
want := map[string]any{"task_id": "task-asr", "action": action, "status": "applied", "reason_code": "applied", "task_state": state}
|
|
if len(payload) != len(want) {
|
|
t.Fatalf("success must contain exactly five fields: %+v", payload)
|
|
}
|
|
for k, v := range want {
|
|
if payload[k] != v {
|
|
t.Errorf("%s: got %v, want %v", k, payload[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRejectedControlExtractsValidFieldsIndependently(t *testing.T) {
|
|
for _, tc := range []struct{ name, payload, task, action, state string }{
|
|
{"missing timestamp", `{"task_id":"task-asr","action":"pause","reason":"private"}`, "task-asr", "pause", "running"},
|
|
{"invalid sibling", `{"task_id":"task-asr","action":"pause","reason":99}`, "task-asr", "pause", "running"},
|
|
{"overflowing sibling number", `{"task_id":"task-asr","action":"pause","reason":1e1000}`, "task-asr", "pause", "running"},
|
|
{"non-object payload", `["task-asr","pause"]`, "", "", ""},
|
|
{"null task", `{"task_id":null,"action":"pause"}`, "", "pause", ""},
|
|
{"invalid action type", `{"task_id":"task-asr","action":99}`, "task-asr", "", "running"},
|
|
{"unknown action", `{"task_id":"task-asr","action":"invalid"}`, "task-asr", "", "running"},
|
|
{"invalid task type", `{"task_id":99,"action":"pause"}`, "", "pause", ""},
|
|
{"invalid task syntax", `{"task_id":"bad task","action":"pause"}`, "", "pause", ""},
|
|
{"missing task", `{"action":"pause"}`, "", "pause", ""},
|
|
{"unknown task", `{"task_id":"absent","action":"pause"}`, "absent", "pause", ""},
|
|
{"other tenant task", `{"task_id":"foreign-task","action":"pause"}`, "foreign-task", "pause", ""},
|
|
{"fake requested state", `{"task_id":"task-asr","action":"pause","task_state":"stopped"}`, "task-asr", "pause", "running"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
if _, err := s.db.Exec(`INSERT INTO dispatcher_tasks(dispatcher_id,tenant_id,task_id,task_revision,status,present) VALUES(?,2002,'foreign-task',1,'stopped',1)`, currentDispatcherID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := []byte(`{"event_id":"rejected-fields","event_type":"task.control","dispatcher_id":"` + currentDispatcherID + `","tenant_id":1001,"payload":` + tc.payload + `}`)
|
|
cause := contract.ValidateCurrent("mq", raw)
|
|
if cause == nil {
|
|
t.Fatal("fixture must be rejected")
|
|
}
|
|
if err := s.RejectData(currentDispatcherID, raw, cause); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload := controlPayload(t, s, "rejected-fields")
|
|
for k, want := range map[string]string{"task_id": tc.task, "action": tc.action, "task_state": tc.state} {
|
|
got, present := payload[k]
|
|
if want == "" {
|
|
if present {
|
|
t.Errorf("unverified %s must be omitted: %v", k, got)
|
|
}
|
|
} else if got != want {
|
|
t.Errorf("valid %s lost: %v, want %s", k, got, want)
|
|
}
|
|
}
|
|
if payload["reason_code"] != "invalid_data" || payload["status"] != "rejected" || payload["reason_message"] == "" {
|
|
t.Fatalf("missing rejection cause: %+v", payload)
|
|
}
|
|
var state string
|
|
if err := s.db.QueryRow(`SELECT control_state FROM dispatcher_tasks WHERE dispatcher_id=? AND tenant_id=1001 AND task_id='task-asr'`, currentDispatcherID).Scan(&state); err != nil || state != "" {
|
|
t.Fatalf("invalid request executed: %q %v", state, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBusinessRejectedControlReportsTaskIdentityAndActualState(t *testing.T) {
|
|
for _, tc := range []struct{ name, task, action, state string }{
|
|
{"stopped task", "task-asr", "resume", "stopped"},
|
|
{"unknown task", "absent", "start", ""},
|
|
{"pending pause", "task-asr", "resume", "pausing"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
if tc.state != "" {
|
|
if _, err := s.db.Exec(`UPDATE dispatcher_tasks SET control_state=? WHERE dispatcher_id=? AND tenant_id=1001 AND task_id='task-asr'`, tc.state, currentDispatcherID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := s.RejectControl(currentDispatcherID, 1001, "business-rejection", tc.task, tc.action, "task cannot accept this control"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload := controlPayload(t, s, "business-rejection")
|
|
for k, want := range map[string]string{"task_id": tc.task, "action": tc.action, "status": "rejected", "reason_code": "control_rejected", "reason_message": "task cannot accept this control"} {
|
|
if payload[k] != want {
|
|
t.Errorf("%s: got %v, want %v", k, payload[k], want)
|
|
}
|
|
}
|
|
if tc.state == "" {
|
|
if _, present := payload["task_state"]; present {
|
|
t.Fatal("invented unknown task state")
|
|
}
|
|
} else if payload["task_state"] != tc.state {
|
|
t.Fatalf("actual state missing: %+v", payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRejectedControlReplayPreservesOriginalTaskState(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
raw := []byte(`{"event_id":"rejected-repeat","event_type":"task.control","dispatcher_id":"` + currentDispatcherID + `","tenant_id":1001,"payload":{"task_id":"task-asr","action":"pause"}}`)
|
|
cause := contract.ValidateCurrent("mq", raw)
|
|
if err := s.RejectData(currentDispatcherID, raw, cause); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var original []byte
|
|
if err := s.db.QueryRow(`SELECT body FROM dispatcher_outbox WHERE event_id='rejected-repeat'`).Scan(&original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkOutboxConfirmed(currentDispatcherID, "task.control", "rejected-repeat"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.db.Exec(`UPDATE dispatcher_tasks SET control_state='stopped' WHERE dispatcher_id=? AND tenant_id=1001 AND task_id='task-asr'`, currentDispatcherID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.RejectData(currentDispatcherID, raw, cause); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := s.ListPendingOutbox(currentDispatcherID)
|
|
if err != nil || len(rows) != 1 || !bytes.Equal(original, rows[0].Body) {
|
|
t.Fatalf("rejected receipt changed on replay: %+v %v", rows, err)
|
|
}
|
|
}
|
|
|
|
func TestRejectedControlStateReadFailureDoesNotPersistFeedback(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
if _, err := s.db.Exec(`ALTER TABLE dispatcher_tasks RENAME COLUMN control_state TO unavailable_control_state`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw := []byte(`{"event_id":"state-failure","event_type":"task.control","dispatcher_id":"` + currentDispatcherID + `","tenant_id":1001,"payload":{"task_id":"task-asr","action":"pause"}}`)
|
|
if err := s.RejectData(currentDispatcherID, raw, errors.New("invalid structure")); err == nil {
|
|
t.Fatal("state lookup failure silently omitted task_state")
|
|
}
|
|
var count int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM dispatcher_outbox`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("feedback persisted on state failure: %d %v", count, err)
|
|
}
|
|
}
|
|
|
|
func TestEditReceiptRetransmitsOriginalStateAfterTaskChanges(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
snapshot := currentStoreSnapshot(t)
|
|
snapshot.SIP.Trunks = []byte(strings.Replace(string(snapshot.SIP.Trunks), `"max_concurrent_calls":null`, `"max_concurrent_calls":2`, 1))
|
|
if _, err := s.CompleteEdit(snapshot, "edit-repeat"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var original []byte
|
|
if err := s.db.QueryRow(`SELECT body FROM dispatcher_outbox WHERE event_id='edit-repeat'`).Scan(&original); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkOutboxConfirmed(currentDispatcherID, "task.control", "edit-repeat"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.PrepareControl(currentDispatcherID, 1001, "task-asr", "pause"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CompleteControl(currentDispatcherID, 1001, "task-asr", "pause", "pause-later"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.CompleteEdit(snapshot, "edit-repeat"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var repeated []byte
|
|
if err := s.db.QueryRow(`SELECT body FROM dispatcher_outbox WHERE event_id='edit-repeat'`).Scan(&repeated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(original, repeated) {
|
|
t.Fatalf("original receipt changed: %s -> %s", original, repeated)
|
|
}
|
|
if payload := controlPayload(t, s, "edit-repeat"); payload["task_state"] != "running" {
|
|
t.Fatalf("original state lost: %+v", payload)
|
|
}
|
|
if err := s.PrepareControl(currentDispatcherID, 1001, "task-asr", "pause"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CompleteControl(currentDispatcherID, 1001, "task-asr", "pause", "edit-repeat"); err == nil || !strings.Contains(err.Error(), "conflicts") {
|
|
t.Fatalf("event identity reused for another action: %v", err)
|
|
}
|
|
}
|