Files
go-sip/internal/store/control_receipt_test.go
T

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)
}
}