Files
go-sip/internal/dispatcher/query_mq_integration_test.go
T

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
}