Files
kandev/apps/backend/internal/gateway/websocket/client_send_queue_test.go
T
2026-08-03 10:07:12 +01:00

418 lines
13 KiB
Go

package websocket
import (
"encoding/json"
"reflect"
"slices"
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestClient_ControlResponseIsNotBlockedByStreamBacklog(t *testing.T) {
c := newTestClient("control-priority")
c.controlSend = make(chan []byte, controlSendBufferSize)
for index := 0; index < cap(c.send); index++ {
if !c.sendBytes([]byte("stream")) {
t.Fatalf("stream frame %d was not queued", index)
}
}
response, err := ws.NewResponse("request-1", "message.add", map[string]any{"accepted": true})
if err != nil {
t.Fatalf("build response: %v", err)
}
c.sendMessage(response)
select {
case data := <-c.controlSend:
var got ws.Message
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("decode control frame: %v", err)
}
if got.ID != "request-1" || got.Type != ws.MessageTypeResponse {
t.Fatalf("unexpected control frame: %+v", got)
}
default:
t.Fatal("control response was not queued while stream queue was full")
}
}
func TestClient_ControlOverflowClosesConnection(t *testing.T) {
c := newTestClient("control-overflow")
c.controlSend = make(chan []byte, 1)
first, err := ws.NewResponse("request-1", "message.add", map[string]any{"accepted": true})
if err != nil {
t.Fatalf("build first response: %v", err)
}
second, err := ws.NewResponse("request-2", "message.add", map[string]any{"accepted": true})
if err != nil {
t.Fatalf("build second response: %v", err)
}
c.sendMessage(first)
c.sendMessage(second)
if !c.closed {
t.Fatal("control overflow should close the client")
}
if _, ok := <-c.send; ok {
t.Fatal("stream queue should be closed after control overflow")
}
if _, ok := <-c.controlSend; ok {
// The first response is still drainable, but the queue must close after
// its buffered frame is consumed.
if _, stillOpen := <-c.controlSend; stillOpen {
t.Fatal("control queue remained open after overflow")
}
}
}
func TestClient_ReplaceableSessionMessageKeepsLatestFrame(t *testing.T) {
c := newTestClient("replaceable-session-message")
first := []byte(`{"type":"notification","action":"session.message.updated","payload":{"session_id":"session-1","message_id":"message-1","content":"old"}}`)
latest := []byte(`{"type":"notification","action":"session.message.updated","payload":{"session_id":"session-1","message_id":"message-1","content":"latest"}}`)
if !c.sendBytes(first) || !c.sendBytes(latest) {
t.Fatal("replaceable frames should be accepted")
}
if got := len(c.send); got != 0 {
t.Fatalf("replaceable frame leaked into semantic queue, got %d", got)
}
frame, ok := c.popNextReplaceable()
if !ok {
t.Fatal("expected one replaceable frame")
}
var message ws.Message
if err := json.Unmarshal(frame.data, &message); err != nil {
t.Fatalf("decode queued frame: %v", err)
}
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.Content != "latest" {
t.Fatalf("expected latest content, got %q", payload.Content)
}
if stats := c.notificationQueueStats(); stats.Depth != 0 || stats.Replacements != 1 {
t.Fatalf("unexpected replaceable stats: %+v", stats)
}
}
func TestClient_ReplaceableQueuePreservesPositionAndSemanticBarrier(t *testing.T) {
c := newTestClient("replaceable-order")
first, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": "session-1", "message_id": "message-1", "content": "first",
})
if err != nil {
t.Fatalf("build first update: %v", err)
}
latest, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": "session-1", "message_id": "message-1", "content": "latest",
})
if err != nil {
t.Fatalf("build latest update: %v", err)
}
deleted, err := ws.NewNotification(ws.ActionSessionMessageDeleted, map[string]any{
"session_id": "session-1", "message_id": "message-1",
})
if err != nil {
t.Fatalf("build delete: %v", err)
}
firstBytes, _ := json.Marshal(first)
latestBytes, _ := json.Marshal(latest)
deletedBytes, _ := json.Marshal(deleted)
if !c.sendNotification(firstBytes, first.Action) || !c.sendNotification(latestBytes, latest.Action) {
t.Fatal("message updates should be accepted")
}
if !c.sendNotification(deletedBytes, deleted.Action) {
t.Fatal("semantic barrier should be accepted")
}
if got := len(c.send); got != 0 {
t.Fatalf("semantic barrier overtook replacement queue, got %d frames", got)
}
frame, ok := c.popNextReplaceable()
if !ok {
t.Fatal("expected replacement frame")
}
var got ws.Message
if err := json.Unmarshal(frame.data, &got); err != nil {
t.Fatalf("decode replacement: %v", err)
}
if got.Action != ws.ActionSessionMessageUpdated {
t.Fatalf("expected message update first, got %q", got.Action)
}
var payload struct {
Content string `json:"content"`
}
if err := json.Unmarshal(got.Payload, &payload); err != nil {
t.Fatalf("decode replacement payload: %v", err)
}
if payload.Content != "latest" {
t.Fatalf("expected latest payload in original position, got %q", payload.Content)
}
barrierFrame, ok := c.popNextReplaceable()
if !ok {
t.Fatal("expected semantic barrier after replacement")
}
var barrierMessage ws.Message
if err := json.Unmarshal(barrierFrame.data, &barrierMessage); err != nil {
t.Fatalf("decode semantic barrier: %v", err)
}
if barrierMessage.Action != ws.ActionSessionMessageDeleted {
t.Fatalf("expected delete barrier after replacement, got %q", barrierMessage.Action)
}
}
func TestClient_ReplaceableQueueRoundRobinsSessions(t *testing.T) {
c := newTestClient("replaceable-round-robin")
for _, item := range []struct {
sessionID string
messageID string
content string
}{
{sessionID: "session-a", messageID: "message-a1", content: "a1"},
{sessionID: "session-a", messageID: "message-a2", content: "a2"},
{sessionID: "session-b", messageID: "message-b1", content: "b1"},
{sessionID: "session-b", messageID: "message-b2", content: "b2"},
} {
message, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": item.sessionID, "message_id": item.messageID, "content": item.content,
})
if err != nil {
t.Fatalf("build update: %v", err)
}
data, _ := json.Marshal(message)
if !c.sendNotification(data, message.Action) {
t.Fatalf("queue update %s/%s", item.sessionID, item.messageID)
}
}
var got []string
for i := 0; i < 4; i++ {
frame, ok := c.popNextReplaceable()
if !ok {
t.Fatalf("missing frame %d", i)
}
var message ws.Message
if err := json.Unmarshal(frame.data, &message); err != nil {
t.Fatalf("decode frame %d: %v", i, err)
}
var payload struct {
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("decode frame payload %d: %v", i, err)
}
got = append(got, payload.MessageID)
}
want := []string{"message-a1", "message-b1", "message-a2", "message-b2"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round-robin order = %v, want %v", got, want)
}
}
func TestClient_ReplaceableQueueBoundsNoisySessionOnly(t *testing.T) {
c := newTestClient("replaceable-bounds")
c.replaceablePerSessionLimit = 2
c.replaceableGlobalLimit = 4
queueUpdate := func(sessionID, messageID string) {
t.Helper()
message, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": sessionID, "message_id": messageID,
})
if err != nil {
t.Fatalf("build update: %v", err)
}
data, _ := json.Marshal(message)
if !c.sendNotification(data, message.Action) {
t.Fatalf("queue update %s/%s", sessionID, messageID)
}
}
queueUpdate("noisy", "n1")
queueUpdate("noisy", "n2")
queueUpdate("noisy", "n3") // evicts noisy/n1
queueUpdate("quiet", "q1")
queueUpdate("quiet", "q2")
queueUpdate("quiet", "q3") // evicts quiet/q1, not noisy
if accepted := c.sendNotification([]byte(`{"type":"notification","action":"session.message.updated","payload":{"session_id":"other","message_id":"o1"}}`), ws.ActionSessionMessageUpdated); accepted {
t.Fatal("global overflow should reject a new session rather than evict another session")
}
stats := c.notificationQueueStats()
if stats.Depth != 4 || stats.Evictions != 2 || stats.Rejected != 1 {
t.Fatalf("unexpected bounded queue stats: %+v", stats)
}
var got []string
for {
frame, ok := c.popNextReplaceable()
if !ok {
break
}
var message ws.Message
if err := json.Unmarshal(frame.data, &message); err != nil {
t.Fatalf("decode bounded frame: %v", err)
}
var payload struct {
MessageID string `json:"message_id"`
}
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("decode bounded payload: %v", err)
}
got = append(got, payload.MessageID)
}
for _, messageID := range []string{"n2", "n3", "q2", "q3"} {
if !slices.Contains(got, messageID) {
t.Fatalf("bounded queue lost %s: %v", messageID, got)
}
}
for _, messageID := range []string{"n1", "q1", "o1"} {
if slices.Contains(got, messageID) {
t.Fatalf("bounded queue retained evicted/rejected %s: %v", messageID, got)
}
}
}
func TestClient_ReplaceableEvictionKeepsSessionOrderUnique(t *testing.T) {
c := newTestClient("replaceable-eviction-order")
c.replaceablePerSessionLimit = 2
c.replaceableGlobalLimit = 2
queueUpdate := func(sessionID, messageID string) {
t.Helper()
message, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": sessionID, "message_id": messageID,
})
if err != nil {
t.Fatalf("build update: %v", err)
}
data, _ := json.Marshal(message)
if !c.sendNotification(data, message.Action) {
t.Fatalf("queue update %s/%s", sessionID, messageID)
}
}
queueUpdate("session-a", "message-a1")
queueUpdate("session-b", "message-b1")
queueUpdate("session-a", "message-a2") // evicts a1 while a remains registered
c.mu.RLock()
order := append([]string(nil), c.replaceableSessionOrder...)
c.mu.RUnlock()
if !reflect.DeepEqual(order, []string{"session-a", "session-b"}) {
t.Fatalf("replaceable session order = %v, want one entry per session", order)
}
}
func TestClient_SemanticBarrierPartitionsReplacementUpdates(t *testing.T) {
c := newTestClient("semantic-barrier-partition")
queueUpdate := func(content string) {
t.Helper()
message, err := ws.NewNotification(ws.ActionSessionMessageUpdated, map[string]any{
"session_id": "session-1", "message_id": "message-1", "content": content,
})
if err != nil {
t.Fatalf("build update: %v", err)
}
data, _ := json.Marshal(message)
if !c.sendNotification(data, message.Action) {
t.Fatalf("queue update %q", content)
}
}
queueUpdate("before")
deleted, err := ws.NewNotification(ws.ActionSessionMessageDeleted, map[string]any{
"session_id": "session-1", "message_id": "message-1",
})
if err != nil {
t.Fatalf("build delete: %v", err)
}
deletedData, _ := json.Marshal(deleted)
if !c.sendNotification(deletedData, deleted.Action) {
t.Fatal("semantic barrier was not accepted")
}
queueUpdate("after")
readAction := func() (string, string) {
t.Helper()
frame, ok := c.popNextReplaceable()
if !ok {
t.Fatal("expected scheduled frame")
}
var message ws.Message
if err := json.Unmarshal(frame.data, &message); err != nil {
t.Fatalf("decode scheduled frame: %v", err)
}
var payload struct {
Content string `json:"content"`
}
if len(message.Payload) > 0 {
if err := json.Unmarshal(message.Payload, &payload); err != nil {
t.Fatalf("decode scheduled payload: %v", err)
}
}
return message.Action, payload.Content
}
if action, content := readAction(); action != ws.ActionSessionMessageUpdated || content != "before" {
t.Fatalf("first scheduled frame = (%q, %q), want before update", action, content)
}
if action, _ := readAction(); action != ws.ActionSessionMessageDeleted {
t.Fatalf("second scheduled frame = %q, want delete barrier", action)
}
if action, content := readAction(); action != ws.ActionSessionMessageUpdated || content != "after" {
t.Fatalf("third scheduled frame = (%q, %q), want after update", action, content)
}
}
func TestClient_SemanticDropsAreCounted(t *testing.T) {
c := newTestClient("semantic-drop-count")
c.send = make(chan []byte, 1)
c.send <- []byte("occupied")
if c.sendNotification([]byte(`{"type":"notification","action":"session.state_updated"}`), "session.state_updated") {
t.Fatal("semantic frame should be rejected when the queue is full")
}
if got := c.notificationQueueStats().DroppedSemantic; got != 1 {
t.Fatalf("dropped semantic count = %d, want 1", got)
}
}
func TestClient_ReplaceableQueueCloseAndZeroValueAreSafe(t *testing.T) {
var zero Client
frame := []byte(`{"type":"notification","action":"session.message.updated","payload":{"session_id":"session-1","message_id":"message-1"}}`)
if !zero.sendBytes(frame) {
t.Fatal("zero-value client should accept replaceable traffic")
}
if _, ok := zero.popNextReplaceable(); !ok {
t.Fatal("zero-value replaceable traffic was not drainable")
}
if zero.sendBytes([]byte("semantic")) {
t.Fatal("zero-value client should reject semantic traffic without a channel")
}
c := newTestClient("replaceable-close")
if !c.sendBytes(frame) {
t.Fatal("replaceable frame was not accepted before close")
}
c.closeSend()
if c.sendBytes(frame) {
t.Fatal("closed client accepted replaceable traffic")
}
if _, ok := c.popNextReplaceable(); !ok {
t.Fatal("queued replaceable frame was lost during close drain")
}
}