From 0e39598aa610ae452c116c24a42fba917681ab5f Mon Sep 17 00:00:00 2001 From: rdev-worker Date: Fri, 6 Feb 2026 09:09:52 +0000 Subject: [PATCH] build: /implement-feature websocket-chat --requirements 'GET /ws upgrades to... --- .sdlc/branches/feature/websocket-chat.yaml | 4 + .sdlc/config.yaml | 36 ++++ .sdlc/features/websocket-chat/design.md | 52 ++++++ .sdlc/features/websocket-chat/manifest.yaml | 50 ++++++ .sdlc/features/websocket-chat/qa-plan.md | 32 ++++ .sdlc/features/websocket-chat/spec.md | 30 ++++ .sdlc/features/websocket-chat/tasks.md | 29 ++++ .sdlc/state.yaml | 63 +++++++ pkg/realtime/handler.go | 4 +- services/chat-api/.env.example | 3 + services/chat-api/cmd/server/main.go | 44 ++++- services/chat-api/internal/api/routes.go | 7 +- services/chat-api/internal/api/spec.go | 23 ++- services/chat-api/internal/api/ws_test.go | 178 ++++++++++++++++++++ services/chat-api/internal/config/config.go | 5 + 15 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 .sdlc/branches/feature/websocket-chat.yaml create mode 100644 .sdlc/config.yaml create mode 100644 .sdlc/features/websocket-chat/design.md create mode 100644 .sdlc/features/websocket-chat/manifest.yaml create mode 100644 .sdlc/features/websocket-chat/qa-plan.md create mode 100644 .sdlc/features/websocket-chat/spec.md create mode 100644 .sdlc/features/websocket-chat/tasks.md create mode 100644 .sdlc/state.yaml create mode 100644 services/chat-api/internal/api/ws_test.go diff --git a/.sdlc/branches/feature/websocket-chat.yaml b/.sdlc/branches/feature/websocket-chat.yaml new file mode 100644 index 0000000..77e7cb9 --- /dev/null +++ b/.sdlc/branches/feature/websocket-chat.yaml @@ -0,0 +1,4 @@ +name: feature/websocket-chat +feature: websocket-chat +base_branch: main +created_at: 2026-02-06T09:05:35.974642783Z diff --git a/.sdlc/config.yaml b/.sdlc/config.yaml new file mode 100644 index 0000000..0d9e993 --- /dev/null +++ b/.sdlc/config.yaml @@ -0,0 +1,36 @@ +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 diff --git a/.sdlc/features/websocket-chat/design.md b/.sdlc/features/websocket-chat/design.md new file mode 100644 index 0000000..f9e4351 --- /dev/null +++ b/.sdlc/features/websocket-chat/design.md @@ -0,0 +1,52 @@ +# 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) diff --git a/.sdlc/features/websocket-chat/manifest.yaml b/.sdlc/features/websocket-chat/manifest.yaml new file mode 100644 index 0000000..09c6b59 --- /dev/null +++ b/.sdlc/features/websocket-chat/manifest.yaml @@ -0,0 +1,50 @@ +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 diff --git a/.sdlc/features/websocket-chat/qa-plan.md b/.sdlc/features/websocket-chat/qa-plan.md new file mode 100644 index 0000000..ce906ab --- /dev/null +++ b/.sdlc/features/websocket-chat/qa-plan.md @@ -0,0 +1,32 @@ +# 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 diff --git a/.sdlc/features/websocket-chat/spec.md b/.sdlc/features/websocket-chat/spec.md new file mode 100644 index 0000000..3f6aa28 --- /dev/null +++ b/.sdlc/features/websocket-chat/spec.md @@ -0,0 +1,30 @@ +# 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 diff --git a/.sdlc/features/websocket-chat/tasks.md b/.sdlc/features/websocket-chat/tasks.md new file mode 100644 index 0000000..92301b7 --- /dev/null +++ b/.sdlc/features/websocket-chat/tasks.md @@ -0,0 +1,29 @@ +# 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. diff --git a/.sdlc/state.yaml b/.sdlc/state.yaml new file mode 100644 index 0000000..9485635 --- /dev/null +++ b/.sdlc/state.yaml @@ -0,0 +1,63 @@ +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 diff --git a/pkg/realtime/handler.go b/pkg/realtime/handler.go index 485d24a..cea0739 100644 --- a/pkg/realtime/handler.go +++ b/pkg/realtime/handler.go @@ -68,8 +68,8 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) { // Extract user from auth context var userID string - if claims := auth.ClaimsFromContext(r.Context()); claims != nil { - userID = claims.Subject + if user := auth.GetUser(r.Context()); user != nil { + userID = user.ID } // Check auth requirement diff --git a/services/chat-api/.env.example b/services/chat-api/.env.example index b403baa..0775a21 100644 --- a/services/chat-api/.env.example +++ b/services/chat-api/.env.example @@ -19,3 +19,6 @@ 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 diff --git a/services/chat-api/cmd/server/main.go b/services/chat-api/cmd/server/main.go index 63b8f9c..aafc074 100644 --- a/services/chat-api/cmd/server/main.go +++ b/services/chat-api/cmd/server/main.go @@ -2,16 +2,23 @@ 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() @@ -22,8 +29,43 @@ 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) + api.RegisterRoutes(application, exampleService, wsHandler) // Start server application.Run() diff --git a/services/chat-api/internal/api/routes.go b/services/chat-api/internal/api/routes.go index ed0e870..cf74443 100644 --- a/services/chat-api/internal/api/routes.go +++ b/services/chat-api/internal/api/routes.go @@ -4,6 +4,7 @@ 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" @@ -14,7 +15,8 @@ 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 -func RegisterRoutes(application *app.App, exampleService *service.ExampleService) { +// - https://domain/api/chat-api/ws (WebSocket) +func RegisterRoutes(application *app.App, exampleService *service.ExampleService, wsHandler *realtime.Handler) { logger := application.Logger() cfg := config.Load() @@ -35,6 +37,9 @@ 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 { diff --git a/services/chat-api/internal/api/spec.go b/services/chat-api/internal/api/spec.go index 5c4db8b..5546802 100644 --- a/services/chat-api/internal/api/spec.go +++ b/services/chat-api/internal/api/spec.go @@ -8,7 +8,8 @@ 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("Examples", "Example CRUD endpoints"). + WithTag("WebSocket", "Real-time WebSocket endpoints") // Define reusable schemas spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{ @@ -108,5 +109,25 @@ 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 } diff --git a/services/chat-api/internal/api/ws_test.go b/services/chat-api/internal/api/ws_test.go new file mode 100644 index 0000000..1541646 --- /dev/null +++ b/services/chat-api/internal/api/ws_test.go @@ -0,0 +1,178 @@ +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") + } +} diff --git a/services/chat-api/internal/config/config.go b/services/chat-api/internal/config/config.go index 6c09a2e..810bb09 100644 --- a/services/chat-api/internal/config/config.go +++ b/services/chat-api/internal/config/config.go @@ -18,6 +18,9 @@ type Config struct { // Auth AuthEnabled bool JWTSecret string + + // Redis + RedisURL string } // Load reads configuration from environment variables. @@ -30,5 +33,7 @@ func Load() *Config { AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"), JWTSecret: os.Getenv("JWT_SECRET"), + + RedisURL: os.Getenv("REDIS_URL"), } }