Compare commits
No commits in common. "feature/websocket-chat" and "main" have entirely different histories.
feature/we
...
main
@ -1,4 +0,0 @@
|
||||
name: feature/websocket-chat
|
||||
feature: websocket-chat
|
||||
base_branch: main
|
||||
created_at: 2026-02-06T09:05:35.974642783Z
|
||||
@ -1,36 +0,0 @@
|
||||
version: 1
|
||||
project:
|
||||
name: workspace
|
||||
branches:
|
||||
main: main
|
||||
feature_prefix: feature/
|
||||
phases:
|
||||
enabled:
|
||||
- draft
|
||||
- specified
|
||||
- planned
|
||||
- ready
|
||||
- implementation
|
||||
- review
|
||||
- audit
|
||||
- qa
|
||||
- merge
|
||||
- released
|
||||
required_artifacts:
|
||||
audit:
|
||||
- audit
|
||||
planned:
|
||||
- spec
|
||||
- design
|
||||
- tasks
|
||||
- qa_plan
|
||||
qa:
|
||||
- qa_results
|
||||
review:
|
||||
- review
|
||||
specified:
|
||||
- spec
|
||||
compliance:
|
||||
require_approvals: true
|
||||
require_branch: true
|
||||
require_qa: true
|
||||
@ -1,52 +0,0 @@
|
||||
# WebSocket Chat - Technical Design
|
||||
|
||||
## Architecture
|
||||
|
||||
Leverages the existing `pkg/realtime` package which provides:
|
||||
- `LocalHub`: In-memory connection registry with room support
|
||||
- `WSClient`: WebSocket connection with heartbeat (ping/pong)
|
||||
- `Handler`: HTTP upgrade and connection lifecycle management
|
||||
- `RedisBroadcaster`: Cross-pod message distribution via Redis Pub/Sub
|
||||
|
||||
### Message Flow
|
||||
|
||||
```
|
||||
Client → WebSocket → WSClient.readPump → Handler.OnMessage callback
|
||||
→ RedisBroadcaster.Publish → Redis Pub/Sub channel
|
||||
→ RedisBroadcaster.Run (subscriber) → LocalHub.Broadcast
|
||||
→ WSClient.writePump → All connected WebSocket clients
|
||||
```
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. Configuration (`internal/config/config.go`)
|
||||
|
||||
Add `RedisURL` field to Config struct, loaded from `REDIS_URL` environment variable.
|
||||
|
||||
### 2. Main Entry Point (`cmd/server/main.go`)
|
||||
|
||||
- Create Redis client from `REDIS_URL`
|
||||
- Create `LocalHub` and start its event loop
|
||||
- Create `RedisBroadcaster` and start its subscriber loop
|
||||
- Pass hub and broadcaster to `RegisterRoutes`
|
||||
- Register shutdown hooks for hub cancellation and Redis client cleanup
|
||||
|
||||
### 3. Route Registration (`internal/api/routes.go`)
|
||||
|
||||
- Accept `realtime.Handler` as a parameter
|
||||
- Mount WebSocket handler routes at `/api/chat-api/ws`
|
||||
|
||||
### 4. OpenAPI Spec (`internal/api/spec.go`)
|
||||
|
||||
- Add WebSocket tag and endpoint documentation
|
||||
|
||||
### 5. Tests
|
||||
|
||||
- Unit test for WebSocket handler wiring
|
||||
- Integration test for WebSocket connection upgrade and message echo
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `pkg/realtime` (already exists, no changes needed)
|
||||
- `github.com/redis/go-redis/v9` (already in pkg/go.mod)
|
||||
- `github.com/gorilla/websocket` (already in pkg/go.mod)
|
||||
@ -1,50 +0,0 @@
|
||||
slug: websocket-chat
|
||||
title: WebSocket Chat
|
||||
created: 2026-02-06T09:03:57.042968245Z
|
||||
branch: feature/websocket-chat
|
||||
phase: implementation
|
||||
phase_history:
|
||||
- phase: draft
|
||||
entered: 2026-02-06T09:03:57.042968245Z
|
||||
exited: 2026-02-06T09:05:15.971847347Z
|
||||
- phase: specified
|
||||
entered: 2026-02-06T09:05:15.971847347Z
|
||||
exited: 2026-02-06T09:05:32.230363629Z
|
||||
- phase: planned
|
||||
entered: 2026-02-06T09:05:32.230363629Z
|
||||
exited: 2026-02-06T09:05:39.185157676Z
|
||||
- phase: ready
|
||||
entered: 2026-02-06T09:05:39.185157676Z
|
||||
exited: 2026-02-06T09:05:39.189335599Z
|
||||
- phase: implementation
|
||||
entered: 2026-02-06T09:05:39.189335599Z
|
||||
artifacts:
|
||||
audit:
|
||||
status: pending
|
||||
path: audit.md
|
||||
design:
|
||||
status: approved
|
||||
path: design.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-06T09:04:55.897812432Z
|
||||
qa_plan:
|
||||
status: approved
|
||||
path: qa-plan.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-06T09:05:28.161281152Z
|
||||
qa_results:
|
||||
status: pending
|
||||
path: qa-results.md
|
||||
review:
|
||||
status: pending
|
||||
path: review.md
|
||||
spec:
|
||||
status: approved
|
||||
path: spec.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-06T09:04:55.893238485Z
|
||||
tasks:
|
||||
status: approved
|
||||
path: tasks.md
|
||||
approved_by: user
|
||||
approved_at: 2026-02-06T09:04:55.902193928Z
|
||||
@ -1,32 +0,0 @@
|
||||
# WebSocket Chat - QA Plan
|
||||
|
||||
## Test Cases
|
||||
|
||||
### TC-1: WebSocket Connection Upgrade
|
||||
- Connect to `GET /api/chat-api/ws`
|
||||
- Verify HTTP 101 Switching Protocols
|
||||
- Verify WebSocket connection is established
|
||||
|
||||
### TC-2: Message Send and Broadcast
|
||||
- Connect two clients to the same room
|
||||
- Client A sends a chat message
|
||||
- Verify Client B receives the message via broadcast
|
||||
|
||||
### TC-3: Redis Pub/Sub Integration
|
||||
- Connect a client
|
||||
- Send a message
|
||||
- Verify message is published to Redis channel
|
||||
- Verify subscriber broadcasts to all clients
|
||||
|
||||
### TC-4: Room-Based Messaging
|
||||
- Connect Client A to room "general"
|
||||
- Connect Client B to room "general"
|
||||
- Connect Client C to room "other"
|
||||
- Client A sends message to "general"
|
||||
- Verify Client B receives it
|
||||
- Verify Client C does not receive it
|
||||
|
||||
### TC-5: Graceful Shutdown
|
||||
- Connect a client
|
||||
- Send SIGTERM to server
|
||||
- Verify connection is closed cleanly
|
||||
@ -1,30 +0,0 @@
|
||||
# WebSocket Chat - Feature Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Add WebSocket support to the chat-api service. `GET /ws` upgrades to a WebSocket connection. Incoming messages are published to a Redis Pub/Sub channel. A Redis subscriber broadcasts received messages to all connected WebSocket clients.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **WebSocket Endpoint**: `GET /api/chat-api/ws` upgrades HTTP connections to WebSocket
|
||||
2. **Message Publishing**: Incoming WebSocket messages are published to Redis Pub/Sub channels
|
||||
3. **Message Broadcasting**: A Redis subscriber receives messages and broadcasts them to all connected WebSocket clients
|
||||
4. **Room Support**: Optional room-based messaging via `GET /api/chat-api/ws/{room}` or `?room=` query parameter
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. Uses existing `pkg/realtime` package (Hub, WSClient, RedisBroadcaster)
|
||||
2. Follows existing chat-api handler and routing patterns
|
||||
3. Redis connection configured via `REDIS_URL` environment variable
|
||||
4. Graceful shutdown of hub and Redis broadcaster on SIGTERM
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `GET /api/chat-api/ws` upgrades to WebSocket
|
||||
- [ ] Messages sent by a client are published to Redis
|
||||
- [ ] Messages from Redis are broadcast to all connected clients
|
||||
- [ ] Hub and Redis broadcaster shut down gracefully
|
||||
- [ ] Configuration via `REDIS_URL` environment variable
|
||||
- [ ] OpenAPI spec updated with WebSocket endpoint documentation
|
||||
@ -1,29 +0,0 @@
|
||||
# WebSocket Chat - Implementation Tasks
|
||||
|
||||
## Task 1: Add Redis configuration
|
||||
- **ID**: ws-1-config
|
||||
- **Status**: pending
|
||||
- **Blocked By**: none
|
||||
- **Files**: `services/chat-api/internal/config/config.go`, `services/chat-api/.env.example`
|
||||
- **Scope**: Add `RedisURL` field to Config struct. Add `REDIS_URL` to .env.example.
|
||||
|
||||
## Task 2: Wire up Hub, Redis broadcaster, and WebSocket handler in main.go
|
||||
- **ID**: ws-2-main-wiring
|
||||
- **Status**: pending
|
||||
- **Blocked By**: ws-1-config
|
||||
- **Files**: `services/chat-api/cmd/server/main.go`
|
||||
- **Scope**: Create Redis client, LocalHub, RedisBroadcaster, and realtime.Handler. Start hub and broadcaster goroutines. Pass handler to RegisterRoutes. Register shutdown hooks.
|
||||
|
||||
## Task 3: Mount WebSocket routes
|
||||
- **ID**: ws-3-routes
|
||||
- **Status**: pending
|
||||
- **Blocked By**: ws-2-main-wiring
|
||||
- **Files**: `services/chat-api/internal/api/routes.go`, `services/chat-api/internal/api/spec.go`
|
||||
- **Scope**: Update RegisterRoutes to accept realtime.Handler. Mount WebSocket handler at `/api/chat-api/ws`. Add WebSocket endpoint to OpenAPI spec.
|
||||
|
||||
## Task 4: Add WebSocket handler test
|
||||
- **ID**: ws-4-tests
|
||||
- **Status**: pending
|
||||
- **Blocked By**: ws-3-routes
|
||||
- **Files**: `services/chat-api/internal/api/handlers/ws_test.go`
|
||||
- **Scope**: Test WebSocket upgrade, message send/receive, and hub broadcast.
|
||||
@ -1,63 +0,0 @@
|
||||
version: 1
|
||||
project:
|
||||
name: workspace
|
||||
active_work:
|
||||
features:
|
||||
- slug: websocket-chat
|
||||
branch: feature/websocket-chat
|
||||
phase: implementation
|
||||
blocked: []
|
||||
last_updated: 2026-02-06T09:05:39.190068428Z
|
||||
last_action: TRANSITION
|
||||
last_actor: cli
|
||||
history:
|
||||
- timestamp: 2026-02-06T09:03:57.04327906Z
|
||||
action: CREATE_FEATURE
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:04:55.893663644Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: websocket-chat
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:04:55.898271126Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: websocket-chat
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:04:55.902590295Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: websocket-chat
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:15.972296783Z
|
||||
action: TRANSITION
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:28.16183808Z
|
||||
action: APPROVE_ARTIFACT
|
||||
feature: websocket-chat
|
||||
actor: user
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:32.230960612Z
|
||||
action: TRANSITION
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:35.978710478Z
|
||||
action: CREATE_BRANCH
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:39.185695108Z
|
||||
action: TRANSITION
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
- timestamp: 2026-02-06T09:05:39.190066334Z
|
||||
action: TRANSITION
|
||||
feature: websocket-chat
|
||||
actor: cli
|
||||
result: success
|
||||
@ -68,8 +68,8 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Extract user from auth context
|
||||
var userID string
|
||||
if user := auth.GetUser(r.Context()); user != nil {
|
||||
userID = user.ID
|
||||
if claims := auth.ClaimsFromContext(r.Context()); claims != nil {
|
||||
userID = claims.Subject
|
||||
}
|
||||
|
||||
// Check auth requirement
|
||||
|
||||
@ -19,6 +19,3 @@ JWT_SECRET=dev-secret-change-in-production
|
||||
|
||||
# Database (if needed)
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/sp3-test-1770368381?sslmode=disable
|
||||
|
||||
# Redis (required for WebSocket chat broadcasting)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
@ -2,23 +2,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/app"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/api"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/config"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
cfg := config.Load()
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
@ -29,43 +22,8 @@ func main() {
|
||||
// Create application
|
||||
application := app.New("chat-api", app.WithDefaultPort(8001))
|
||||
|
||||
// Set up WebSocket hub and Redis broadcaster
|
||||
hub := realtime.NewHub(logger)
|
||||
hubCtx, hubCancel := context.WithCancel(context.Background())
|
||||
go hub.Run(hubCtx)
|
||||
|
||||
var broadcaster realtime.Broadcaster
|
||||
if cfg.RedisURL != "" {
|
||||
opts, err := redis.ParseURL(cfg.RedisURL)
|
||||
if err != nil {
|
||||
logger.Error("invalid REDIS_URL", "error", err)
|
||||
} else {
|
||||
redisClient := redis.NewClient(opts)
|
||||
redisBroadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
|
||||
go func() {
|
||||
if err := redisBroadcaster.Run(hubCtx); err != nil && hubCtx.Err() == nil {
|
||||
logger.Error("redis broadcaster error", "error", err)
|
||||
}
|
||||
}()
|
||||
broadcaster = redisBroadcaster
|
||||
|
||||
application.OnShutdown(func(_ context.Context) error {
|
||||
return redisClient.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
||||
Broadcaster: broadcaster,
|
||||
})
|
||||
|
||||
application.OnShutdown(func(_ context.Context) error {
|
||||
hubCancel()
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService, wsHandler)
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
|
||||
@ -4,7 +4,6 @@ package api
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/app"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/config"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
|
||||
@ -15,8 +14,7 @@ import (
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/chat-api/health
|
||||
// - https://domain/api/chat-api/examples
|
||||
// - https://domain/api/chat-api/ws (WebSocket)
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService, wsHandler *realtime.Handler) {
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
@ -37,9 +35,6 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
// WebSocket endpoint (upgrades to WebSocket connection)
|
||||
r.Mount("/ws", wsHandler.Routes())
|
||||
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
|
||||
@ -8,8 +8,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
WithDescription("REST API for the chat-api service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints").
|
||||
WithTag("WebSocket", "Real-time WebSocket endpoints")
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
||||
@ -109,25 +108,5 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// WebSocket
|
||||
spec.AddPath("/api/chat-api/ws", "get", map[string]any{
|
||||
"summary": "WebSocket connection",
|
||||
"description": "Upgrades to WebSocket. Incoming messages are published to Redis and broadcast to all connected clients. Optionally specify a room via /ws/{room} or ?room= query parameter.",
|
||||
"tags": []string{"WebSocket"},
|
||||
"parameters": []any{
|
||||
map[string]any{
|
||||
"name": "room",
|
||||
"in": "query",
|
||||
"description": "Optional room name for scoped messaging",
|
||||
"schema": openapi.String(),
|
||||
},
|
||||
},
|
||||
"responses": map[string]any{
|
||||
"101": map[string]any{
|
||||
"description": "Switching Protocols - WebSocket connection established",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
|
||||
)
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
logger := logging.Nop()
|
||||
|
||||
hub := realtime.NewHub(logger)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go hub.Run(ctx)
|
||||
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/api/chat-api/ws", wsHandler.Routes())
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to websocket: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if resp.StatusCode != 101 {
|
||||
t.Errorf("expected status 101, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketBroadcast(t *testing.T) {
|
||||
logger := logging.Nop()
|
||||
|
||||
hub := realtime.NewHub(logger)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go hub.Run(ctx)
|
||||
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/api/chat-api/ws", wsHandler.Routes())
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
|
||||
|
||||
// Connect two clients
|
||||
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client 1 failed to connect: %v", err)
|
||||
}
|
||||
defer conn1.Close()
|
||||
|
||||
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client 2 failed to connect: %v", err)
|
||||
}
|
||||
defer conn2.Close()
|
||||
|
||||
// Wait for both clients to register with the hub
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Client 1 sends a message
|
||||
msg := realtime.Message{
|
||||
Type: "chat",
|
||||
Data: json.RawMessage(`"hello from client 1"`),
|
||||
}
|
||||
if err := conn1.WriteJSON(msg); err != nil {
|
||||
t.Fatalf("client 1 failed to send: %v", err)
|
||||
}
|
||||
|
||||
// Client 2 should receive the broadcast
|
||||
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var received realtime.Message
|
||||
if err := conn2.ReadJSON(&received); err != nil {
|
||||
t.Fatalf("client 2 failed to receive: %v", err)
|
||||
}
|
||||
|
||||
if received.Type != "chat" {
|
||||
t.Errorf("expected message type 'chat', got %q", received.Type)
|
||||
}
|
||||
|
||||
var data string
|
||||
if err := json.Unmarshal(received.Data, &data); err != nil {
|
||||
t.Fatalf("failed to unmarshal data: %v", err)
|
||||
}
|
||||
if data != "hello from client 1" {
|
||||
t.Errorf("expected 'hello from client 1', got %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketRoomBroadcast(t *testing.T) {
|
||||
logger := logging.Nop()
|
||||
|
||||
hub := realtime.NewHub(logger)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go hub.Run(ctx)
|
||||
|
||||
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/api/chat-api/ws", wsHandler.Routes())
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
defer server.Close()
|
||||
|
||||
baseURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
|
||||
|
||||
// Connect client A to room "general"
|
||||
connA, _, err := websocket.DefaultDialer.Dial(baseURL+"/general", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client A failed to connect: %v", err)
|
||||
}
|
||||
defer connA.Close()
|
||||
|
||||
// Connect client B to room "general"
|
||||
connB, _, err := websocket.DefaultDialer.Dial(baseURL+"/general", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client B failed to connect: %v", err)
|
||||
}
|
||||
defer connB.Close()
|
||||
|
||||
// Connect client C to room "other"
|
||||
connC, _, err := websocket.DefaultDialer.Dial(baseURL+"/other", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("client C failed to connect: %v", err)
|
||||
}
|
||||
defer connC.Close()
|
||||
|
||||
// Wait for all clients to register
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Client A sends a message (room defaults to "general")
|
||||
msg := realtime.Message{
|
||||
Type: "chat",
|
||||
Data: json.RawMessage(`"room message"`),
|
||||
}
|
||||
if err := connA.WriteJSON(msg); err != nil {
|
||||
t.Fatalf("client A failed to send: %v", err)
|
||||
}
|
||||
|
||||
// Client B should receive it (same room)
|
||||
connB.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
var received realtime.Message
|
||||
if err := connB.ReadJSON(&received); err != nil {
|
||||
t.Fatalf("client B failed to receive: %v", err)
|
||||
}
|
||||
if received.Type != "chat" {
|
||||
t.Errorf("expected type 'chat', got %q", received.Type)
|
||||
}
|
||||
|
||||
// Client C should NOT receive it (different room)
|
||||
connC.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
var unexpected realtime.Message
|
||||
err = connC.ReadJSON(&unexpected)
|
||||
if err == nil {
|
||||
t.Error("client C should not have received message from 'general' room")
|
||||
}
|
||||
}
|
||||
@ -18,9 +18,6 @@ type Config struct {
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
|
||||
// Redis
|
||||
RedisURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@ -33,7 +30,5 @@ func Load() *Config {
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user