278 lines
11 KiB
Go
278 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/configread"
|
|
)
|
|
|
|
func preparedCurrentCallStore(t *testing.T) *CurrentStore {
|
|
t.Helper()
|
|
s, err := OpenCurrent(filepath.Join(t.TempDir(), "state.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
task := currentStoreSnapshot(t)
|
|
task.SIP.Trunks = []byte(strings.Replace(string(task.SIP.Trunks), `"max_concurrent_calls":null`, `"max_concurrent_calls":2`, 1))
|
|
if err := s.ApplyDiscoverySnapshot(currentDispatcherID, []configread.CurrentDiscoveredTask{{TaskID: task.Task.TaskID, TenantID: task.Task.TenantID, TaskRevision: task.Task.TaskRevision, Status: "running"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.SaveSnapshot(task); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkReadyForSIP(currentDispatcherID, 8); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func currentCall(id string) CurrentExecuteCommand {
|
|
return CurrentExecuteCommand{DispatcherID: currentDispatcherID, EventID: id, TenantID: 1001, TaskID: "task-asr", Callee: "15003164745", IssuedAt: "2026-09-21T01:30:00Z"}
|
|
}
|
|
func currentReservation() CurrentCallReservation {
|
|
return CurrentCallReservation{TrunkID: "trunk-mock", SIPRevision: 8, CallerID: "BD00000000", DialedCallee: "15003164745", Deadline: time.Date(2026, 9, 21, 1, 32, 0, 0, time.UTC)}
|
|
}
|
|
|
|
func TestCurrentExecuteInboxPreventsDuplicateOriginationAfterRestart(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-1")
|
|
first, created, err := s.RecordExecute(cmd)
|
|
if err != nil || !created || first.Status != "pending" {
|
|
t.Fatalf("first durable receipt: %+v created=%v err=%v", first, created, err)
|
|
}
|
|
second, created, err := s.RecordExecute(cmd)
|
|
if err != nil || created || second.Status != "pending" {
|
|
t.Fatalf("redelivery not deduplicated: %+v created=%v err=%v", second, created, err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, created, err := s.RecordExecute(cmd); err != nil || created {
|
|
t.Fatalf("reserved call was reaccepted: created=%v err=%v", created, err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err == nil {
|
|
t.Fatal("duplicate receipt reserved a second originate")
|
|
}
|
|
pending, err := s.ListPendingExecute(currentDispatcherID)
|
|
if err != nil || len(pending) != 0 {
|
|
t.Fatalf("unknown/reserved call retried: %+v %v", pending, err)
|
|
}
|
|
if err := s.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, created, err := s.RecordExecute(cmd); err != nil || created {
|
|
t.Fatalf("unknown call reaccepted: created=%v err=%v", created, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentExecuteRejectsOnlyOnceWithoutFinalResult(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-invalid")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.RejectExecute(cmd.DispatcherID, cmd.EventID, "callee not permitted"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outbox, err := s.ListPendingOutbox(currentDispatcherID)
|
|
if err != nil || len(outbox) != 1 || outbox[0].EventType != "call.execute" || !strings.Contains(string(outbox[0].Body), `"status":"rejected"`) {
|
|
t.Fatalf("rejection outbox: %+v %v", outbox, err)
|
|
}
|
|
if _, created, err := s.RecordExecute(cmd); err != nil || created {
|
|
t.Fatalf("rejected command was accepted twice: %v %v", created, err)
|
|
}
|
|
if err := s.RejectExecute(cmd.DispatcherID, cmd.EventID, "callee not permitted"); err == nil {
|
|
t.Fatal("duplicate rejection created another outbox event")
|
|
}
|
|
if err := s.MarkOutboxConfirmed(currentDispatcherID, outbox[0].EventID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pending, err := s.ListPendingOutbox(currentDispatcherID); err != nil || len(pending) != 0 {
|
|
t.Fatalf("confirmed outbox was republished: %+v %v", pending, err)
|
|
}
|
|
var total int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM dispatcher_outbox WHERE dispatcher_id=?`, currentDispatcherID).Scan(&total); err != nil || total != 1 {
|
|
t.Fatalf("confirmed outbox vanished before SaaS receipt: %d %v", total, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentExecuteQuotaCountsUnknownAndReleasesOnlyConfirmedEnd(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)
|
|
for _, id := range []string{"call-1", "call-2"} {
|
|
cmd := currentCall(id)
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
third := currentCall("call-3")
|
|
if _, _, err := s.RecordExecute(third); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(third.DispatcherID, third.EventID, currentReservation(), at); !errors.Is(err, ErrCurrentCapacity) {
|
|
t.Fatalf("third call exceeded task/trunk capacity: %v", err)
|
|
}
|
|
if err := s.MarkExecuteUnknown(currentDispatcherID, "call-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(third.DispatcherID, third.EventID, currentReservation(), at); !errors.Is(err, ErrCurrentCapacity) {
|
|
t.Fatalf("unknown call released occupancy: %v", err)
|
|
}
|
|
if err := s.MarkExecuteDispatched(currentDispatcherID, "call-2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.FinishExecute(currentDispatcherID, "call-2"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(third.DispatcherID, third.EventID, currentReservation(), at); err != nil {
|
|
t.Fatalf("finished call failed to release capacity: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentConfirmedEndBeforeOriginateAckIsDurableAndIdempotent(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-ended-before-ack")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.FinishExecute(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("confirmed Agent end raced with originate acknowledgment: %v", err)
|
|
}
|
|
if err := s.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("late originate acknowledgment was not idempotent: %v", err)
|
|
}
|
|
if err := s.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("late RPC timeout obscured a confirmed call end: %v", err)
|
|
}
|
|
if err := s.FinishExecute(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("Agent end-report retry was not idempotent: %v", err)
|
|
}
|
|
outbox, err := s.ListPendingOutbox(cmd.DispatcherID)
|
|
if err != nil || len(outbox) != 1 || outbox[0].EventID != cmd.EventID || !strings.Contains(string(outbox[0].Body), `"status":"dispatched"`) {
|
|
t.Fatalf("early end lost or duplicated original acknowledgment: outbox=%+v err=%v", outbox, err)
|
|
}
|
|
occupied, err := s.TrunkOccupancy(cmd.DispatcherID)
|
|
if err != nil || occupied["trunk-mock"] != 0 {
|
|
t.Fatalf("confirmed end held occupancy after acknowledgment: occupied=%+v err=%v", occupied, err)
|
|
}
|
|
if _, created, err := s.RecordExecute(cmd); err != nil || created {
|
|
t.Fatalf("restarted finished call could originate again: created=%t err=%v", created, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentConfirmedUnknownCallEndReleasesOnlyAfterDurableAck(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-ended-after-unknown")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.FinishExecute(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("confirmed unknown call end did not release: %v", err)
|
|
}
|
|
outbox, err := s.ListPendingOutbox(cmd.DispatcherID)
|
|
if err != nil || len(outbox) != 1 || outbox[0].EventID != cmd.EventID {
|
|
t.Fatalf("confirmed unknown execution lacked one durable acknowledgment: outbox=%+v err=%v", outbox, err)
|
|
}
|
|
occupied, err := s.TrunkOccupancy(cmd.DispatcherID)
|
|
if err != nil || occupied["trunk-mock"] != 0 {
|
|
t.Fatalf("confirmed unknown call still occupied trunk: occupied=%+v err=%v", occupied, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentEarlyEndOutboxFailureRetainsUnknownOccupancy(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-early-end-fault")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.db.Exec(`CREATE TRIGGER fail_early_ack BEFORE INSERT ON dispatcher_outbox BEGIN SELECT RAISE(ABORT,'injected acknowledgment fault'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.FinishExecute(cmd.DispatcherID, cmd.EventID); err == nil {
|
|
t.Fatal("acknowledgment write failure released the call")
|
|
}
|
|
occupied, err := s.TrunkOccupancy(cmd.DispatcherID)
|
|
if err != nil || occupied["trunk-mock"] != 1 {
|
|
t.Fatalf("failed acknowledgment lost the unknown occupancy: occupied=%+v err=%v", occupied, err)
|
|
}
|
|
if _, err := s.db.Exec(`DROP TRIGGER fail_early_ack`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.FinishExecute(cmd.DispatcherID, cmd.EventID); err != nil {
|
|
t.Fatalf("original confirmed end could not resume: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentEndAndOriginateAckRaceNeverDuplicate(t *testing.T) {
|
|
for attempt := 0; attempt < 30; attempt++ {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-concurrent-end")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
errorsCh := make(chan error, 2)
|
|
go func() { errorsCh <- s.FinishExecute(cmd.DispatcherID, cmd.EventID) }()
|
|
go func() { errorsCh <- s.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID) }()
|
|
for range 2 {
|
|
if err := <-errorsCh; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
outbox, err := s.ListPendingOutbox(cmd.DispatcherID)
|
|
if err != nil || len(outbox) != 1 {
|
|
t.Fatalf("race produced multiple acknowledgments: outbox=%+v err=%v", outbox, err)
|
|
}
|
|
occupied, err := s.TrunkOccupancy(cmd.DispatcherID)
|
|
if err != nil || occupied["trunk-mock"] != 0 {
|
|
t.Fatalf("race failed to release confirmed end: occupied=%+v err=%v", occupied, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentExecuteOutboxFailureDoesNotMarkOriginatedCallDelivered(t *testing.T) {
|
|
s := preparedCurrentCallStore(t)
|
|
cmd := currentCall("call-fault")
|
|
if _, _, err := s.RecordExecute(cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.ReserveExecute(cmd.DispatcherID, cmd.EventID, currentReservation(), time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.db.Exec(`CREATE TRIGGER fail_outbox BEFORE INSERT ON dispatcher_outbox BEGIN SELECT RAISE(ABORT,'injected outbox failure'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID); err == nil {
|
|
t.Fatal("marked dispatch despite outbox failure")
|
|
}
|
|
var status string
|
|
if err := s.db.QueryRow(`SELECT status FROM dispatcher_inbox WHERE dispatcher_id=? AND event_id=?`, cmd.DispatcherID, cmd.EventID).Scan(&status); err != nil || status != "dispatching" {
|
|
t.Fatalf("lost pre-originate fence after outbox failure: %q %v", status, err)
|
|
}
|
|
if _, created, err := s.RecordExecute(cmd); err != nil || created {
|
|
t.Fatalf("originated command redialed after outbox failure: %v %v", created, err)
|
|
}
|
|
}
|