Files
go-sip/deploys/test/saas-mock/publish_test.go
T

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