352 lines
13 KiB
Go
352 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/contract"
|
|
"github.com/google/uuid"
|
|
amqp "github.com/rabbitmq/amqp091-go"
|
|
)
|
|
|
|
func TestOneShotDispatchReceiptDoesNotEndCapture(t *testing.T) {
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
receipt := map[string]any{
|
|
"event_id": "event-once-1", "event_type": "call.execute",
|
|
"dispatcher_id": data.dispatcherID, "tenant_id": data.tenantID,
|
|
"issued_at": "2026-10-04T02:00:00Z", "payload": map[string]any{"status": "dispatched"},
|
|
}
|
|
body, err := json.Marshal(receipt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, err := verifyOneShotReceipt(body, data.dispatcherID, data.tenantID, "event-once-1"); err != nil || status != "dispatched" {
|
|
t.Fatalf("expected dispatch receipt: %s %v", status, err)
|
|
}
|
|
if _, err := verifyOneShotReceipt(body, data.dispatcherID, data.tenantID, "different-event"); err == nil {
|
|
t.Fatal("unrelated call receipt must remain unacknowledged")
|
|
}
|
|
receipt["payload"].(map[string]any)["status"] = "rejected"
|
|
receipt["payload"].(map[string]any)["reason_code"] = nil
|
|
receipt["payload"].(map[string]any)["reason_message"] = "Agent refused before outbound call"
|
|
body, _ = json.Marshal(receipt)
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, err := verifyOneShotReceipt(body, data.dispatcherID, data.tenantID, "event-once-1"); err != nil || status != "rejected" {
|
|
t.Fatalf("definite no-dial rejection should be acknowledged without a final result: %s %v", status, err)
|
|
}
|
|
receipt["payload"].(map[string]any)["status"] = "unknown"
|
|
body, _ = json.Marshal(receipt)
|
|
if _, err := verifyOneShotReceipt(body, data.dispatcherID, data.tenantID, "event-once-1"); err == nil {
|
|
t.Fatal("unknown receipt status cannot authorize completion")
|
|
}
|
|
}
|
|
|
|
type testMQAck struct{ accepted, requeued int }
|
|
|
|
func (a *testMQAck) Ack(uint64, bool) error { a.accepted++; return nil }
|
|
func (a *testMQAck) Nack(uint64, bool, bool) error { a.requeued++; return nil }
|
|
func (a *testMQAck) Reject(uint64, bool) error { a.requeued++; return nil }
|
|
|
|
func TestRejectedOneShotReceiptIsSavedBeforeAckAndStopsWaiting(t *testing.T) {
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.Marshal(map[string]any{"event_id": "call-rejected", "event_type": "call.execute", "dispatcher_id": data.dispatcherID, "tenant_id": data.tenantID, "issued_at": "2026-10-04T02:00:00Z", "payload": map[string]any{"status": "rejected", "reason_code": nil, "reason_message": "Agent refused before outbound call"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ack := &testMQAck{}
|
|
incoming := make(chan amqp.Delivery, 1)
|
|
incoming <- amqp.Delivery{Body: body, Acknowledger: ack}
|
|
consumer := &resultConsumer{deliveries: incoming}
|
|
dir := t.TempDir()
|
|
if err := os.Chmod(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := filepath.Join(dir, "result.json")
|
|
_, err = consumer.wait(context.Background(), file, data.dispatcherID, data.tenantID, "call-rejected", "task-full", "15003164745", "trunk-mock")
|
|
if err == nil || !strings.Contains(err.Error(), "rejected") || ack.accepted != 1 || ack.requeued != 0 {
|
|
t.Fatalf("rejected receipt was not acknowledged as terminal no-dial: accepted=%d requeued=%d err=%v", ack.accepted, ack.requeued, err)
|
|
}
|
|
saved, err := os.ReadFile(file + ".rejected.json")
|
|
if err != nil || !bytes.Equal(saved, body) {
|
|
t.Fatalf("receipt not durably retained: %v", err)
|
|
}
|
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
t.Fatalf("false final result created: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOneShotResultMustMatchSourceIdentityAndPersistBeforeReceipt(t *testing.T) {
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sourceID := "event-once-1"
|
|
resultID := expectedResultEventID(data.dispatcherID, sourceID)
|
|
result := map[string]any{}
|
|
example, err := os.ReadFile("../../../contracts/local/examples/mq-result-uploaded.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(example, &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result["event_id"] = resultID
|
|
result["dispatcher_id"] = data.dispatcherID
|
|
payload := result["payload"].(map[string]any)
|
|
payload["task_id"], payload["callee"], payload["trunk_id"] = "task-full", "15003164745", "trunk-mock"
|
|
body, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := summarizeOneShotResult(body, data.dispatcherID, sourceID, "task-full", "15003164745", "trunk-mock"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := summarizeOneShotResult(body, data.dispatcherID, "different-event", "task-full", "15003164745", "trunk-mock"); err == nil {
|
|
t.Fatal("unrelated final result must not be acknowledged")
|
|
}
|
|
if _, err := summarizeOneShotResult(body, data.dispatcherID, sourceID, "task-full", "15003164745", "different-trunk"); err == nil {
|
|
t.Fatal("result from the wrong trunk must not be acknowledged")
|
|
}
|
|
dir := t.TempDir()
|
|
if err := os.Chmod(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := filepath.Join(dir, "one-result.json")
|
|
if err := persistOneShotResult(file, body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stored, err := os.ReadFile(file)
|
|
if err != nil || !bytes.Equal(stored, body) {
|
|
t.Fatal("final result not durably saved")
|
|
}
|
|
if info, err := os.Stat(file); err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatal("result must remain private")
|
|
}
|
|
if err := persistOneShotResult(file, body); err == nil {
|
|
t.Fatal("existing final result must not be overwritten")
|
|
}
|
|
}
|
|
|
|
func TestSaaSMockAwaitsExactResultBeforeStoppingCapture(t *testing.T) {
|
|
url := os.Getenv("SAAS_MOCK_TEST_BROKER_URL")
|
|
if url == "" {
|
|
t.Skip("requires isolated ephemeral dedicated-vhost RabbitMQ")
|
|
}
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := provisionMQ(url, data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eventID := "test-" + uuid.NewString()
|
|
result, err := os.ReadFile("../../../contracts/local/examples/mq-result-uploaded.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var message map[string]any
|
|
if err := json.Unmarshal(result, &message); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
message["event_id"] = expectedResultEventID(data.dispatcherID, eventID)
|
|
message["dispatcher_id"] = data.dispatcherID
|
|
payload := message["payload"].(map[string]any)
|
|
payload["task_id"], payload["callee"], payload["trunk_id"] = "task-full", "15003164745", "trunk-mock"
|
|
result, err = json.Marshal(message)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
receipt, err := json.Marshal(map[string]any{"event_id": eventID, "event_type": "call.execute", "dispatcher_id": data.dispatcherID, "tenant_id": data.tenantID, "issued_at": "2026-10-04T02:00:00Z", "payload": map[string]any{"status": "dispatched"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
producer := make(chan error, 1)
|
|
go func() {
|
|
conn, err := amqp.Dial(url)
|
|
if err != nil {
|
|
producer <- err
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
ch, err := conn.Channel()
|
|
if err != nil {
|
|
producer <- err
|
|
return
|
|
}
|
|
defer ch.Close()
|
|
queue := "agent-call.d." + testDispatcher + ".task.task-full.v1"
|
|
for {
|
|
command, found, err := ch.Get(queue, false)
|
|
if err != nil {
|
|
producer <- err
|
|
return
|
|
}
|
|
if found {
|
|
if !bytes.Contains(command.Body, []byte(eventID)) {
|
|
producer <- fmt.Errorf("wrong command received")
|
|
return
|
|
}
|
|
if err := command.Ack(false); err != nil {
|
|
producer <- err
|
|
return
|
|
}
|
|
if err := ch.PublishWithContext(ctx, "agent-call.saas.v1", "d."+testDispatcher+".out", true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, ContentType: "application/json", Body: receipt}); err != nil {
|
|
producer <- err
|
|
return
|
|
}
|
|
err = ch.PublishWithContext(ctx, "agent-call.saas.v1", "d."+testDispatcher+".out", true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, ContentType: "application/json", Body: result})
|
|
producer <- err
|
|
return
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
producer <- ctx.Err()
|
|
return
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
}()
|
|
dir := t.TempDir()
|
|
if err := os.Chmod(dir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := filepath.Join(dir, "final.json")
|
|
observed, err := publishAndAwait(ctx, url, data, eventID, "task-full", "15003164745", file, time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-producer; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if observed.Outcome != "answered" || observed.RecordingStatus != "uploaded" {
|
|
t.Fatalf("result not acknowledged with real-looking recording: %+v", observed)
|
|
}
|
|
if saved, err := os.ReadFile(file); err != nil || !bytes.Equal(saved, result) {
|
|
t.Fatalf("result was not saved before broker receipt: err=%v", err)
|
|
}
|
|
if saved, err := os.ReadFile(file + ".receipt.json"); err != nil || !bytes.Equal(saved, receipt) {
|
|
t.Fatalf("issuance receipt was not persisted before acknowledgment: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaaSMockPublishesOneCommandOnProvisionedRabbitMQ(t *testing.T) {
|
|
url := os.Getenv("SAAS_MOCK_TEST_BROKER_URL")
|
|
if url == "" {
|
|
t.Skip("requires an isolated ephemeral dedicated-vhost RabbitMQ")
|
|
}
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := provisionMQ(url, data); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, discovered := range data.discovery {
|
|
found = found || discovered.TaskID == "task-full"
|
|
}
|
|
if !found {
|
|
t.Fatal("fixture task-full was not preprovisioned")
|
|
}
|
|
conn, err := amqp.Dial(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
ch, err := conn.Channel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ch.Close()
|
|
eventID := "test-" + uuid.NewString()
|
|
at := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := publishExecuteAt(ctx, url, data, eventID, "task-full", "15003164745", at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queue := "agent-call.d." + testDispatcher + ".task.task-full.v1"
|
|
message, ok, err := ch.Get(queue, false)
|
|
if err != nil || !ok {
|
|
t.Fatalf("one command must reach exactly its preprovisioned task queue: present=%v err=%v", ok, err)
|
|
}
|
|
if err := contract.ValidateCurrent("mq", message.Body); err != nil || !strings.Contains(string(message.Body), eventID) || message.DeliveryMode != amqp.Persistent {
|
|
t.Fatalf("broker delivered wrong command identity or persistence: err=%v", err)
|
|
}
|
|
if err := message.Ack(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if extra, ok, err := ch.Get(queue, false); err != nil || ok {
|
|
if ok {
|
|
_ = extra.Nack(false, true)
|
|
}
|
|
t.Fatalf("one publish created a second command: present=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestSaaSMockBuildsOneApprovedCommandForTheBoundTask(t *testing.T) {
|
|
data, err := loadDataset(testDataDir(t), testDispatcher)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inside := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC) // Shanghai 10:00
|
|
key, body, err := buildExecute(data, "event-once-1", "task-full", "15003164745", inside)
|
|
if err != nil || key != "d."+testDispatcher+".task.task-full.in" {
|
|
t.Fatalf("SaaS command must target only the assigned Dispatcher/task: key=%q err=%v", key, err)
|
|
}
|
|
if err := contract.ValidateCurrent("mq", body); err != nil {
|
|
t.Fatalf("call command violates the formal contract: %v", err)
|
|
}
|
|
var event struct {
|
|
EventID string `json:"event_id"`
|
|
Type string `json:"event_type"`
|
|
DispatcherID string `json:"dispatcher_id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
Payload struct {
|
|
TaskID string `json:"task_id"`
|
|
Callee string `json:"callee"`
|
|
} `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(body, &event); err != nil || event.EventID != "event-once-1" || event.Type != "call.execute" || event.DispatcherID != testDispatcher || event.TenantID != 1001 || event.Payload.TaskID != "task-full" || event.Payload.Callee != "15003164745" || strings.Contains(string(body), "trunk-mock") {
|
|
t.Fatalf("SaaS must not leak a trunk/caller/AI override into the execute command: %+v err=%v", event, err)
|
|
}
|
|
for _, test := range []struct {
|
|
id, task, callee string
|
|
at time.Time
|
|
}{
|
|
{"../event", "task-full", "15003164745", inside},
|
|
{"event-2", "missing", "15003164745", inside},
|
|
{"event-3", "task-full", "708915003164745", inside},
|
|
{"event-4", "task-full", "13900000000", inside},
|
|
{"event-5", "task-full", "15003164745", inside.Add(-2 * time.Hour)},
|
|
{"event-6", "task-full", "15003164745", inside.Add(10 * time.Hour)},
|
|
} {
|
|
if _, _, err := buildExecute(data, test.id, test.task, test.callee, test.at); err == nil {
|
|
t.Fatalf("invalid or out-of-window command was allowed: event=%q task=%q", test.id, test.task)
|
|
}
|
|
}
|
|
}
|