257 lines
7.8 KiB
Go
257 lines
7.8 KiB
Go
package dispatcher
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/go-sip/contracts"
|
|
"git.ipao.vip/rogee/go-sip/internal/contract"
|
|
"git.ipao.vip/rogee/go-sip/internal/mq"
|
|
"git.ipao.vip/rogee/go-sip/internal/store"
|
|
"git.ipao.vip/rogee/go-sip/internal/tenant"
|
|
"github.com/google/uuid"
|
|
amqp "github.com/rabbitmq/amqp091-go"
|
|
)
|
|
|
|
// A dedicated local vhost prevents other package tests from consuming these
|
|
// messages from the contract's fixed SaaS queue.
|
|
func TestLocalMQRequestRoundTrip(t *testing.T) {
|
|
address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL")
|
|
if address == "" {
|
|
t.Skip("dedicated local RabbitMQ vhost not configured")
|
|
}
|
|
u, err := url.Parse(address)
|
|
if err != nil || (u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" && u.Hostname() != "localhost") {
|
|
t.Fatal("test requires loopback RabbitMQ")
|
|
}
|
|
for _, fixture := range []string{"command-query", "call-query", "call-replay", "command-replay", "call-execute"} {
|
|
t.Run(fixture, func(t *testing.T) { runLocalMQRequest(t, address, fixture) })
|
|
}
|
|
}
|
|
|
|
func runLocalMQRequest(t *testing.T, address, fixture string) {
|
|
t.Helper()
|
|
id, key := uuid.NewString(), "request."+uuid.NewString()
|
|
broker, err := mq.Open(address, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer broker.Close()
|
|
s, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
if err := s.BindDispatcherID(id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, err := New(s, broker, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
route, err := tenant.NewDispatcherRoute(id, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := broker.DeclareTenantQueue(key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection, err := amqp.Dial(address)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer connection.Close()
|
|
channel, err := connection.Channel()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer channel.Close()
|
|
defer channel.QueueDelete(route.InboxQueue, false, false, false)
|
|
defer channel.QueueDelete(route.DeadLetterQueue, false, false, false)
|
|
defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil)
|
|
if err := channel.Confirm(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := contracts.Files.ReadFile("upstream/v1/examples/" + fixture + ".json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request map[string]any
|
|
if err := json.Unmarshal(raw, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
requestID := uuid.NewString()
|
|
request["dispatcher_id"], request["tenant_key"] = id, key
|
|
_, isCommand := request["command_type"]
|
|
identityField := "message_id"
|
|
if isCommand {
|
|
identityField = "command_id"
|
|
}
|
|
request[identityField] = requestID
|
|
request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano)
|
|
request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano)
|
|
raw, err = json.Marshal(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
isExecute := fixture == "call-execute"
|
|
isReplay := isCommand && !isExecute
|
|
var originalFact []byte
|
|
if isReplay {
|
|
originalFact = seedReplayMQFact(t, s, id, key, route.OutboundKey)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
finished := make(chan error, 1)
|
|
go func() { finished <- d.ConsumeTenant(ctx, broker, key) }()
|
|
defer func() { cancel(); <-finished }()
|
|
originalResponseID := ""
|
|
factDeliveries := 0
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: raw})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ack, err := confirmation.WaitContext(ctx); err != nil || !ack {
|
|
t.Fatalf("input not queued: %v", err)
|
|
}
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
delivered := false
|
|
for !delivered {
|
|
select {
|
|
case <-ctx.Done():
|
|
ticker.Stop()
|
|
t.Fatal("MQ response timed out")
|
|
case <-ticker.C:
|
|
n, err := d.FlushOutbox(ctx, 1)
|
|
if err != nil {
|
|
ticker.Stop()
|
|
t.Fatal(err)
|
|
}
|
|
if n == 0 {
|
|
continue
|
|
}
|
|
message, ok, err := channel.Get(mq.SaaSQueue, true)
|
|
if err != nil || !ok {
|
|
ticker.Stop()
|
|
t.Fatalf("confirmed output absent: %v", err)
|
|
}
|
|
if err := contract.ValidateMQMessage(message.Body); err != nil {
|
|
ticker.Stop()
|
|
t.Fatal(err)
|
|
}
|
|
var response struct {
|
|
DispatcherID string `json:"dispatcher_id"`
|
|
TenantKey string `json:"tenant_key"`
|
|
MessageID string `json:"message_id"`
|
|
CorrelationID string `json:"correlation_id"`
|
|
EventID string `json:"event_id"`
|
|
EventType string `json:"event_type"`
|
|
Payload struct {
|
|
CommandID string `json:"command_id"`
|
|
Status string `json:"status"`
|
|
} `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(message.Body, &response); err != nil {
|
|
ticker.Stop()
|
|
t.Fatal(err)
|
|
}
|
|
if response.DispatcherID != id || response.TenantKey != key || message.DeliveryMode != amqp.Persistent || message.RoutingKey != route.OutboundKey {
|
|
ticker.Stop()
|
|
t.Fatal("output scope or durability mismatch")
|
|
}
|
|
if isReplay && response.EventType != "command.result" {
|
|
if string(message.Body) != string(originalFact) {
|
|
ticker.Stop()
|
|
t.Fatal("replay rewrote the original business fact")
|
|
}
|
|
factDeliveries++
|
|
continue
|
|
}
|
|
responseID := response.MessageID
|
|
if isCommand {
|
|
responseID = response.EventID
|
|
wantStatus := "applied"
|
|
if isExecute {
|
|
wantStatus = "accepted"
|
|
}
|
|
if response.Payload.CommandID != requestID || response.Payload.Status != wantStatus {
|
|
ticker.Stop()
|
|
t.Fatal("incorrect replay receipt")
|
|
}
|
|
} else if response.CorrelationID != requestID {
|
|
ticker.Stop()
|
|
t.Fatal("incorrect query correlation")
|
|
}
|
|
if attempt == 0 {
|
|
originalResponseID = responseID
|
|
} else if responseID != originalResponseID {
|
|
ticker.Stop()
|
|
t.Fatal("duplicate created another response identity")
|
|
}
|
|
delivered = true
|
|
}
|
|
}
|
|
ticker.Stop()
|
|
}
|
|
if isReplay && factDeliveries != 1 {
|
|
t.Fatalf("original fact delivered %d times; duplicate re-executed replay", factDeliveries)
|
|
}
|
|
var tasks int
|
|
if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&tasks); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantTasks := 0
|
|
if isExecute {
|
|
wantTasks = 1
|
|
}
|
|
if tasks != wantTasks {
|
|
t.Fatalf("created %d tasks, want %d", tasks, wantTasks)
|
|
}
|
|
}
|
|
|
|
func seedReplayMQFact(t *testing.T, s *store.Store, id, key, routingKey string) []byte {
|
|
t.Helper()
|
|
raw, err := contracts.Files.ReadFile("upstream/v1/examples/call-execute.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var command map[string]any
|
|
if err := json.Unmarshal(raw, &command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command["dispatcher_id"], command["tenant_key"], command["command_id"] = id, key, "command-a"
|
|
command["payload"].(map[string]any)["execution_id"] = "execution-1"
|
|
raw, err = json.Marshal(command)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.DB().Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at) VALUES('tenant-a','command-a',?,'call.execute','fixture',?,'persisted','2026-09-21T00:00:00Z')`, key, raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err = contracts.Files.ReadFile("upstream/v1/examples/event-call-status.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var event map[string]any
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
event["dispatcher_id"], event["tenant_key"] = id, key
|
|
raw, err = json.Marshal(event)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := contract.ValidateMQMessage(raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,'agent-call.saas.v2',?,?,'published',?)`, event["event_id"], key, routingKey, raw, event["occurred_at"]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|