diff --git a/docs/client-api.md b/docs/client-api.md index d5fa10f04..58bb2d7a3 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -261,7 +261,7 @@ The creator's account and the site come from the subject (`chat.user.{account}.r See [Error envelope](#6-error-envelope-reference). Returned synchronously on validation/authorization failure: -- `"missing X-Request-ID header"` / `"invalid X-Request-ID format"` +- `"X-Request-ID header is required …"` (`bad_request`, reason `request_id_required`) — the `X-Request-ID` header is absent or not a valid hyphenated UUID - `"request must include at least one of users, orgs, channels, or name"` - `"channel name is required"` / `"channel name must be at most 100 characters"` - `"cannot create a DM with yourself"` @@ -619,9 +619,8 @@ See [Error envelope](#6-error-envelope-reference). Returned synchronously when v - `"room not found"` — no room matches the subject `{roomID}`. - `"rename is only allowed in channel rooms"` — the room is a DM, botDM, or discussion. - `"only owners or platform admins can rename a channel"` — the requester is not a platform admin and does not hold the `owner` role in the room. -- `"invalid request"` — body is malformed. -- `"missing X-Request-ID header"` — the NATS header is absent. -- `"invalid X-Request-ID format"` — the header value is not a valid hyphenated UUID. +- `"invalid request payload"` — body is malformed (returned as `bad_request` by the router; previously surfaced as an internal error). +- `"X-Request-ID header is required …"` — `bad_request` (reason `request_id_required`); the `X-Request-ID` header is absent or not a valid hyphenated UUID. ```json { "error": "rename is only allowed in channel rooms" } @@ -2996,6 +2995,8 @@ Every error response — NATS reply subjects, JetStream async results, and HTTP | `unavailable` | 503 | Transient server saturation/timeout (admission, expand timeout). | | `internal` | 500 | Unclassified server-side fault. The real cause is logged server-side only and never sent to the client. | +> **Malformed request bodies.** Any room request/reply RPC whose payload is not valid JSON for its schema is rejected uniformly with `code: bad_request` and the message `"invalid request payload"` — the transport layer rejects it before the handler runs. Treat this as a generic encoding error; do not pattern-match the message text. + ### `reason` catalog (present today) | `reason` | Typical `code` | Emitted by | diff --git a/docs/superpowers/plans/2026-06-04-room-service-natsrouter-migration.md b/docs/superpowers/plans/2026-06-04-room-service-natsrouter-migration.md new file mode 100644 index 000000000..f4167c35a --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-room-service-natsrouter-migration.md @@ -0,0 +1,1026 @@ +# room-service (+ room-worker RPC) natsrouter Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move all 20 room-service request/reply RPCs (including `member.statuses` and `subscription.mentionable`) and room-worker's one RPC (`natsServerCreateDM`) off raw `nc.QueueSubscribe` + hand-written wrappers onto `pkg/natsrouter`, gaining per-message concurrency and centralized marshal/error/recovery/request-ID/logging, with no wire changes. + +**Architecture:** Add a strict `RequireRequestID()` middleware and `{name}` `*Pattern` subject builders, convert each handler core from `handleXxx(ctx, subj, data)` to a typed `xxx(c *natsrouter.Context, req)` (or `xxx(c)` for body-less), register them via `natsrouter.Register`/`RegisterNoBody`, then cut `main.go` from `RegisterCRUD` to a router. Subjects, request/response JSON, and error envelopes are preserved. + +**Tech Stack:** Go 1.25, `pkg/natsrouter`, `pkg/subject`, `pkg/model`, `pkg/natsutil`, `pkg/errcode`, `go.uber.org/mock`, `testify`. + +**Spec:** `docs/superpowers/specs/2026-06-04-room-service-natsrouter-migration-design.md` + +--- + +## Conventions used throughout + +- Run a single test: `go test ./pkg/natsrouter/ -run TestName -race -count=1` +- Run a service's unit tests: `make test SERVICE=room-service` +- Run a package's tests: `go test ./pkg/subject/ -race` (Makefile wraps `-race`; raw `go test` is fine for a single package during TDD). +- Commit after every green step. Branch is already `claude/zen-brown-Atb7v`. +- **Keep `make test` green at every commit.** Integration tests (`//go:build integration`) only go fully green after the `main.go` cutover (Task 11) — that is expected and called out there. + +## File structure + +| File | Responsibility | Tasks | +|------|----------------|-------| +| `pkg/natsrouter/middleware.go` | Add `RequireRequestID()` | 1 | +| `pkg/natsrouter/middleware_test.go` | Tests for `RequireRequestID()` | 1 | +| `pkg/subject/subject.go` | Add 17 `*Pattern` builders | 2 | +| `pkg/subject/subject_test.go` | Pattern↔Wildcard equivalence tests | 2 | +| `pkg/model/event.go` | Add `StatusReply`, `StatusWithRequestReply`, `RoomRenameRequest` | 3 | +| `pkg/model/model_test.go` | Round-trip tests for the new types | 3 | +| `room-service/handler.go` | Convert 20 handlers; add `Register`; delete `RegisterCRUD`/wrappers/`wrappedCtx` | 4–11 | +| `room-service/handler_test.go` | Convert handler tests; delete dead tests | 4–11 | +| `room-service/main.go` | Router wiring + shutdown | 11 | +| `room-worker/handler.go` | Convert `natsServerCreateDM`→`serverCreateDM`; delete `requireDedupRequestID` | 12 | +| `room-worker/handler_test.go` | Convert sync-DM tests | 12 | +| `room-worker/main.go` | Router for the one RPC + shutdown | 12 | +| `docs/client-api.md` | Update rename malformed-body error | 13 | + +--- + +## Phase 1 — Foundation (no room-service changes yet) + +### Task 1: `RequireRequestID()` middleware + +**Files:** +- Modify: `pkg/natsrouter/middleware.go` +- Test: `pkg/natsrouter/middleware_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `pkg/natsrouter/middleware_test.go` (add `"github.com/nats-io/nats.go"` and `"github.com/hmchangw/chat/pkg/natsutil"` to imports): + +```go +func TestRequireRequestID_ValidPasses(t *testing.T) { + const id = "01970a4f-8c2d-7c9a-abcd-e0123456789f" + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{natsutil.RequestIDHeader: []string{id}}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + require.True(t, ran, "handler must run when request ID is a valid UUID") + got, ok := c.Get(requestIDKey) + require.True(t, ok) + assert.Equal(t, id, got) + assert.Equal(t, id, natsutil.RequestIDFromContext(c)) +} + +func TestRequireRequestID_MissingAborts(t *testing.T) { + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + assert.False(t, ran, "handler must NOT run when request ID is missing") + assert.True(t, c.IsAborted()) +} + +func TestRequireRequestID_InvalidAborts(t *testing.T) { + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{natsutil.RequestIDHeader: []string{"not-a-uuid"}}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + assert.False(t, ran, "handler must NOT run when request ID is malformed") + assert.True(t, c.IsAborted()) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/natsrouter/ -run TestRequireRequestID -race -count=1` +Expected: FAIL — `undefined: RequireRequestID`. + +- [ ] **Step 3: Implement the middleware** + +Add to `pkg/natsrouter/middleware.go` directly below `RequestID()` (the file already imports `nats`, `errcode`, `errnats`, `natsutil`): + +```go +// RequireRequestID is the strict variant of RequestID: a missing/non-UUID +// X-Request-ID is rejected (BadRequest, reason RequestIDRequired) and aborts; never mints. +func RequireRequestID() HandlerFunc { + return func(c *Context) { + var ( + headers nats.Header + subj string + ) + if c.Msg != nil { + headers = c.Msg.Header + subj = c.Msg.Subject + } + ctx, id, err := natsutil.RequireRequestID(c.ctx, headers, subj) + if err != nil { + // c.Msg is set in production; guard the nil-Msg unit-test context. + if c.Msg != nil { + errnats.Reply(c, c.Msg, err) + } + c.Abort() + return + } + c.Set(requestIDKey, id) + c.SetContext(ctx) + c.WithLogValues("request_id", id) + c.Next() + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/natsrouter/ -run TestRequireRequestID -race -count=1` +Expected: PASS (3 tests). + +- [ ] **Step 5: Lint + commit** + +```bash +make lint +git add pkg/natsrouter/middleware.go pkg/natsrouter/middleware_test.go +git commit -m "feat(natsrouter): add strict RequireRequestID middleware" +``` + +--- + +### Task 2: `*Pattern` subject builders + +**Files:** +- Modify: `pkg/subject/subject.go` +- Test: `pkg/subject/subject_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `pkg/subject/subject_test.go` (ensure `"strings"` and testify imports exist): + +```go +func TestRoomPatternsMatchWildcards(t *testing.T) { + const site = "site-a" + repl := strings.NewReplacer("{account}", "*", "{roomID}", "*", "{orgID}", "*") + cases := []struct{ name, pattern, wildcard string }{ + {"create", RoomCreatePattern(site), RoomCreateWildcard(site)}, + {"role-update", MemberRoleUpdatePattern(site), MemberRoleUpdateWildcard(site)}, + {"remove", MemberRemovePattern(site), MemberRemoveWildcard(site)}, + {"add", MemberAddPattern(site), MemberAddWildcard(site)}, + {"list", MemberListPattern(site), MemberListWildcard(site)}, + {"org-members", OrgMembersPattern(site), OrgMembersWildcard(site)}, + {"message-read", MessageReadPattern(site), MessageReadWildcard(site)}, + {"read-receipt", MessageReadReceiptPattern(site), MessageReadReceiptWildcard(site)}, + {"thread-read", MessageThreadReadPattern(site), MessageThreadReadWildcard(site)}, + {"key-get", RoomKeyGetPattern(site), RoomKeyGetWildcard(site)}, + {"mute", MuteTogglePattern(site), MuteToggleWildcard(site)}, + {"favorite", FavoriteTogglePattern(site), FavoriteToggleWildcard(site)}, + {"rename", RoomRenamePattern(site), RoomRenameWildcard(site)}, + {"app-tabs", RoomAppTabsPattern(site), RoomAppTabsWildcard(site)}, + {"app-cmd-menu", RoomAppCmdMenuPattern(site), RoomAppCmdMenuWildcard(site)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.wildcard, repl.Replace(tc.pattern), + "pattern with params replaced by * must equal the existing wildcard subscription subject") + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/subject/ -run TestRoomPatternsMatchWildcards -race -count=1` +Expected: FAIL — `undefined: RoomCreatePattern` (etc.). + +- [ ] **Step 3: Add the builders** + +Append to `pkg/subject/subject.go` (after the existing `--- natsrouter patterns ---` group, e.g. near line 524): + +```go +// --- room-service natsrouter pattern builders (siteID baked in) --- + +func RoomCreatePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.%s.create", siteID) +} + +func MemberRoleUpdatePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.role-update", siteID) +} + +func MemberRemovePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.remove", siteID) +} + +func MemberAddPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.add", siteID) +} + +func MemberListPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.list", siteID) +} + +func OrgMembersPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.orgs.{orgID}.%s.members", siteID) +} + +func MessageReadPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.read", siteID) +} + +func MessageReadReceiptPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.read-receipt", siteID) +} + +func MessageThreadReadPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.thread.read", siteID) +} + +func RoomKeyGetPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.key.get", siteID) +} + +func MuteTogglePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.mute.toggle", siteID) +} + +func FavoriteTogglePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.favorite.toggle", siteID) +} + +func RoomRenamePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.room.rename", siteID) +} + +func RoomAppTabsPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.app.tabs", siteID) +} + +func RoomAppCmdMenuPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.app.cmd-menu", siteID) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/subject/ -run TestRoomPatternsMatchWildcards -race -count=1` +Expected: PASS (17 subtests). + +- [ ] **Step 5: Lint + commit** + +```bash +make lint +git add pkg/subject/subject.go pkg/subject/subject_test.go +git commit -m "feat(subject): add room-service natsrouter pattern builders" +``` + +--- + +### Task 3: Typed status replies + rename request + +**Files:** +- Modify: `pkg/model/event.go` +- Test: `pkg/model/model_test.go` + +- [ ] **Step 1: Write the failing test** + +`pkg/model/model_test.go` uses a generic `roundTrip` helper. Append: + +```go +func TestStatusReply_RoundTrip(t *testing.T) { + roundTrip(t, model.StatusReply{Status: "ok"}) + roundTrip(t, model.StatusReply{Status: "accepted"}) +} + +func TestStatusWithRequestReply_RoundTrip(t *testing.T) { + roundTrip(t, model.StatusWithRequestReply{Status: "accepted", RequestID: "01970a4f-8c2d-7c9a-abcd-e0123456789f"}) +} + +func TestRoomRenameRequest_RoundTrip(t *testing.T) { + roundTrip(t, model.RoomRenameRequest{NewName: "New Name"}) +} + +func TestStatusReply_JSONShape(t *testing.T) { + b, err := json.Marshal(model.StatusReply{Status: "accepted"}) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"accepted"}`, string(b)) +} + +func TestStatusWithRequestReply_JSONShape(t *testing.T) { + b, err := json.Marshal(model.StatusWithRequestReply{Status: "ok", RequestID: "rid"}) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"ok","requestId":"rid"}`, string(b)) +} +``` + +(If `json`, `require`, or `assert` are not already imported in `model_test.go`, add them.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/model/ -run 'TestStatusReply|TestStatusWithRequestReply|TestRoomRenameRequest' -race -count=1` +Expected: FAIL — `undefined: model.StatusReply` (etc.). + +- [ ] **Step 3: Add the types** + +Append to `pkg/model/event.go`: + +```go +// StatusReply is the response for fire-and-forget RPCs that only confirm +// acceptance. Status is "ok" or "accepted" depending on the endpoint. +type StatusReply struct { + Status string `json:"status"` +} + +// StatusWithRequestReply is StatusReply plus the echoed request ID, for RPCs +// whose clients correlate the async result by request ID (rename, restricted). +type StatusWithRequestReply struct { + Status string `json:"status"` + RequestID string `json:"requestId"` +} + +// RoomRenameRequest is the rename RPC body. NewName-only: roomID is taken from +// the subject, never the body. +type RoomRenameRequest struct { + NewName string `json:"newName"` +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/model/ -run 'TestStatusReply|TestStatusWithRequestReply|TestRoomRenameRequest' -race -count=1` +Expected: PASS. + +- [ ] **Step 5: Lint + commit** + +```bash +make lint +git add pkg/model/event.go pkg/model/model_test.go +git commit -m "feat(model): add StatusReply, StatusWithRequestReply, RoomRenameRequest" +``` + +--- + +## Phase 2 — room-service handler conversion + +### Conversion recipe (read once, applied per handler in Tasks 4–10) + +Every handler today has a thin wrapper `natsXxx(m otelnats.Msg)` and a core `handleXxx(ctx, subj, data)`. The conversion **deletes the wrapper** and **reshapes the core** into the typed handler natsrouter calls. + +**Three flavors:** + +**(A) Body handler → `Register`** (e.g. role-update, remove, add, read-receipt, thread-read, rename, restricted, rooms-info-batch, ensure-key, create): + +Before: +```go +func (h *Handler) natsUpdateRole(m otelnats.Msg) { + ctx, err := wrappedCtx(m) + if err != nil { errnats.Reply(ctx, m.Msg, err); return } + resp, err := h.handleUpdateRole(ctx, m.Msg.Subject, m.Msg.Data) + if err != nil { errnats.Reply(ctx, m.Msg, err); return } + natsutil.ReplyJSON(m.Msg, resp) +} + +func (h *Handler) handleUpdateRole(ctx context.Context, subj string, data []byte) ([]byte, error) { + account, roomID, ok := subject.ParseUserRoomSubject(subj) + if !ok { return nil, fmt.Errorf("invalid role-update subject") } + var req model.UpdateRoleRequest + if err := json.Unmarshal(data, &req); err != nil { return nil, errcode.BadRequest("invalid request") } + // …business logic using ctx, account, roomID, req… + return json.Marshal(map[string]string{"status": "ok"}) +} +``` + +After (delete the wrapper entirely; reshape the core): +```go +func (h *Handler) updateRole(c *natsrouter.Context, req model.UpdateRoleRequest) (*model.StatusReply, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …same business logic, unchanged… + return &model.StatusReply{Status: "ok"}, nil +} +``` + +Mechanical changes: +1. Delete `natsXxx`. +2. Rename `handleXxx` → `xxx` (unexported, no `Nats`/`handle` prefix). Signature → `(c *natsrouter.Context, req ) (*, error)`. +3. First line: `var ctx context.Context = c` (keeps every internal `ctx` reference working; `*Context` implements `context.Context`). +4. Delete the `subject.ParseXxx` block; replace with `account := c.Param("account")` / `roomID := c.Param("roomID")` as the body uses. +5. Delete the `json.Unmarshal(data, &req)` block — `req` is now the typed parameter. +6. Convert returns: `return json.Marshal(X)` → `return &X, nil`; every `return nil, err` stays. +7. Add the registration line to `Register` (Task 4 introduces the method). + +**(B) Body-less handler → `RegisterNoBody`** (mute, favorite, message-read, app-tabs, app-cmd-menu, list-org-members): identical to (A) but the signature is `func (h *Handler) xxx(c *natsrouter.Context) (*, error)` (no `req`), and there is no unmarshal block to delete (the core took `_ []byte` or no data arg). + +**(C) Optional-body handler → `RegisterNoBody` + manual unmarshal** (list-members, get-room-key): signature `func (h *Handler) xxx(c *natsrouter.Context) (*, error)`; **keep** the existing `var req …; if len(c.Msg.Data) > 0 { json.Unmarshal(c.Msg.Data, &req) }` guard (was `if len(data) > 0`). Do **not** use `Register` — it rejects an empty body. + +**Test conversion (all flavors).** Tests currently call e.g. `h.handleUpdateRole(ctxWithReqID(), subj, body)`. Convert to build a `*natsrouter.Context` and pass the typed request. Add this helper once to `room-service/handler_test.go`: + +```go +// ctxParams builds a *natsrouter.Context with subject params and a valid +// request ID on the underlying ctx (for handlers that echo/read it). +func ctxParams(params map[string]string) *natsrouter.Context { + c := natsrouter.NewContext(params) + c.SetContext(natsutil.WithRequestID(context.Background(), "01970a4f-8c2d-7c9a-abcd-e0123456789f")) + return c +} +``` + +For optional-body / no-body handlers that read `c.Msg.Data`, also set `c.Msg`: +```go +c := ctxParams(map[string]string{"account": "alice", "roomID": "r1"}) +c.Msg = &nats.Msg{Data: body} // body may be nil for the empty-body case +``` + +Then call the typed handler and assert on the struct: +```go +resp, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.UpdateRoleRequest{/*…*/}) +require.NoError(t, err) +assert.Equal(t, "ok", resp.Status) +``` + +**Deleted test categories (across the tasks):** (1) `*_InvalidSubject` cases — the handler no longer parses the subject, so the branch is unreachable; (2) `TestWrappedCtx_*` (deleted with `wrappedCtx` in Task 10) and room-worker's `TestRequireDedupRequestID` (Task 12) — request-ID validation now lives in `RequireRequestID` (Task 1 tests cover it); (3) malformed-JSON-body cases for `Register` handlers — unmarshalling happens in `natsrouter.Register` before the handler runs, so the handler can no longer receive malformed JSON. For (3), verify `pkg/natsrouter/router_test.go` (or `register` tests) already asserts a malformed body yields `errcode.BadRequest("invalid request payload")`; if it does not, add that one test there rather than keeping it per-handler. + +**Per-handler tasks below give the exact new signature, registration line, params, and any handler-specific notes.** Apply the recipe; the business logic between the parsing prologue and the return is unchanged unless a note says otherwise. + +--- + +### Task 4: Introduce `Register`; convert the toggles (mute, favorite) + +**Files:** +- Modify: `room-service/handler.go` +- Test: `room-service/handler_test.go` + +- [ ] **Step 1: Add the `Register` method skeleton + the `natsrouter` import** + +In `room-service/handler.go`, add `"github.com/hmchangw/chat/pkg/natsrouter"` to imports and add (above `RegisterCRUD`): + +```go +// Register wires every room-service RPC onto the natsrouter Router. Replaces +// RegisterCRUD. Register/RegisterNoBody panic on subscription failure (fatal at startup). +func (h *Handler) Register(r *natsrouter.Router) { + natsrouter.RegisterNoBody(r, subject.MuteTogglePattern(h.siteID), h.muteToggle) + natsrouter.RegisterNoBody(r, subject.FavoriteTogglePattern(h.siteID), h.favoriteToggle) +} +``` + +- [ ] **Step 2: Convert the `muteToggle` test (write failing)** + +In `room-service/handler_test.go`: add the `ctxParams` helper from the recipe (and imports `context`, `github.com/nats-io/nats.go`, `github.com/hmchangw/chat/pkg/natsrouter`, `github.com/hmchangw/chat/pkg/natsutil` if absent). Rewrite each `h.handleMuteToggle(ctx, subj, nil)` call site (see spec: `handler_test.go:4063,4120,4154,4164,4183,4210,4240,4692`) to: + +```go +resp, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) +``` + +Delete the `*_InvalidSubject` mute case (the `"garbage.subject"` test at `handler_test.go:4164`) — subject parsing no longer happens in the handler. Keep all other assertions; change `resp` usage from the unmarshalled `MuteToggleResponse` to the returned `*model.MuteToggleResponse` (fields identical). + +- [ ] **Step 3: Run to verify failure** + +Run: `make test SERVICE=room-service` +Expected: FAIL to compile — `h.muteToggle undefined` / `h.handleMuteToggle` signature mismatch. + +- [ ] **Step 4: Convert `muteToggle` + `favoriteToggle` cores (flavor B)** + +Apply recipe (B). Delete `natsMuteToggle` and `natsFavoriteToggle`. Reshape: + +```go +func (h *Handler) muteToggle(c *natsrouter.Context) (*model.MuteToggleResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …unchanged business logic… + return &model.MuteToggleResponse{Status: "ok", Muted: sub.Muted}, nil +} + +func (h *Handler) favoriteToggle(c *natsrouter.Context) (*model.FavoriteToggleResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …unchanged business logic… + return &model.FavoriteToggleResponse{Status: "ok", Favorite: sub.Favorite}, nil +} +``` + +Remove the `MuteToggleWildcard`/`FavoriteToggleWildcard` lines from `RegisterCRUD` (they now live in `Register`). + +- [ ] **Step 5: Run to verify pass** + +Run: `make test SERVICE=room-service` +Expected: PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint +git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate mute/favorite toggles to natsrouter" +``` + +--- + +### Task 5: Convert simple no-body reads (app-tabs, app-cmd-menu, list-org-members) + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.RegisterNoBody(r, subject.RoomAppTabsPattern(h.siteID), h.getRoomAppTabs) + natsrouter.RegisterNoBody(r, subject.RoomAppCmdMenuPattern(h.siteID), h.getRoomAppCommandMenu) + natsrouter.RegisterNoBody(r, subject.OrgMembersPattern(h.siteID), h.listOrgMembers) +``` + +- [ ] **Step 2: Convert tests (write failing)** — update call sites to: + +```go +resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) +resp, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) +resp, err := h.listOrgMembers(ctxParams(map[string]string{"account": "alice", "orgID": "eng"})) +``` + +Delete any `*_InvalidSubject` cases for these three (`handler_test.go:5403,5537` cover app tabs/cmd-menu; the org-members invalid-subject case if present). Adjust `resp` to the returned struct pointer (fields identical to the previously-unmarshalled responses). + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL (undefined methods). + +- [ ] **Step 4: Convert cores (flavor B).** Signatures + return types: + +```go +func (h *Handler) getRoomAppTabs(c *natsrouter.Context) (*model.GetRoomAppTabsResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …unchanged; final return becomes &model.GetRoomAppTabsResponse{Apps: out}, nil +} + +func (h *Handler) getRoomAppCommandMenu(c *natsrouter.Context) (*model.GetRoomAppCommandMenuResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …unchanged, return &model.GetRoomAppCommandMenuResponse{...}, nil… +} + +func (h *Handler) listOrgMembers(c *natsrouter.Context) (*model.ListOrgMembersResponse, error) { + var ctx context.Context = c + orgID := c.Param("orgID") + // …unchanged (drop ParseOrgMembersSubject; use orgID)… + return &model.ListOrgMembersResponse{Members: members}, nil +} +``` + +Note (list-org-members): reads only `orgID`; no requester check (per spec decision). Delete `natsGetRoomAppTabs`, `natsGetRoomAppCommandMenu`, `natsListOrgMembers` and their three `RegisterCRUD` lines. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate app-tabs, app-cmd-menu, list-org-members to natsrouter" +``` + +--- + +### Task 6: Convert message-read + read-receipt + thread-read + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.RegisterNoBody(r, subject.MessageReadPattern(h.siteID), h.messageRead) + natsrouter.Register(r, subject.MessageReadReceiptPattern(h.siteID), h.messageReadReceipt) + natsrouter.Register(r, subject.MessageThreadReadPattern(h.siteID), h.messageThreadRead) +``` + +- [ ] **Step 2: Convert tests (write failing).** Call sites become: + +```go +resp, err := h.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) +resp, err := h.messageReadReceipt(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.ReadReceiptRequest{MessageID: "m1"}) +resp, err := h.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "t1"}) +``` + +Delete the malformed-JSON-body test cases for read-receipt/thread-read (now covered by natsrouter `Register` tests) and any `*_InvalidSubject` cases (`handler_test.go:1825,1997` per spec). `messageRead` returns `*model.StatusReply` (`Status: "accepted"`); `messageThreadRead` returns `*model.StatusReply` (`Status: "accepted"`); `messageReadReceipt` returns `*model.ReadReceiptResponse`. + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL. + +- [ ] **Step 4: Convert cores.** +- `messageRead` — flavor B (was `_ []byte`): +```go +func (h *Handler) messageRead(c *natsrouter.Context) (*model.StatusReply, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …unchanged… return &model.StatusReply{Status: "accepted"}, nil +} +``` +- `messageReadReceipt` — flavor A, `req model.ReadReceiptRequest`, returns `*model.ReadReceiptResponse` (`return &model.ReadReceiptResponse{Readers: entries}, nil`). +- `messageThreadRead` — flavor A, `req model.MessageThreadReadRequest`, returns `*model.StatusReply{Status: "accepted"}`. + +Delete `natsMessageRead`, `natsMessageReadReceipt`, `natsMessageThreadRead` and their `RegisterCRUD` lines. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate message read/read-receipt/thread-read to natsrouter" +``` + +--- + +### Task 7: Convert optional-body reads (list-members, get-room-key) + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.RegisterNoBody(r, subject.MemberListPattern(h.siteID), h.listMembers) + natsrouter.RegisterNoBody(r, subject.RoomKeyGetPattern(h.siteID), h.getRoomKey) +``` + +- [ ] **Step 2: Convert tests (write failing).** Call sites build a Context with `c.Msg` so the optional body is reachable: + +```go +c := ctxParams(map[string]string{"account": "alice", "roomID": "r1"}) +c.Msg = &nats.Msg{Data: body} // body may be nil +resp, err := h.listMembers(c) +``` + +**Add an explicit empty-body test for each** (locks the optional-body contract): + +```go +func TestHandler_ListMembers_EmptyBody(t *testing.T) { + // …set up store mock for a member alice in r1… + c := ctxParams(map[string]string{"account": "alice", "roomID": "r1"}) + c.Msg = &nats.Msg{Data: nil} + resp, err := h.listMembers(c) + require.NoError(t, err) + assert.NotNil(t, resp) +} +``` +(Mirror for `getRoomKey`.) Delete the list-members/get-room-key `*_InvalidSubject` cases. + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL. + +- [ ] **Step 4: Convert cores (flavor C — keep the `len > 0` guard).** + +```go +func (h *Handler) listMembers(c *natsrouter.Context) (*model.ListRoomMembersResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …membership check unchanged (GetSubscription → errNotRoomMember)… + var req model.ListRoomMembersRequest + if len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { + return nil, errcode.BadRequest("invalid request") + } + } + // …unchanged… return &model.ListRoomMembersResponse{Members: members}, nil +} + +func (h *Handler) getRoomKey(c *natsrouter.Context) (*model.RoomKeyGetResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + // …membership check unchanged… + var req model.RoomKeyGetRequest + if len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { + return nil, errcode.BadRequest("invalid request") + } + } + // …unchanged; the two return json.Marshal(model.RoomKeyGetResponse{...}) become + // return &model.RoomKeyGetResponse{...}, nil (keep the #nosec G117 comments)… +} +``` + +Note: `getRoomKey` previously took `data []byte` and used `requesterAccount`/`roomID` from the subject — replace those with `account`/`roomID` from `c.Param`. Delete `natsListMembers`, `natsGetRoomKey`, and their `RegisterCRUD` lines. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate list-members and get-room-key (optional body) to natsrouter" +``` + +--- + +### Task 8: Convert async-accept mutations (remove-member, add-members, role-update) + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.Register(r, subject.MemberRoleUpdatePattern(h.siteID), h.updateRole) + natsrouter.Register(r, subject.MemberRemovePattern(h.siteID), h.removeMember) + natsrouter.Register(r, subject.MemberAddPattern(h.siteID), h.addMembers) +``` + +- [ ] **Step 2: Convert tests (write failing).** Call sites: + +```go +resp, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.UpdateRoleRequest{/*…*/}) +resp, err := h.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{/*…*/}) +resp, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.AddMembersRequest{/*…*/}) +``` + +`updateRole` → `*model.StatusReply{Status: "ok"}`; `removeMember`/`addMembers` → `*model.StatusReply{Status: "accepted"}`. Delete malformed-JSON-body cases and `*_InvalidSubject` cases for these three (`handler_test.go:2431,2940,3376,3742` per spec). The `NatsHandleRemoveMember` wrapper-level tests (`otelnats.Msg{...}`) are deleted. + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL. + +- [ ] **Step 4: Convert cores (flavor A).** Reshape `handleUpdateRole`, `handleRemoveMember`, `handleAddMembers`: signature `(c *natsrouter.Context, req ) (*model.StatusReply, error)`, `var ctx context.Context = c`, `account`/`roomID` from `c.Param`, drop the unmarshal block, convert the trailing `json.Marshal(map[string]string{"status": …})` to `&model.StatusReply{Status: …}, nil`. Note these handlers also cross-check `req.RoomID` against the subject roomID (`if req.RoomID != "" && req.RoomID != roomID`) — keep that. Delete `natsUpdateRole`, `NatsHandleRemoveMember`, `natsAddMembers` and their `RegisterCRUD` lines. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate role-update/remove/add members to natsrouter" +``` + +--- + +### Task 9: Convert rename + restricted (request-ID echo; bad-body fix) + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.Register(r, subject.RoomRenamePattern(h.siteID), h.roomRename) + natsrouter.Register(r, subject.RoomRestricted(h.siteID), h.roomRestricted) // concrete subject, no params +``` + +- [ ] **Step 2: Convert tests (write failing).** Call sites: + +```go +resp, err := h.roomRename(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RoomRenameRequest{NewName: "X"}) +// restricted has no subject params: +resp, err := h.roomRestricted(ctxParams(map[string]string{}), model.RoomRestrictedRequest{RoomID: "r1", Account: "alice"}) +``` + +Both return `*model.StatusWithRequestReply` — `resp.RequestID` equals the request ID set by `ctxParams` (`01970a4f-…`); `resp.Status` is `"accepted"` (rename) / `"ok"` (restricted). Delete the old malformed-body tests that asserted an `internal`/`fmt.Errorf("invalid request")` result — that path now returns a router `bad_request`. + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL. + +- [ ] **Step 4: Convert cores (flavor A).** +- `roomRename`: signature `(c *natsrouter.Context, req model.RoomRenameRequest) (*model.StatusWithRequestReply, error)`. Delete the inline `var body struct{ NewName string }` + its `json.Unmarshal` + `fmt.Errorf("invalid request: %w", err)` (the bad-body fix — router now handles it). Use `req.NewName`. Read the request ID once: `requestID := natsutil.RequestIDFromContext(c)`. Convert the final `json.Marshal(map[string]string{"status":"accepted","requestId":requestID})` → `&model.StatusWithRequestReply{Status: "accepted", RequestID: requestID}, nil`. +- `roomRestricted`: signature `(c *natsrouter.Context, req model.RoomRestrictedRequest) (*model.StatusWithRequestReply, error)`. Delete the `json.Unmarshal` + `fmt.Errorf("invalid request: %w", err)` block. `requestID := natsutil.RequestIDFromContext(c)`. Final return → `&model.StatusWithRequestReply{Status: "ok", RequestID: requestID}, nil`. No `c.Param` (concrete subject). + +Delete `natsRoomRename`, `natsRoomRestricted` and their `RegisterCRUD` lines. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate rename/restricted to natsrouter (fix bad-body to bad_request)" +``` + +--- + +### Task 10: Convert rooms-info-batch, ensure-room-key, and create + +**Files:** `room-service/handler.go`, `room-service/handler_test.go` + +- [ ] **Step 1: Add registration lines** to `Register`: + +```go + natsrouter.Register(r, subject.RoomsInfoBatchSubscribe(h.siteID), h.roomsInfoBatch) // concrete subject + natsrouter.Register(r, subject.RoomKeyEnsure(h.siteID), h.ensureRoomKey) // concrete subject + natsrouter.Register(r, subject.RoomCreatePattern(h.siteID), h.createRoom) +``` + +- [ ] **Step 2: Convert tests (write failing).** Call sites: + +```go +resp, err := h.roomsInfoBatch(ctxParams(map[string]string{}), model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}) +resp, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{/*…*/}) +resp, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), body) // body is model.CreateRoomRequest +``` + +`roomsInfoBatch` → `*model.RoomsInfoBatchResponse`; `ensureRoomKey` → `*model.RoomKeyEnsureResponse`; `createRoom` → `*model.CreateRoomReply`. The existing `handleCreateRoom` tests pass a `[]byte` body and `createRoomSubj("alice","site-a")`; convert them to pass `model.CreateRoomRequest{}` and `ctxParams({"account":"alice"})`, asserting on `resp` fields (was: unmarshal the returned `[]byte`). Delete the create `*_InvalidSubject` (`handler_test.go:2437`) and invalid-JSON (`handler_test.go:2817`) cases. + +- [ ] **Step 3: Run to verify failure** — `make test SERVICE=room-service` → FAIL. + +- [ ] **Step 4: Convert cores.** +- `roomsInfoBatch` (flavor A): `(c *natsrouter.Context, req model.RoomsInfoBatchRequest) (*model.RoomsInfoBatchResponse, error)`; drop unmarshal; `var ctx context.Context = c`; final `json.Marshal(model.RoomsInfoBatchResponse{Rooms: infos})` → `&model.RoomsInfoBatchResponse{Rooms: infos}, nil`. (No subject params — it's a batch endpoint.) +- `ensureRoomKey` (flavor A): `(c *natsrouter.Context, req model.RoomKeyEnsureRequest) (*model.RoomKeyEnsureResponse, error)`; drop unmarshal (keep the comment block about not using `WithCause` on parse errors — but the parse now happens in the router, so that whole `json.Unmarshal` block is deleted); convert the two `json.Marshal(model.RoomKeyEnsureResponse{...})` returns → `&…, nil`. +- `createRoom` (flavor A, **plus sub-handler return-type changes**): + ```go + func (h *Handler) createRoom(c *natsrouter.Context, req model.CreateRoomRequest) (*model.CreateRoomReply, error) { + var ctx context.Context = c + requesterAccount := c.Param("account") + // delete ParseRoomCreateSubject + json.Unmarshal blocks + roomType, err := classifyAndValidate(&req, requesterAccount) + if err != nil { return nil, err } + // …GetUser + name checks unchanged… + switch roomType { + case model.RoomTypeChannel: + return h.handleCreateRoomChannel(ctx, &req, requester, requesterAccount, roomType) + case model.RoomTypeDM, model.RoomTypeBotDM: + return h.handleCreateRoomDMOrBotDM(ctx, &req, requester, roomType) + default: + return nil, fmt.Errorf("unknown room type: %s", roomType) + } + } + ``` + Change `handleCreateRoomChannel` and `handleCreateRoomDMOrBotDM` return types from `([]byte, error)` to `(*model.CreateRoomReply, error)`: each ends in `return json.Marshal(model.CreateRoomReply{…})` — change to `return &model.CreateRoomReply{…}, nil`. Their other `return nil, err` lines are unchanged. + +Delete `natsCreateRoom`, `natsRoomsInfoBatch`, `NatsHandleEnsureRoomKey` and their `RegisterCRUD` lines. `RegisterCRUD` is now an empty method (`return nil`) — **leave it for now**; `main.go` still calls it until Task 11 (deleting it here would break `main.go`'s compile). Delete the now-unused `wrappedCtx` helper **and** the `TestWrappedCtx_*` trio (`handler_test.go:2351-2402`) in this same step — `wrappedCtx` becomes unreferenced once the last wrapper is gone (golangci-lint `unused` would flag it), and its behavior is already covered by Task 1's `RequireRequestID` tests. + +- [ ] **Step 5: Run to verify pass** — `make test SERVICE=room-service` → PASS. (Integration tests are intentionally still red until Task 11: `RegisterCRUD` is now an empty no-op and `Register` is not wired into `main.go` yet.) + +- [ ] **Step 6: Lint + commit** + +```bash +make lint && git add room-service/handler.go room-service/handler_test.go +git commit -m "refactor(room-service): migrate rooms-info-batch, ensure-room-key, create to natsrouter; remove wrappedCtx" +``` + +--- + +### Task 11: `room-service/main.go` cutover + +**Files:** Modify `room-service/main.go` + +- [ ] **Step 1: Replace registration + add router** + +Add imports `"github.com/hmchangw/chat/pkg/natsrouter"`. Replace the `handler.RegisterCRUD(nc)` block (`main.go:182-185`) with: + +```go + router := natsrouter.New(nc, "room-service") + router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) + handler.Register(router) +``` + +Then delete the now-empty `RegisterCRUD` method from `room-service/handler.go`, and remove the `otelnats` import (`github.com/Marz32onE/instrumentation-go/otel-nats/otelnats`) from `handler.go` if it is no longer referenced there (it was only used by the deleted wrappers and `RegisterCRUD`'s signature). + +- [ ] **Step 2 (OPTIONAL — recommended): per-handler timeout backpressure** + +Only if adopting the §6.4 recommendation. Add config field to `type config`: + +```go + RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" envDefault:"10s"` +``` + +and a guard + middleware line (after the `router.Use(...)` above): + +```go + if cfg.RequestTimeout <= 0 { + slog.Error("invalid REQUEST_TIMEOUT: must be > 0", "value", cfg.RequestTimeout) + os.Exit(1) + } + router.Use(natsrouter.HandlerTimeout(cfg.RequestTimeout)) +``` + +If adopting, also add `REQUEST_TIMEOUT=10s` to `room-service/deploy/docker-compose.yml`. **Skip this entire step if the timeout is not wanted** — the migration is correct without it. + +- [ ] **Step 3: Add `router.Shutdown` as the first shutdown hook** + +Make the first hook in `shutdown.Wait(...)` (before `nc.Drain()`): + +```go + func(ctx context.Context) error { return router.Shutdown(ctx) }, + func(ctx context.Context) error { return nc.Drain() }, +``` + +- [ ] **Step 4: Build + run unit tests** + +Run: `make build SERVICE=room-service && make test SERVICE=room-service` +Expected: build OK; tests PASS. (`RegisterCRUD` is gone; confirm no references remain: `grep -rn RegisterCRUD room-service` returns nothing.) + +- [ ] **Step 5: Integration tests** + +Run: `make test-integration SERVICE=room-service` +Expected: PASS — subjects/replies are unchanged, so request/reply assertions hold. Fix only failures caused by the wiring/signature change (e.g., a test that called the removed `RegisterCRUD`). + +- [ ] **Step 6: Lint + commit** + +```bash +make lint +git add room-service/main.go room-service/deploy/docker-compose.yml +git commit -m "refactor(room-service): cut main.go over to natsrouter Router" +``` + +--- + +## Phase 3 — room-worker RPC + docs + +### Task 12: room-worker `natsServerCreateDM` → natsrouter + +**Files:** `room-worker/handler.go`, `room-worker/handler_test.go`, `room-worker/main.go` + +- [ ] **Step 1: Convert the test (write failing)** + +In `room-worker/handler_test.go`, rewrite `handleSyncCreateDM(ctx, data)` call sites to the typed handler, and **move** `TestRequireDedupRequestID` (`handler_test.go:4345`) out — it tested `requireDedupRequestID`, which is deleted; the strict-request-ID behavior is now covered by `pkg/natsrouter`'s `TestRequireRequestID_*` (Task 1). Add the same `ctxParams` helper (no subject params needed — concrete subject), with `c.Msg` set for body access if a test needs it: + +```go +resp, err := h.serverCreateDM(ctxParams(map[string]string{}), model.SyncCreateDMRequest{/*…*/}) +``` + +- [ ] **Step 2: Run to verify failure** — `make test SERVICE=room-worker` → FAIL. + +- [ ] **Step 3: Convert the handler** + +Reshape `handleSyncCreateDM(ctx, data []byte)` → `serverCreateDM(c *natsrouter.Context, req model.SyncCreateDMRequest) (*model.SyncCreateDMReply, error)`: `var ctx context.Context = c`; delete the internal `json.Unmarshal(data, &req)` (req is the param) — **keep** the field validation that returns `errInvalidSyncDMRequest`. Delete `natsServerCreateDM` and `requireDedupRequestID`. If `otelnats` is no longer referenced in `room-worker/handler.go` after removing `natsServerCreateDM`, remove its import (note: `room-worker/main.go` still imports `oteljetstream`/`otelnats` for the JetStream loop — leave those). + +- [ ] **Step 4: Run to verify pass** — `make test SERVICE=room-worker` → PASS. + +- [ ] **Step 5: Wire the router in `room-worker/main.go`** + +Add import `"github.com/hmchangw/chat/pkg/natsrouter"`. Replace the `nc.QueueSubscribe(subject.RoomCreateDMSync(...), "room-worker", handler.natsServerCreateDM)` block (`main.go:152-155`) with: + +```go + router := natsrouter.New(nc, "room-worker") + router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) + natsrouter.Register(router, subject.RoomCreateDMSync(cfg.SiteID), handler.serverCreateDM) +``` + +Add a `router.Shutdown(ctx)` hook **before** `nc.Drain()` in the existing `hooks` slice (after the `iter.Stop()` + `wg.Wait()` hooks that drain the JetStream loop): + +```go + func(ctx context.Context) error { return router.Shutdown(ctx) }, + func(ctx context.Context) error { return nc.Drain() }, +``` + +- [ ] **Step 6: Build, test, lint, commit** + +```bash +make build SERVICE=room-worker && make test SERVICE=room-worker +make test-integration SERVICE=room-worker # subjects unchanged → should pass +make lint +git add room-worker/handler.go room-worker/handler_test.go room-worker/main.go +git commit -m "refactor(room-worker): migrate natsServerCreateDM to natsrouter" +``` + +--- + +### Task 13: docs/client-api.md + final verification + +**Files:** Modify `docs/client-api.md` + +- [ ] **Step 1: Update the rename malformed-body error** + +At `docs/client-api.md:622`, the rename section documents the malformed-body error as `"invalid request"` with (effectively) an internal failure. Change it to reflect the new behavior: the wire message is `"invalid request payload"` and the code is `bad_request` (the router now classifies a malformed body as a client error). Verify the request-ID-required note is present for the migrated RPCs (create §211 and rename §608 already document it; the behavior is unchanged — enforced on all 20 — so no schema changes are needed elsewhere). + +- [ ] **Step 2: Full-repo lint + tests** + +```bash +make lint +make test +``` +Expected: PASS across all packages. + +- [ ] **Step 3: Confirm no dead references** + +```bash +grep -rn "RegisterCRUD\|wrappedCtx\|requireDedupRequestID\|natsCreateRoom\|natsServerCreateDM" room-service room-worker +``` +Expected: no matches. + +- [ ] **Step 4: Commit** + +```bash +git add docs/client-api.md +git commit -m "docs(client-api): rename malformed-body error now bad_request/invalid request payload" +``` + +- [ ] **Step 5: SAST (blocking CI gate)** + +```bash +make sast +``` +Expected: no new medium+ findings. (Migration removes manual `errnats.Reply` sites and keeps the existing `#nosec G117` comments inside the preserved `getRoomKey` core; no new SAST surface.) + +--- + +## Self-review notes (already reconciled against the spec) + +- **Every spec section maps to a task:** RequireRequestID → T1; Pattern builders → T2; typed replies + RoomRenameRequest → T3; the 20 handler flavors (10 Register / 6 NoBody / 4 NoBody+optional) → T4–T10; main.go wiring + shutdown + optional HandlerTimeout → T11; room-worker RPC → T12; wire-compat client-api.md + error-model bad-body fix → T9/T13; observability parity → preserved by `var ctx context.Context = c` (recipe step 3); test deletions (invalid-subject, wrappedCtx, RequireDedupRequestID) → T4–T10, T12. +- **Type/name consistency:** handler methods are unexported `xxx(c, …)`; builders are `XxxPattern(siteID)`; responses are `*model.StatusReply` / `*model.StatusWithRequestReply` / existing `*model.XxxResponse`; `c.Param("account"|"roomID"|"orgID")` matches the `{account}`/`{roomID}`/`{orgID}` placeholders in the Task 2 builders. +- **Concrete subjects** (rooms-info-batch, ensure-room-key, restricted, create-dm) register with their existing builders and take **no** `c.Param`. diff --git a/docs/superpowers/specs/2026-06-04-room-service-natsrouter-migration-design.md b/docs/superpowers/specs/2026-06-04-room-service-natsrouter-migration-design.md new file mode 100644 index 000000000..d358b57c1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-room-service-natsrouter-migration-design.md @@ -0,0 +1,333 @@ +# room-service (+ room-worker RPC) natsrouter Migration + +## Summary + +Move every NATS **request/reply RPC** in `room-service` (20) and the one in +`room-worker` (`natsServerCreateDM`) off raw `nc.QueueSubscribe` + hand-written +wrappers onto `pkg/natsrouter`. This unblocks per-message concurrency (today each +subscription dispatches serially) and centralizes marshal/unmarshal, error +replies, panic recovery, request-ID, and logging — matching `search-service` / +`history-service`. + +Transport + plumbing swap only: subjects, request/response JSON, error +envelopes, and business semantics are preserved. room-worker's JetStream +consumer is **out of scope** (correct pattern, already error-model-conformant). + +## Goals + +- All request/reply RPCs registered through `natsrouter` (typed handlers, auto + marshal/unmarshal, centralized error reply). +- Per-message concurrency replacing serial dispatch. +- Handlers conform to `pkg/errcode`: named-constructor errors with a `reason` + where the frontend branches, raw `fmt.Errorf` for infra, no log-and-return. +- Strict request-ID preserved (reject missing/invalid — never mint). + +## Non-Goals + +- No change to room-worker's JetStream consumer (`HandleJetStreamMsg`; already + uses `errcode.Permanent`/`IsPermanent` → `Ack`/`Nak`). +- No change to subject **values**, request/response **schemas**, federation + payloads, or business logic. +- No admission-control cap (see Decisions). + +## Background + +### Current (room-service) + +`RegisterCRUD(nc)` does `nc.QueueSubscribe(subj, "room-service", h.natsXxx)` ×18. +Each `natsXxx(m otelnats.Msg)`: +1. `wrappedCtx(m)` — strict request-ID via `natsutil.RequireRequestID`. +2. `handleXxx(ctx, m.Msg.Subject, m.Msg.Data)` — core parses subject + unmarshals body. +3. Error → `errnats.Reply`; success → `m.Msg.Respond` / `natsutil.ReplyJSON`. + +nats.go dispatches each subscription's callback serially → every RPC is a +per-subject bottleneck. + +### Target (typed `Register`; middleware per `natsrouter.Default()`) + +```go +router := natsrouter.New(nc, "room-service") +router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) +handler.Register(router) +// shutdown: router.Shutdown(ctx) before nc.Drain() +``` + +Handlers become `func (h *Handler) xxx(c *natsrouter.Context, req ReqT) (*RespT, error)`. +`Register` unmarshals, calls the handler, replies via `c.ReplyJSON` / +`errnats.Reply`, one goroutine per message. + +`search-service` is the reference for the typed `Register` shape only; it uses +the **minting** `RequestID()` outermost (`search-service/main.go:168-170`). +room-service needs **strict** `RequireRequestID()` with `Recovery` outermost, per +`natsrouter.Default()` (`router.go:106`) / history-service. + +## Decisions (confirmed) + +1. **Strict request-ID** as a reusable `pkg/natsrouter` middleware + `RequireRequestID()`, mirroring the minting `RequestID()`. +2. **Subject params via `{name}` Pattern builders + `c.Param`**, consistent with + the other natsrouter services. +3. **Unbounded concurrency** (`natsrouter.New`, no `WithMaxConcurrency`), matching + `search-service`; per-handler timeout is the backpressure lever (§6.4). +4. **Scope includes room-worker's single RPC** (`natsServerCreateDM`); JetStream + consumer untouched. + +## Design + +### 6.1 `pkg/natsrouter` — `RequireRequestID()` middleware + +New in `middleware.go`, alongside `RequestID()`: + +```go +// Strict X-Request-ID: reject missing/non-UUID with BadRequest+Abort; never mint. +func RequireRequestID() HandlerFunc { + return func(c *Context) { + var headers nats.Header + var subj string + if c.Msg != nil { + headers, subj = c.Msg.Header, c.Msg.Subject + } + ctx, id, err := natsutil.RequireRequestID(c.ctx, headers, subj) + if err != nil { + // c.Msg is set in production; guard the nil-Msg unit-test context. + if c.Msg != nil { + errnats.Reply(c, c.Msg, err) // BadRequest, reason RequestIDRequired + } + c.Abort() + return + } + c.Set(requestIDKey, id) + c.SetContext(ctx) + c.WithLogValues("request_id", id) + c.Next() + } +} +``` + +Tests (`middleware_test.go`): valid UUID passes + stamps id; missing/invalid +replies BadRequest + aborts. + +### 6.2 `pkg/subject` — Pattern builders + +Add a `{name}`-placeholder `*Pattern` builder for each of the 17 wildcard +room-service subjects, each rendering the **same** concrete subject as its +`*Wildcard` sibling (which stays for publishers). Example: + +```go +// → chat.user.{account}.request.room.{roomID}..member.role-update +func MemberRoleUpdatePattern(siteID string) string { ... } +``` + +Each builder names every variable token (`{account}`, `{roomID}`, `{orgID}` …); +`{siteID}` stays concrete. Each gets a `subject_test.go` case. The 3 concrete +subjects (`RoomsInfoBatchSubscribe`, `RoomKeyEnsure`, `RoomRestricted`) and +room-worker's `RoomCreateDMSync` need no builder. + +### 6.3 `pkg/model` — typed status replies + rename request + +Replace ad-hoc `map[string]string{"status":…}` returns with typed structs +(CLAUDE.md: no bare maps): + +```go +type StatusReply struct { + Status string `json:"status"` // "ok" | "accepted" +} +type StatusWithRequestReply struct { + Status string `json:"status"` + RequestID string `json:"requestId"` +} +``` + +Add `RoomRenameRequest` (rename uses an inline `struct{ NewName string }`; +`Register` needs a named type): + +```go +type RoomRenameRequest struct { + NewName string `json:"newName"` +} +``` + +`NewName`-only (no server-ignored `RoomID`), keeping the body byte-identical. +Status replies are content-identical to today (JSON key order isn't part of the +contract). Add `model_test.go` round-trips. + +### 6.4 `room-service/main.go` wiring + +- Middleware order `Recovery` → `RequireRequestID` → `Logging`, per + `natsrouter.Default()` (`router.go:106`) / history-service (not search-service, + which puts `RequestID` outermost). `Logging` reads `request_id` on success; on + reject `RequireRequestID` aborts, so the rejection is logged once by + `errnats.Reply` → `Classify`, not `Logging`. room-service is NATS-only (no + HTTP/`/metrics`/`/healthz`). + ```go + router := natsrouter.New(nc, "room-service") + router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) + router.Use(natsrouter.HandlerTimeout(cfg.RequestTimeout)) // see below + handler.Register(router) + ``` +- **Recommended:** `HandlerTimeout(cfg.RequestTimeout)` via new `REQUEST_TIMEOUT` + env (default `10s`) — the backpressure lever under unbounded concurrency. (Open + for confirmation.) +- Replace `handler.RegisterCRUD(nc)` with `handler.Register(router)`. +- Add `router.Shutdown(ctx)` as the first shutdown hook, before `nc.Drain()`. + +### 6.5 `room-service/handler.go` conversion + +Delete `RegisterCRUD`, the 20 `natsXxx`/`NatsHandleXxx` wrappers, and +`wrappedCtx`. Add: + +```go +func (h *Handler) Register(r *natsrouter.Router) { + natsrouter.Register(r, subject.RoomCreatePattern(h.siteID), h.createRoom) + natsrouter.RegisterNoBody(r, subject.MuteTogglePattern(h.siteID), h.muteToggle) + // … 20 total … +} +``` + +Three flavors (see inventory): +- **`Register[Req,Resp]`** — router unmarshals; handler reads params via + `c.Param`, returns `(*Resp, error)`. +- **`RegisterNoBody[Resp]`** — body-less; params via `c.Param`. +- **`RegisterNoBody[Resp]` + manual optional unmarshal** — `listMembers` / + `getRoomKey` tolerate an empty body today; `Register` rejects empty bytes (a + `{}` is fine). Keep the `if len(c.Msg.Data) > 0 { json.Unmarshal(...) }` guard. + +Cores reshape from `handleXxx(ctx, subj, data)` to (params, typed req). +Subject-parse error branches disappear (a matched subscription always yields +params). `requestId`-echoing responses read it via +`natsutil.RequestIDFromContext(c.Context())`. + +**Observability parity:** spans (`trace.SpanFromContext`) and metrics +(`roomkeymetrics.*.Add`) live in the preserved cores +(`handler.go:365,380,1099,1359,1904,1996,2126,2179,2195`); the consumer span +flows via `m.Context()` → `acquireContext` (`router.go:211`) as today. Guardrail: +thread the natsrouter `*Context` / `c.Context()` into cores — never a bare ctx, +or spans vanish. + +### 6.6 `room-worker` — `natsServerCreateDM` + +Add a small router for the one RPC (JS consumer unchanged): + +```go +router := natsrouter.New(nc, "room-worker") +router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) +natsrouter.Register(router, subject.RoomCreateDMSync(cfg.SiteID), handler.serverCreateDM) +``` + +Add `router.Shutdown(ctx)` before `nc.Drain()` (after the JS iterator stop + +`wg.Wait()`). Delete `natsServerCreateDM` + `requireDedupRequestID` (middleware +replaces it). Reshape `handleSyncCreateDM(ctx, data)` → `serverCreateDM(c, req) +(*model.SyncCreateDMReply, error)`. Concrete subject → no builder / `c.Param`. +Already collision/retry-safe, so concurrent dispatch is fine. + +### 6.7 Error-propagation conformance pass + +Handlers mostly already return `errcode.*` with reasons + raw `fmt.Errorf` for +infra. The migration removes manual `errnats.Reply` sites. Audit each handler for: +- Client-error-as-internal bugs fixed for free: `roomRename` / `roomRestricted` + return `fmt.Errorf("invalid request: %w", err)` on a bad body today (→ + `internal`); the router now replies `BadRequest`. +- No `slog.*` before returning the same error (router classifies+logs once). +- `WithCause` only on infra errors, never raw client payloads. + +## Per-RPC inventory + +| # | RPC | Pattern/subject builder | Flavor | Request | Response | Params | +|---|-----|------------------------|--------|---------|----------|--------| +| 1 | create | `RoomCreatePattern` | Register | `CreateRoomRequest` | `CreateRoomReply` | account | +| 2 | rooms-info-batch | `RoomsInfoBatchSubscribe` (concrete) | Register | `RoomsInfoBatchRequest` | `RoomsInfoBatchResponse` | — | +| 3 | role-update | `MemberRoleUpdatePattern` | Register | `UpdateRoleRequest` | `StatusReply{ok}` | account, roomID | +| 4 | remove-member | `MemberRemovePattern` | Register | `RemoveMemberRequest` | `StatusReply{accepted}` | account, roomID | +| 5 | add-members | `MemberAddPattern` | Register | `AddMembersRequest` | `StatusReply{accepted}` | account, roomID | +| 6 | message-read | `MessageReadPattern` | NoBody | — | `StatusReply{accepted}` | account, roomID | +| 7 | read-receipt | `MessageReadReceiptPattern` | Register | `ReadReceiptRequest` | `ReadReceiptResponse` | account, roomID | +| 8 | thread-read | `MessageThreadReadPattern` | Register | `MessageThreadReadRequest` | `StatusReply{accepted}` | account, roomID | +| 9 | list-members | `MemberListPattern` | NoBody + optional body | `ListRoomMembersRequest` (optional) | `ListRoomMembersResponse` | account, roomID | +| 10 | list-org-members | `OrgMembersPattern` | NoBody | — | `ListOrgMembersResponse` | account, orgID | +| 11 | ensure-room-key | `RoomKeyEnsure` (concrete) | Register | `RoomKeyEnsureRequest` | `RoomKeyEnsureResponse` | — | +| 12 | get-room-key | `RoomKeyGetPattern` | NoBody + optional body | `RoomKeyGetRequest` (optional) | `RoomKeyGetResponse` | account, roomID | +| 13 | mute-toggle | `MuteTogglePattern` | NoBody | — | `MuteToggleResponse` | account, roomID | +| 14 | favorite-toggle | `FavoriteTogglePattern` | NoBody | — | `FavoriteToggleResponse` | account, roomID | +| 15 | room-rename | `RoomRenamePattern` | Register | `RoomRenameRequest` (new) | `StatusWithRequestReply{accepted}` | account, roomID | +| 16 | room-restricted | `RoomRestricted` (concrete) | Register | `RoomRestrictedRequest` | `StatusWithRequestReply{ok}` | — | +| 17 | app-tabs | `RoomAppTabsPattern` | NoBody | — | `GetRoomAppTabsResponse` | account, roomID | +| 18 | app-cmd-menu | `RoomAppCmdMenuPattern` | NoBody | — | `GetRoomAppCommandMenuResponse` | account, roomID | +| 19 | member-statuses | `MemberStatusesPattern` | NoBody + optional body | `ListMemberStatusesRequest` (optional) | `ListMemberStatusesResponse` | account, roomID | +| 20 | mentionable | `MentionableSubscriptionsPattern` | NoBody + optional body | `MentionableSubscriptionsRequest` (optional) | `MentionableSubscriptionsResponse` | account, roomID | +| 21 | server-create-dm (room-worker) | `RoomCreateDMSync` (concrete) | Register | `SyncCreateDMRequest` | `SyncCreateDMReply` | — | + +Authorization on the two list endpoints: +- **list-members** (room, row 9): reads `account` for the membership check + (`GetSubscription` → `errNotRoomMember`, `handler.go:470-481`) — `{account}` + load-bearing. +- **list-org-members** (row 10): reads only `orgID`, no requester check + (`handler.go:454-467`). **Reviewed/decided:** it's a section/department + directory lookup (`orgID` = user `SectID`/`DeptID`, `store.go:73`) with no room + context, intentionally readable by any authenticated caller — no gate added. + `{account}` still named for uniformity. + +## Wire compatibility + +- **Subjects:** unchanged (Pattern builders render byte-identical subjects). +- **Success bodies:** unchanged content; `map → struct` only changes key order + (not part of the contract). +- **Error envelopes:** codes/reasons unchanged except two deltas: (1) + unmarshal-failure text `"invalid request"` → `"invalid request payload"`, same + `bad_request`; (2) `roomRename`/`roomRestricted` bad body now `bad_request` + instead of `internal` (a fix). +- **Empty-body callers** of `list-members` / `get-room-key` keep working via the + optional-unmarshal flavor. +- `docs/client-api.md`: **must** update the rename malformed-body error + (`client-api.md:622`: `"invalid request"` → `"invalid request payload"`, + `internal` → `bad_request`). Per CLAUDE.md, also verify the request-ID-required + note for the migrated RPCs (today only create §211, rename §608 — pre-existing + gap). Schemas/events otherwise unchanged. + +## Testing (TDD) + +Red → Green per unit, suite green throughout. +- `pkg/natsrouter`: `RequireRequestID` tests (valid / missing / invalid → abort). +- `pkg/subject`: rendered-pattern assertion per builder. +- `pkg/model`: round-trips for `StatusReply`, `StatusWithRequestReply`, + `RoomRenameRequest`. +- `room-service` / `room-worker` handler tests: build `*Context` via + `natsrouter.NewContext(map[...])`, call `h.xxx(ctx, req)`, assert on `*Resp`. + Beyond signature updates: (a) **delete** the ~12 `*_InvalidSubject` cases + (`handler_test.go:884,1825,1997,2431,2940,3376,3742,4158,4381,4879,5403,5537`) + — branches now unreachable; (b) **move** the `TestWrappedCtx_*` trio + (`handler_test.go:2351-2402`) and `TestRequireDedupRequestID` + (`handler_test.go:4345`) into `pkg/natsrouter`; (c) malformed-body cases covered + once by `Register` tests. Add empty-body tests for list-members / get-room-key. +- Integration tests: subjects/replies unchanged → should pass as-is; fix only + what wiring touches. +- `make generate` if store interfaces changed (none expected), then `make lint` + + `make test` (race). + +## Sequencing (single branch, small commits) + +1. `pkg/natsrouter`: `RequireRequestID()` + tests. +2. `pkg/subject`: Pattern builders + tests. +3. `pkg/model`: status replies + `RoomRenameRequest` + tests. +4. room-service handlers in groups (toggles/reads → list/get → mutations → + create), tests green. +5. room-service `main.go` cutover; delete `RegisterCRUD`/wrappers/`wrappedCtx`. +6. room-worker: migrate `natsServerCreateDM`. +7. Final `make lint` + `make test`; diff `docs/client-api.md`. + +## Risks & mitigations + +- **Silent dedup break** from minting → use `RequireRequestID()` (decision 1); + middleware tests. +- **Empty-body regression** (list-members/get-room-key) → optional-body flavor + + tests. +- **Unbounded goroutines** flooding deps → `HandlerTimeout` (§6.4); revisit + `WithMaxConcurrency` if needed. +- **Ordering no longer FIFO per subject** → mutations use atomic Mongo ops / dedup + by request-id; create + sync-DM are collision/retry-safe. +- **Large test-file churn** (`handler_test.go` ~228 KB) → mechanical `NewContext` + updates, group-by-group. + +## Out of scope + +room-worker JetStream consumer; subject values; request/response schemas; +federation payloads; room-worker async error model (already conformant). diff --git a/history-service/internal/cassrepo/reactions_integration_test.go b/history-service/internal/cassrepo/reactions_integration_test.go index 743243e33..aae064e15 100644 --- a/history-service/internal/cassrepo/reactions_integration_test.go +++ b/history-service/internal/cassrepo/reactions_integration_test.go @@ -146,11 +146,11 @@ func TestRepository_AddReaction_Pinned(t *testing.T) { msgID, roomID, createdAt, sender, "pinned msg", "", pinnedAt, ).Exec()) require.NoError(t, repo.session.Query( - `INSERT INTO messages_by_room (room_id, bucket, created_at, message_id, sender, msg, thread_parent_id, pinned_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - roomID, bucketSizer.Of(createdAt), createdAt, msgID, sender, "pinned msg", "", pinnedAt, + `INSERT INTO messages_by_room (room_id, bucket, created_at, message_id, sender, msg, thread_parent_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, + roomID, bucketSizer.Of(createdAt), createdAt, msgID, sender, "pinned msg", "", ).Exec()) require.NoError(t, repo.session.Query( - `INSERT INTO pinned_messages_by_room (room_id, created_at, message_id, sender, msg) VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO pinned_messages_by_room (room_id, pinned_at, message_id, sender, msg) VALUES (?, ?, ?, ?, ?)`, roomID, pinnedAt, msgID, sender, "pinned msg", ).Exec()) @@ -176,7 +176,7 @@ func TestRepository_AddReaction_Pinned(t *testing.T) { // default (no UPDATE issued by AddReaction). var pinnedUpdatedAt time.Time err := repo.session.Query( - `SELECT updated_at FROM pinned_messages_by_room WHERE room_id = ? AND created_at = ? AND message_id = ?`, + `SELECT updated_at FROM pinned_messages_by_room WHERE room_id = ? AND pinned_at = ? AND message_id = ?`, roomID, pinnedAt, msgID, ).Scan(&pinnedUpdatedAt) require.NoError(t, err) @@ -294,11 +294,11 @@ func TestRepository_RemoveReaction_Pinned(t *testing.T) { msgID, roomID, createdAt, sender, "pinned msg", "", pinnedAt, ).Exec()) require.NoError(t, repo.session.Query( - `INSERT INTO messages_by_room (room_id, bucket, created_at, message_id, sender, msg, thread_parent_id, pinned_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - roomID, bucketSizer.Of(createdAt), createdAt, msgID, sender, "pinned msg", "", pinnedAt, + `INSERT INTO messages_by_room (room_id, bucket, created_at, message_id, sender, msg, thread_parent_id) VALUES (?, ?, ?, ?, ?, ?, ?)`, + roomID, bucketSizer.Of(createdAt), createdAt, msgID, sender, "pinned msg", "", ).Exec()) require.NoError(t, repo.session.Query( - `INSERT INTO pinned_messages_by_room (room_id, created_at, message_id, sender, msg) VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO pinned_messages_by_room (room_id, pinned_at, message_id, sender, msg) VALUES (?, ?, ?, ?, ?)`, roomID, pinnedAt, msgID, sender, "pinned msg", ).Exec()) @@ -323,7 +323,7 @@ func TestRepository_RemoveReaction_Pinned(t *testing.T) { // pinned_messages_by_room.updated_at must NOT have been touched. var pinnedUpdatedAt time.Time require.NoError(t, repo.session.Query( - `SELECT updated_at FROM pinned_messages_by_room WHERE room_id = ? AND created_at = ? AND message_id = ?`, + `SELECT updated_at FROM pinned_messages_by_room WHERE room_id = ? AND pinned_at = ? AND message_id = ?`, roomID, pinnedAt, msgID, ).Scan(&pinnedUpdatedAt)) assert.True(t, pinnedUpdatedAt.IsZero() || pinnedUpdatedAt.Before(removedAt), diff --git a/pkg/model/event.go b/pkg/model/event.go index ef6b087f1..837515647 100644 --- a/pkg/model/event.go +++ b/pkg/model/event.go @@ -508,3 +508,22 @@ type RoomRestrictedOutboxPayload struct { OwnerAccount string `json:"ownerAccount,omitempty" bson:"ownerAccount,omitempty"` Timestamp int64 `json:"timestamp" bson:"timestamp"` } + +// StatusReply is the response for fire-and-forget RPCs that only confirm +// acceptance. Status is "ok" or "accepted" depending on the endpoint. +type StatusReply struct { + Status string `json:"status"` +} + +// StatusWithRequestReply is StatusReply plus the echoed request ID, for RPCs +// whose clients correlate the async result by request ID (rename, restricted). +type StatusWithRequestReply struct { + Status string `json:"status"` + RequestID string `json:"requestId"` +} + +// RoomRenameRequest is the rename RPC body. NewName-only: roomID is taken from +// the subject, never the body. +type RoomRenameRequest struct { + NewName string `json:"newName"` +} diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 6f52403cc..5714bc6c5 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -3278,3 +3278,28 @@ func TestPresenceSnapshotReply_RoundTrip(t *testing.T) { require.NoError(t, json.Unmarshal(data, &out)) assert.Equal(t, in, out) } + +func TestStatusReply_RoundTrip(t *testing.T) { + roundTrip(t, &model.StatusReply{Status: "ok"}, &model.StatusReply{}) + roundTrip(t, &model.StatusReply{Status: "accepted"}, &model.StatusReply{}) +} + +func TestStatusWithRequestReply_RoundTrip(t *testing.T) { + roundTrip(t, &model.StatusWithRequestReply{Status: "accepted", RequestID: "01970a4f-8c2d-7c9a-abcd-e0123456789f"}, &model.StatusWithRequestReply{}) +} + +func TestRoomRenameRequest_RoundTrip(t *testing.T) { + roundTrip(t, &model.RoomRenameRequest{NewName: "New Name"}, &model.RoomRenameRequest{}) +} + +func TestStatusReply_JSONShape(t *testing.T) { + b, err := json.Marshal(model.StatusReply{Status: "accepted"}) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"accepted"}`, string(b)) +} + +func TestStatusWithRequestReply_JSONShape(t *testing.T) { + b, err := json.Marshal(model.StatusWithRequestReply{Status: "ok", RequestID: "rid"}) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"ok","requestId":"rid"}`, string(b)) +} diff --git a/pkg/natsrouter/middleware.go b/pkg/natsrouter/middleware.go index db86c550d..e7e5f1375 100644 --- a/pkg/natsrouter/middleware.go +++ b/pkg/natsrouter/middleware.go @@ -41,6 +41,35 @@ func RequestID() HandlerFunc { } } +// RequireRequestID is the strict variant of RequestID: a missing/non-UUID +// X-Request-ID is rejected (BadRequest, reason RequestIDRequired) and aborts; never mints. +func RequireRequestID() HandlerFunc { + return func(c *Context) { + var ( + headers nats.Header + subj string + ) + if c.Msg != nil { + headers = c.Msg.Header + subj = c.Msg.Subject + } + ctx, id, err := natsutil.RequireRequestID(c.ctx, headers, subj) + if err != nil { + // c.Msg is always set in production (acquireContext); guard so a + // nil-Msg test context aborts cleanly instead of panicking in Respond. + if c.Msg != nil { + errnats.Reply(c, c.Msg, err) + } + c.Abort() + return + } + c.Set(requestIDKey, id) + c.SetContext(ctx) + c.WithLogValues("request_id", id) + c.Next() + } +} + // requestAttrs returns common log attributes including the request ID if present. func requestAttrs(c *Context) []any { var attrs []any diff --git a/pkg/natsrouter/middleware_test.go b/pkg/natsrouter/middleware_test.go index 16f137fe9..582ae847c 100644 --- a/pkg/natsrouter/middleware_test.go +++ b/pkg/natsrouter/middleware_test.go @@ -5,8 +5,11 @@ import ( "testing" "time" + "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/hmchangw/chat/pkg/natsutil" ) func TestHandlerTimeout_SetsDeadline(t *testing.T) { @@ -69,3 +72,77 @@ func TestHandlerTimeout_DoesNotCancelParentContext(t *testing.T) { default: } } + +func TestRequireRequestID_ValidPasses(t *testing.T) { + const id = "01970a4f-8c2d-7c9a-abcd-e0123456789f" + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{natsutil.RequestIDHeader: []string{id}}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + require.True(t, ran, "handler must run when request ID is a valid UUID") + got, ok := c.Get(requestIDKey) + require.True(t, ok) + assert.Equal(t, id, got) + assert.Equal(t, id, natsutil.RequestIDFromContext(c)) +} + +func TestRequireRequestID_MissingAborts(t *testing.T) { + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + assert.False(t, ran, "handler must NOT run when request ID is missing") + assert.True(t, c.IsAborted()) + _, stamped := c.Get(requestIDKey) + assert.False(t, stamped, "request ID must not be stamped on the abort path") +} + +func TestRequireRequestID_InvalidAborts(t *testing.T) { + c := &Context{ + ctx: context.Background(), + Msg: &nats.Msg{Subject: "x", Header: nats.Header{natsutil.RequestIDHeader: []string{"not-a-uuid"}}}, + chain: &chainState{index: -1}, + } + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + assert.False(t, ran, "handler must NOT run when request ID is malformed") + assert.True(t, c.IsAborted()) + _, stamped := c.Get(requestIDKey) + assert.False(t, stamped, "request ID must not be stamped on the abort path") +} + +func TestRequireRequestID_NilMsgAborts(t *testing.T) { + // NewContext-style test context leaves Msg nil; the middleware must abort + // cleanly (no panic in errnats.Reply) rather than dereference a nil Msg. + c := &Context{ctx: context.Background(), chain: &chainState{index: -1}} + var ran bool + c.chain.handlers = []HandlerFunc{ + RequireRequestID(), + func(c *Context) { ran = true }, + } + c.Next() + + assert.False(t, ran, "handler must NOT run when Msg (and thus request ID) is absent") + assert.True(t, c.IsAborted()) +} diff --git a/pkg/subject/subject.go b/pkg/subject/subject.go index cc1bf6046..8b9feeb6d 100644 --- a/pkg/subject/subject.go +++ b/pkg/subject/subject.go @@ -605,6 +605,76 @@ func SearchUsersPattern(siteID string) string { return fmt.Sprintf("chat.user.{account}.request.search.%s.users", siteID) } +// --- room-service natsrouter pattern builders (siteID baked in) --- + +func RoomCreatePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.%s.create", siteID) +} + +func MemberRoleUpdatePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.role-update", siteID) +} + +func MemberRemovePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.remove", siteID) +} + +func MemberAddPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.add", siteID) +} + +func MemberListPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.list", siteID) +} + +func MemberStatusesPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.member.statuses", siteID) +} + +func MentionableSubscriptionsPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.subscription.mentionable", siteID) +} + +func OrgMembersPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.orgs.{orgID}.%s.members", siteID) +} + +func MessageReadPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.read", siteID) +} + +func MessageReadReceiptPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.read-receipt", siteID) +} + +func MessageThreadReadPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.message.thread.read", siteID) +} + +func RoomKeyGetPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.key.get", siteID) +} + +func MuteTogglePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.mute.toggle", siteID) +} + +func FavoriteTogglePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.favorite.toggle", siteID) +} + +func RoomRenamePattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.room.rename", siteID) +} + +func RoomAppTabsPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.app.tabs", siteID) +} + +func RoomAppCmdMenuPattern(siteID string) string { + return fmt.Sprintf("chat.user.{account}.request.room.{roomID}.%s.app.cmd-menu", siteID) +} + // isValidAccountToken rejects empty tokens and tokens containing NATS wildcard // characters ('*' or '>'). Subject parsers use it as the boundary guard for the // account token so wildcard semantics never leak into identity parsing. diff --git a/pkg/subject/subject_test.go b/pkg/subject/subject_test.go index 28ae6cf56..f8e1ab376 100644 --- a/pkg/subject/subject_test.go +++ b/pkg/subject/subject_test.go @@ -1,6 +1,7 @@ package subject_test import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -819,3 +820,33 @@ func TestParseSubscriptionUpdateAccount(t *testing.T) { _, ok = subject.ParseSubscriptionUpdateAccount("chat.user.*.event.subscription.update") assert.False(t, ok) // wildcard token rejected } + +func TestRoomPatternsMatchWildcards(t *testing.T) { + const site = "site-a" + repl := strings.NewReplacer("{account}", "*", "{roomID}", "*", "{orgID}", "*") + cases := []struct{ name, pattern, wildcard string }{ + {"create", subject.RoomCreatePattern(site), subject.RoomCreateWildcard(site)}, + {"role-update", subject.MemberRoleUpdatePattern(site), subject.MemberRoleUpdateWildcard(site)}, + {"remove", subject.MemberRemovePattern(site), subject.MemberRemoveWildcard(site)}, + {"add", subject.MemberAddPattern(site), subject.MemberAddWildcard(site)}, + {"list", subject.MemberListPattern(site), subject.MemberListWildcard(site)}, + {"member-statuses", subject.MemberStatusesPattern(site), subject.MemberStatusesWildcard(site)}, + {"mentionable", subject.MentionableSubscriptionsPattern(site), subject.MentionableSubscriptionsWildcard(site)}, + {"org-members", subject.OrgMembersPattern(site), subject.OrgMembersWildcard(site)}, + {"message-read", subject.MessageReadPattern(site), subject.MessageReadWildcard(site)}, + {"read-receipt", subject.MessageReadReceiptPattern(site), subject.MessageReadReceiptWildcard(site)}, + {"thread-read", subject.MessageThreadReadPattern(site), subject.MessageThreadReadWildcard(site)}, + {"key-get", subject.RoomKeyGetPattern(site), subject.RoomKeyGetWildcard(site)}, + {"mute", subject.MuteTogglePattern(site), subject.MuteToggleWildcard(site)}, + {"favorite", subject.FavoriteTogglePattern(site), subject.FavoriteToggleWildcard(site)}, + {"rename", subject.RoomRenamePattern(site), subject.RoomRenameWildcard(site)}, + {"app-tabs", subject.RoomAppTabsPattern(site), subject.RoomAppTabsWildcard(site)}, + {"app-cmd-menu", subject.RoomAppCmdMenuPattern(site), subject.RoomAppCmdMenuWildcard(site)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.wildcard, repl.Replace(tc.pattern), + "pattern with params replaced by * must equal the existing wildcard subscription subject") + }) + } +} diff --git a/room-service/handler.go b/room-service/handler.go index 948f7214b..a902708ab 100644 --- a/room-service/handler.go +++ b/room-service/handler.go @@ -16,7 +16,6 @@ import ( "time" "unicode/utf8" - "github.com/Marz32onE/instrumentation-go/otel-nats/otelnats" "go.mongodb.org/mongo-driver/v2/mongo" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -25,9 +24,9 @@ import ( "github.com/hmchangw/chat/pkg/displayfmt" "github.com/hmchangw/chat/pkg/errcode" - "github.com/hmchangw/chat/pkg/errcode/errnats" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeymetrics" "github.com/hmchangw/chat/pkg/roomkeystore" @@ -74,117 +73,34 @@ func NewHandler(store RoomStore, keyStore RoomKeyStore, memberListClient MemberL } } -// wrappedCtx validates the inbound X-Request-ID via natsutil.RequireRequestID -// (strict mode) and returns m.Context() seeded with the id for the centralized -// errcode.Classify log line. Missing/malformed headers return an -// errcode.BadRequest that the caller must reply to via errnats.Reply. -// -// Strict mode is required here — not the mint-on-missing default — because -// room-service handlers fan out to room-worker, whose JetStream publishes -// derive Nats-Msg-Id / message IDs from this request ID (OutboxDedupID, -// messageDedupSeed, idgen.MessageIDFromRequestID). A silently-minted server- -// side ID would break dedup across client retries. See docs/error-handling.md -// §3a. -func wrappedCtx(m otelnats.Msg) (context.Context, error) { - ctx, id, err := natsutil.RequireRequestID(m.Context(), m.Msg.Header, m.Msg.Subject) - if err != nil { - return m.Context(), err - } - return errcode.WithLogValues(ctx, "request_id", id), nil -} - -// RegisterCRUD registers NATS request/reply handlers for room CRUD with queue group. -func (h *Handler) RegisterCRUD(nc *otelnats.Conn) error { - const queue = "room-service" - if _, err := nc.QueueSubscribe(subject.RoomCreateWildcard(h.siteID), queue, h.natsCreateRoom); err != nil { - return fmt.Errorf("subscribe room.create: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomsInfoBatchSubscribe(h.siteID), queue, h.natsRoomsInfoBatch); err != nil { - return err - } - if _, err := nc.QueueSubscribe(subject.MemberRoleUpdateWildcard(h.siteID), queue, h.natsUpdateRole); err != nil { - return fmt.Errorf("subscribe member role update: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MemberRemoveWildcard(h.siteID), queue, h.NatsHandleRemoveMember); err != nil { - return fmt.Errorf("subscribe member remove: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MemberAddWildcard(h.siteID), queue, h.natsAddMembers); err != nil { - return fmt.Errorf("subscribe member add: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MessageReadWildcard(h.siteID), queue, h.natsMessageRead); err != nil { - return fmt.Errorf("subscribe message read: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MessageReadReceiptWildcard(h.siteID), queue, h.natsMessageReadReceipt); err != nil { - return fmt.Errorf("subscribe message read-receipt: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MessageThreadReadWildcard(h.siteID), queue, h.natsMessageThreadRead); err != nil { - return fmt.Errorf("subscribe message thread read: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MemberListWildcard(h.siteID), queue, h.natsListMembers); err != nil { - return fmt.Errorf("subscribe member list: %w", err) - } - if _, err := nc.QueueSubscribe(subject.OrgMembersWildcard(h.siteID), queue, h.natsListOrgMembers); err != nil { - return fmt.Errorf("subscribe org members: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomKeyEnsure(h.siteID), queue, h.NatsHandleEnsureRoomKey); err != nil { - return fmt.Errorf("subscribe room key ensure: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomKeyGetWildcard(h.siteID), queue, h.natsGetRoomKey); err != nil { - return fmt.Errorf("subscribe room key get: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MuteToggleWildcard(h.siteID), queue, h.natsMuteToggle); err != nil { - return fmt.Errorf("subscribe mute toggle: %w", err) - } - if _, err := nc.QueueSubscribe(subject.FavoriteToggleWildcard(h.siteID), queue, h.natsFavoriteToggle); err != nil { - return fmt.Errorf("subscribe favorite toggle: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomRenameWildcard(h.siteID), queue, h.natsRoomRename); err != nil { - return fmt.Errorf("subscribe room rename: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomRestricted(h.siteID), queue, h.natsRoomRestricted); err != nil { - return fmt.Errorf("subscribe room restricted: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomAppTabsWildcard(h.siteID), queue, h.natsGetRoomAppTabs); err != nil { - return fmt.Errorf("subscribe app tabs: %w", err) - } - if _, err := nc.QueueSubscribe(subject.RoomAppCmdMenuWildcard(h.siteID), queue, h.natsGetRoomAppCommandMenu); err != nil { - return fmt.Errorf("subscribe app cmd-menu: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MemberStatusesWildcard(h.siteID), queue, h.natsListMemberStatuses); err != nil { - return fmt.Errorf("subscribe member statuses: %w", err) - } - if _, err := nc.QueueSubscribe(subject.MentionableSubscriptionsWildcard(h.siteID), queue, h.natsListMentionableSubscriptions); err != nil { - return fmt.Errorf("subscribe mentionable subscriptions: %w", err) - } - return nil +// Register wires every room-service RPC onto the natsrouter Router. +// Register/RegisterNoBody panic on subscription failure (fatal at startup). +func (h *Handler) Register(r *natsrouter.Router) { + natsrouter.RegisterNoBody(r, subject.MuteTogglePattern(h.siteID), h.muteToggle) + natsrouter.RegisterNoBody(r, subject.FavoriteTogglePattern(h.siteID), h.favoriteToggle) + natsrouter.RegisterNoBody(r, subject.RoomAppTabsPattern(h.siteID), h.getRoomAppTabs) + natsrouter.RegisterNoBody(r, subject.RoomAppCmdMenuPattern(h.siteID), h.getRoomAppCommandMenu) + natsrouter.RegisterNoBody(r, subject.OrgMembersPattern(h.siteID), h.listOrgMembers) + natsrouter.RegisterNoBody(r, subject.MemberListPattern(h.siteID), h.listMembers) + natsrouter.RegisterNoBody(r, subject.MemberStatusesPattern(h.siteID), h.listMemberStatuses) + natsrouter.RegisterNoBody(r, subject.MentionableSubscriptionsPattern(h.siteID), h.listMentionableSubscriptions) + natsrouter.RegisterNoBody(r, subject.RoomKeyGetPattern(h.siteID), h.getRoomKey) + natsrouter.RegisterNoBody(r, subject.MessageReadPattern(h.siteID), h.messageRead) + natsrouter.Register(r, subject.MessageReadReceiptPattern(h.siteID), h.messageReadReceipt) + natsrouter.Register(r, subject.MessageThreadReadPattern(h.siteID), h.messageThreadRead) + natsrouter.Register(r, subject.MemberRoleUpdatePattern(h.siteID), h.updateRole) + natsrouter.Register(r, subject.MemberRemovePattern(h.siteID), h.removeMember) + natsrouter.Register(r, subject.MemberAddPattern(h.siteID), h.addMembers) + natsrouter.Register(r, subject.RoomRenamePattern(h.siteID), h.roomRename) + natsrouter.Register(r, subject.RoomRestricted(h.siteID), h.roomRestricted) + natsrouter.Register(r, subject.RoomsInfoBatchSubscribe(h.siteID), h.roomsInfoBatch) + natsrouter.Register(r, subject.RoomKeyEnsure(h.siteID), h.ensureRoomKey) + natsrouter.Register(r, subject.RoomCreatePattern(h.siteID), h.createRoom) } -func (h *Handler) natsCreateRoom(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleCreateRoom(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to create-room", "error", err) - } -} - -func (h *Handler) handleCreateRoom(ctx context.Context, subj string, data []byte) ([]byte, error) { - requesterAccount, ok := subject.ParseRoomCreateSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid create-room subject: %s", subj) - } - - var req model.CreateRoomRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } +func (h *Handler) createRoom(c *natsrouter.Context, req model.CreateRoomRequest) (*model.CreateRoomReply, error) { //nolint:gocritic // hugeParam: req is passed by value to satisfy the natsrouter.Register handler signature + var ctx context.Context = c + requesterAccount := c.Param("account") roomType, err := classifyAndValidate(&req, requesterAccount) if err != nil { @@ -261,7 +177,7 @@ func classifyAndValidate(req *model.CreateRoomRequest, requesterAccount string) // maxChannelNameRunes caps the rune length of a client-supplied channel name. const maxChannelNameRunes = 100 -func (h *Handler) handleCreateRoomDMOrBotDM(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, roomType model.RoomType) ([]byte, error) { +func (h *Handler) handleCreateRoomDMOrBotDM(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, roomType model.RoomType) (*model.CreateRoomReply, error) { otherAccount := req.Users[0] other, err := h.store.GetUser(ctx, otherAccount) if err != nil { @@ -289,10 +205,10 @@ func (h *Handler) handleCreateRoomDMOrBotDM(ctx context.Context, req *model.Crea // DM already exists: this is a success ("open-or-create"), not an error. // Return the existing room ID so the client opens it. RoomType is left // empty on this branch, matching the prior error-reply behaviour. - return json.Marshal(model.CreateRoomReply{ + return &model.CreateRoomReply{ Status: model.CreateRoomStatusExists, RoomID: existing.RoomID, - }) + }, nil } if err != nil && !errors.Is(err, model.ErrSubscriptionNotFound) { return nil, fmt.Errorf("dm dedup check: %w", err) @@ -314,7 +230,7 @@ func (h *Handler) handleCreateRoomDMOrBotDM(ctx context.Context, req *model.Crea return h.publishCreateRoom(ctx, req, requester, roomType) } -func (h *Handler) handleCreateRoomChannel(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, requesterAccount string, roomType model.RoomType) ([]byte, error) { +func (h *Handler) handleCreateRoomChannel(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, requesterAccount string, roomType model.RoomType) (*model.CreateRoomReply, error) { channelOrgIDs, channelAccounts, err := h.expandChannelRefs(ctx, requester.Account, req.Channels) if err != nil { return nil, fmt.Errorf("expand channels: %w", err) @@ -329,7 +245,7 @@ func (h *Handler) handleCreateRoomChannel(ctx context.Context, req *model.Create } // Reject phantom orgs and users before sizing/publishing (run concurrently), - // same reason as handleAddMembers: the worker writes room_members + sys-msg + // same reason as addMembers: the worker writes room_members + sys-msg // without rechecking validity. if err := h.validateMembershipRefs(ctx, allOrgs, allUsers); err != nil { return nil, err @@ -366,7 +282,7 @@ func (h *Handler) handleCreateRoomChannel(ctx context.Context, req *model.Create return h.publishCreateRoom(ctx, req, requester, roomType) } -func (h *Handler) publishCreateRoom(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, roomType model.RoomType) ([]byte, error) { +func (h *Handler) publishCreateRoom(ctx context.Context, req *model.CreateRoomRequest, requester *model.User, roomType model.RoomType) (*model.CreateRoomReply, error) { req.RequesterID = requester.ID req.RequesterAccount = requester.Account req.Timestamp = time.Now().UTC().UnixMilli() @@ -407,131 +323,66 @@ func (h *Handler) publishCreateRoom(ctx context.Context, req *model.CreateRoomRe if err := h.publishToStream(ctx, subject.RoomCanonical(h.siteID, "create"), payload, ""); err != nil { return nil, fmt.Errorf("publish canonical: %w", err) } - return json.Marshal(model.CreateRoomReply{ + return &model.CreateRoomReply{ Status: model.CreateRoomReplyAccepted, RoomID: req.RoomID, RoomType: string(roomType), - }) + }, nil } -// NatsHandleRemoveMember handles remove-member authorization requests. -func (h *Handler) NatsHandleRemoveMember(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleRemoveMember(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to message", "error", err) - } -} - -func (h *Handler) natsListMembers(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleListMembers(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - natsutil.ReplyJSON(m.Msg, resp) -} - -func (h *Handler) natsListOrgMembers(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleListOrgMembers(ctx, m.Msg.Subject) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - natsutil.ReplyJSON(m.Msg, resp) -} - -func (h *Handler) handleListOrgMembers(ctx context.Context, subj string) (model.ListOrgMembersResponse, error) { - orgID, _, ok := subject.ParseOrgMembersSubject(subj) - if !ok { - return model.ListOrgMembersResponse{}, fmt.Errorf("invalid org-members subject") - } +func (h *Handler) listOrgMembers(c *natsrouter.Context) (*model.ListOrgMembersResponse, error) { + var ctx context.Context = c + orgID := c.Param("orgID") members, err := h.store.ListOrgMembers(ctx, orgID) if err != nil { if errcode.HasReason(err, errcode.RoomInvalidOrg) { - return model.ListOrgMembersResponse{}, errcode.BadRequest("invalid org", errcode.WithReason(errcode.RoomInvalidOrg)) + return nil, errcode.BadRequest("invalid org", errcode.WithReason(errcode.RoomInvalidOrg)) } - return model.ListOrgMembersResponse{}, fmt.Errorf("get org members: %w", err) + return nil, fmt.Errorf("get org members: %w", err) } - return model.ListOrgMembersResponse{Members: members}, nil + return &model.ListOrgMembersResponse{Members: members}, nil } -func (h *Handler) handleListMembers(ctx context.Context, subj string, data []byte) (model.ListRoomMembersResponse, error) { - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return model.ListRoomMembersResponse{}, fmt.Errorf("invalid list-members subject") - } +func (h *Handler) listMembers(c *natsrouter.Context) (*model.ListRoomMembersResponse, error) { + var ctx context.Context = c + requesterAccount := c.Param("account") + roomID := c.Param("roomID") _, err := h.store.GetSubscription(ctx, requesterAccount, roomID) switch { case errors.Is(err, model.ErrSubscriptionNotFound): - return model.ListRoomMembersResponse{}, errNotRoomMember + return nil, errNotRoomMember case err != nil: - return model.ListRoomMembersResponse{}, fmt.Errorf("check room membership: %w", err) + return nil, fmt.Errorf("check room membership: %w", err) } var req model.ListRoomMembersRequest - if len(data) > 0 { - if err := json.Unmarshal(data, &req); err != nil { - return model.ListRoomMembersResponse{}, errcode.BadRequest("invalid request") + if c.Msg != nil && len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { + return nil, errcode.BadRequest("invalid request") } } if req.Limit != nil && *req.Limit <= 0 { - return model.ListRoomMembersResponse{}, errListLimitInvalid + return nil, errListLimitInvalid } if req.Offset != nil && *req.Offset < 0 { - return model.ListRoomMembersResponse{}, errListOffsetInvalid + return nil, errListOffsetInvalid } members, err := h.store.ListRoomMembers(ctx, roomID, req.Limit, req.Offset, req.Enrich) if err != nil { - return model.ListRoomMembersResponse{}, fmt.Errorf("get room members: %w", err) + return nil, fmt.Errorf("get room members: %w", err) } - return model.ListRoomMembersResponse{Members: members}, nil + return &model.ListRoomMembersResponse{Members: members}, nil } -func (h *Handler) natsGetRoomKey(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleGetRoomKey(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to get room key", "error", err) - } -} - -func (h *Handler) handleGetRoomKey(ctx context.Context, subj string, data []byte) ([]byte, error) { +func (h *Handler) getRoomKey(c *natsrouter.Context) (*model.RoomKeyGetResponse, error) { + var ctx context.Context = c if h.keyStore == nil { return nil, fmt.Errorf("get room key: key store not configured") } - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid get-room-key subject") - } + requesterAccount := c.Param("account") + roomID := c.Param("roomID") _, err := h.store.GetSubscription(ctx, requesterAccount, roomID) switch { @@ -542,8 +393,8 @@ func (h *Handler) handleGetRoomKey(ctx context.Context, subj string, data []byte } var req model.RoomKeyGetRequest - if len(data) > 0 { - if err := json.Unmarshal(data, &req); err != nil { + if c.Msg != nil && len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { return nil, errcode.BadRequest("invalid request") } } @@ -557,11 +408,11 @@ func (h *Handler) handleGetRoomKey(ctx context.Context, subj string, data []byte return nil, errRoomKeyAbsent } // #nosec G117 -- RoomKeyGetResponse.PrivateKey is the intended payload: on-demand key delivery to the authorized room member over an auth-callout-gated per-user NATS subject, not a leak - return json.Marshal(model.RoomKeyGetResponse{ + return &model.RoomKeyGetResponse{ RoomID: roomID, Version: existing.Version, PrivateKey: existing.KeyPair.PrivateKey, - }) + }, nil } pair, err := h.keyStore.GetByVersion(ctx, roomID, *req.Version) @@ -572,11 +423,11 @@ func (h *Handler) handleGetRoomKey(ctx context.Context, subj string, data []byte return nil, errRoomKeyAbsent } // #nosec G117 -- RoomKeyGetResponse.PrivateKey is the intended payload: on-demand key delivery to the authorized room member over an auth-callout-gated per-user NATS subject, not a leak - return json.Marshal(model.RoomKeyGetResponse{ + return &model.RoomKeyGetResponse{ RoomID: roomID, Version: *req.Version, PrivateKey: pair.PrivateKey, - }) + }, nil } const ( @@ -584,20 +435,6 @@ const ( defaultMentionableLimit = 3 ) -func (h *Handler) natsListMemberStatuses(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleListMemberStatuses(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - natsutil.ReplyJSON(m.Msg, resp) -} - // requireMembershipAndGetRoom checks the requester's room membership and // loads the room document in parallel — both reads are independent and the // second RTT is wasted on the happy path. Uses sync.WaitGroup (not @@ -636,11 +473,10 @@ func (h *Handler) requireMembershipAndGetRoom(ctx context.Context, account, room return room, nil } -func (h *Handler) handleListMemberStatuses(ctx context.Context, subj string, data []byte) (model.ListMemberStatusesResponse, error) { - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return model.ListMemberStatusesResponse{}, errcode.BadRequest("invalid member-statuses subject") - } +func (h *Handler) listMemberStatuses(c *natsrouter.Context) (*model.ListMemberStatusesResponse, error) { + var ctx context.Context = c + requesterAccount := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( attribute.String("room.id", roomID), @@ -649,59 +485,43 @@ func (h *Handler) handleListMemberStatuses(ctx context.Context, subj string, dat } var req model.ListMemberStatusesRequest - if len(data) > 0 { - if err := json.Unmarshal(data, &req); err != nil { - return model.ListMemberStatusesResponse{}, errcode.BadRequest("invalid request") + if c.Msg != nil && len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { + return nil, errcode.BadRequest("invalid request") } } room, err := h.requireMembershipAndGetRoom(ctx, requesterAccount, roomID) if err != nil { - return model.ListMemberStatusesResponse{}, err + return nil, err } - // Clamp the default to the room cap so a small room with a no-limit - // request doesn't trip the explicit-limit guard. Client-supplied values - // remain strictly validated. + // Clamp the default to the room cap so a small no-limit room doesn't trip + // the explicit-limit guard. Client-supplied values stay strictly validated. var limit int if req.Limit == nil { if room.UserCount == 0 { - return model.ListMemberStatusesResponse{Members: []model.MemberStatus{}}, nil + return &model.ListMemberStatusesResponse{Members: []model.MemberStatus{}}, nil } limit = min(defaultMemberStatusesLimit, room.UserCount) } else { limit = *req.Limit if limit <= 0 || limit > room.UserCount { - return model.ListMemberStatusesResponse{}, errMemberStatusesLimitInvalid + return nil, errMemberStatusesLimitInvalid } } members, err := h.store.ListMemberStatuses(ctx, roomID, limit) if err != nil { - return model.ListMemberStatusesResponse{}, fmt.Errorf("list member statuses: %w", err) + return nil, fmt.Errorf("list member statuses: %w", err) } - return model.ListMemberStatusesResponse{Members: members}, nil + return &model.ListMemberStatusesResponse{Members: members}, nil } -func (h *Handler) natsListMentionableSubscriptions(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleListMentionableSubscriptions(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - natsutil.ReplyJSON(m.Msg, resp) -} - -func (h *Handler) handleListMentionableSubscriptions(ctx context.Context, subj string, data []byte) (model.MentionableSubscriptionsResponse, error) { - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return model.MentionableSubscriptionsResponse{}, errcode.BadRequest("invalid mentionable-subscriptions subject") - } +func (h *Handler) listMentionableSubscriptions(c *natsrouter.Context) (*model.MentionableSubscriptionsResponse, error) { + var ctx context.Context = c + requesterAccount := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( attribute.String("room.id", roomID), @@ -710,28 +530,28 @@ func (h *Handler) handleListMentionableSubscriptions(ctx context.Context, subj s } var req model.MentionableSubscriptionsRequest - if len(data) > 0 { - if err := json.Unmarshal(data, &req); err != nil { - return model.MentionableSubscriptionsResponse{}, errcode.BadRequest("invalid request") + if c.Msg != nil && len(c.Msg.Data) > 0 { + if err := json.Unmarshal(c.Msg.Data, &req); err != nil { + return nil, errcode.BadRequest("invalid request") } } room, err := h.requireMembershipAndGetRoom(ctx, requesterAccount, roomID) if err != nil { - return model.MentionableSubscriptionsResponse{}, err + return nil, err } mentionableCap := room.UserCount + room.AppCount var limit int if req.Limit == nil { if mentionableCap == 0 { - return model.MentionableSubscriptionsResponse{Subscriptions: []model.MentionableSubscription{}}, nil + return &model.MentionableSubscriptionsResponse{Subscriptions: []model.MentionableSubscription{}}, nil } limit = min(defaultMentionableLimit, mentionableCap) } else { limit = *req.Limit if limit <= 0 || limit > mentionableCap { - return model.MentionableSubscriptionsResponse{}, errMentionableLimitInvalid + return nil, errMentionableLimitInvalid } } @@ -741,21 +561,15 @@ func (h *Handler) handleListMentionableSubscriptions(ctx context.Context, subj s subs, err := h.store.ListMentionableSubscriptions(ctx, roomID, requesterAccount, escapedFilter, limit) if err != nil { - return model.MentionableSubscriptionsResponse{}, fmt.Errorf("list mentionable subscriptions: %w", err) + return nil, fmt.Errorf("list mentionable subscriptions: %w", err) } - return model.MentionableSubscriptionsResponse{Subscriptions: subs}, nil + return &model.MentionableSubscriptionsResponse{Subscriptions: subs}, nil } -func (h *Handler) handleRemoveMember(ctx context.Context, subj string, data []byte) ([]byte, error) { - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid remove-member subject: %s", subj) - } - - var req model.RemoveMemberRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } +func (h *Handler) removeMember(c *natsrouter.Context, req model.RemoveMemberRequest) (*model.StatusReply, error) { //nolint:gocritic // hugeParam: req is passed by value to satisfy the natsrouter.Register handler signature + var ctx context.Context = c + requesterAccount := c.Param("account") + roomID := c.Param("roomID") if req.RoomID != "" && req.RoomID != roomID { return nil, errRoomIDMismatch @@ -824,7 +638,7 @@ func (h *Handler) handleRemoveMember(ctx context.Context, subj string, data []by req.Timestamp = time.Now().UTC().UnixMilli() // Publish to ROOMS stream for room-worker processing. - data, err = json.Marshal(req) + data, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal remove member request: %w", err) } @@ -832,34 +646,13 @@ func (h *Handler) handleRemoveMember(ctx context.Context, subj string, data []by return nil, fmt.Errorf("publish to stream: %w", err) } - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } -func (h *Handler) natsUpdateRole(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleUpdateRole(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to update-role message", "error", err) - } -} - -func (h *Handler) handleUpdateRole(ctx context.Context, subj string, data []byte) ([]byte, error) { - requester, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid subject: %s", subj) - } - var req model.UpdateRoleRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } +func (h *Handler) updateRole(c *natsrouter.Context, req model.UpdateRoleRequest) (*model.StatusReply, error) { + var ctx context.Context = c + requester := c.Param("account") + roomID := c.Param("roomID") if req.RoomID != "" && req.RoomID != roomID { return nil, errRoomIDMismatch } @@ -945,7 +738,7 @@ func (h *Handler) handleUpdateRole(ctx context.Context, subj string, data []byte } } - return json.Marshal(map[string]string{"status": "ok"}) + return &model.StatusReply{Status: "ok"}, nil } // publishSubscriptionUpdate marshals a SubscriptionUpdateEvent for sub with the @@ -970,28 +763,11 @@ func (h *Handler) publishSubscriptionUpdate(ctx context.Context, account, action return data, nil } -func (h *Handler) natsAddMembers(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleAddMembers(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to add-members", "error", err) - } -} - -func (h *Handler) handleAddMembers(ctx context.Context, subj string, data []byte) ([]byte, error) { - // 1. Parse subject → requester, roomID - requester, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid add-members subject: %s", subj) - } +func (h *Handler) addMembers(c *natsrouter.Context, req model.AddMembersRequest) (*model.StatusReply, error) { //nolint:gocritic // hugeParam: req is passed by value to satisfy the natsrouter.Register handler signature + var ctx context.Context = c + // 1. Subject params → requester, roomID + requester := c.Param("account") + roomID := c.Param("roomID") // 2. Verify requester is in room. Distinguish "not a member" (typed // forbidden — the user genuinely can't add members) from an infra failure @@ -1016,11 +792,7 @@ func (h *Handler) handleAddMembers(ctx context.Context, subj string, data []byte return nil, errOnlyOwnersCanAddToRes } - // 4. Unmarshal request - var req model.AddMembersRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } + // 4. Cross-check optional body roomID against the subject roomID. if req.RoomID != "" && req.RoomID != roomID { return nil, errRoomIDMismatch } @@ -1090,7 +862,7 @@ func (h *Handler) handleAddMembers(ctx context.Context, subj string, data []byte } // 10. Reply accepted - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } // validateAccountsExist returns a RoomUserNotFound-reason errcode naming the @@ -1242,28 +1014,9 @@ func (h *Handler) expandChannelRefs(ctx context.Context, requester string, refs return orgIDs, accounts, nil } -func (h *Handler) natsRoomsInfoBatch(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleRoomsInfoBatch(ctx, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to message", "error", err) - } -} - -func (h *Handler) handleRoomsInfoBatch(ctx context.Context, data []byte) ([]byte, error) { +func (h *Handler) roomsInfoBatch(c *natsrouter.Context, req model.RoomsInfoBatchRequest) (*model.RoomsInfoBatchResponse, error) { + var ctx context.Context = c start := time.Now() - var req model.RoomsInfoBatchRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } if len(req.RoomIDs) == 0 { return nil, errcode.BadRequest("roomIds must not be empty") } @@ -1316,7 +1069,7 @@ func (h *Handler) handleRoomsInfoBatch(ctx context.Context, data []byte) ([]byte "latency_ms", time.Since(start).Milliseconds(), ) - return json.Marshal(model.RoomsInfoBatchResponse{Rooms: infos}) + return &model.RoomsInfoBatchResponse{Rooms: infos}, nil } func (h *Handler) aggregateRoomInfo(ids []string, rooms []model.Room, keys map[string]*roomkeystore.VersionedKeyPair) ([]model.RoomInfo, int, int) { @@ -1380,28 +1133,10 @@ func chunkedGetKeys(ctx context.Context, ks RoomKeyStore, ids []string) (map[str return merged, nil } -func (h *Handler) natsMessageRead(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleMessageRead(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to message read", "error", err) - } -} - -func (h *Handler) handleMessageRead(ctx context.Context, subj string, _ []byte) ([]byte, error) { - - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid message-read subject: %s", subj) - } +func (h *Handler) messageRead(c *natsrouter.Context) (*model.StatusReply, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") sub, err := h.store.GetSubscription(ctx, account, roomID) switch { @@ -1477,10 +1212,10 @@ func (h *Handler) handleMessageRead(ctx context.Context, subj string, _ []byte) // Skip the room-floor recompute when the room has no content, or when // this user already had a recorded read past the latest message if room.LastMsgAt == nil { - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } if sub.LastSeenAt != nil && sub.LastSeenAt.After(*room.LastMsgAt) { - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } minTime, err := h.store.MinSubscriptionLastSeenByRoomID(ctx, roomID) @@ -1498,35 +1233,14 @@ func (h *Handler) handleMessageRead(ctx context.Context, subj string, _ []byte) } } - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } -func (h *Handler) natsMessageReadReceipt(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleMessageReadReceipt(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to message read-receipt", "error", err) - } -} +func (h *Handler) messageReadReceipt(c *natsrouter.Context, req model.ReadReceiptRequest) (*model.ReadReceiptResponse, error) { + var ctx context.Context = c + requesterAccount := c.Param("account") + roomID := c.Param("roomID") -func (h *Handler) handleMessageReadReceipt(ctx context.Context, subj string, data []byte) ([]byte, error) { - requesterAccount, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid message-read-receipt subject: %s", subj) - } - - var req model.ReadReceiptRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } if req.MessageID == "" { return nil, errcode.BadRequest("invalid request: messageId is required") } @@ -1594,35 +1308,14 @@ func (h *Handler) handleMessageReadReceipt(ctx context.Context, subj string, dat } } - return json.Marshal(model.ReadReceiptResponse{Readers: entries}) -} - -func (h *Handler) natsMessageThreadRead(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleMessageThreadRead(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to message thread-read", "error", err) - } + return &model.ReadReceiptResponse{Readers: entries}, nil } -func (h *Handler) handleMessageThreadRead(ctx context.Context, subj string, data []byte) ([]byte, error) { - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid message-thread-read subject: %s", subj) - } +func (h *Handler) messageThreadRead(c *natsrouter.Context, req model.MessageThreadReadRequest) (*model.StatusReply, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") - var req model.MessageThreadReadRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, errcode.BadRequest("invalid request") - } if strings.TrimSpace(req.ThreadID) == "" { return nil, errInvalidThreadID } @@ -1723,44 +1416,21 @@ func (h *Handler) handleMessageThreadRead(ctx context.Context, subj string, data } } - return json.Marshal(map[string]string{"status": "accepted"}) + return &model.StatusReply{Status: "accepted"}, nil } -// NatsHandleEnsureRoomKey handles server-to-server requests to ensure a room +// ensureRoomKey handles server-to-server requests to ensure a room // has an encryption key pair in Valkey. Generates and stores a new pair if // missing. The reply confirms the room and version but does not return key // bytes — encryption/decryption is performed by broadcast-worker and clients, // which read keys from Valkey directly. -func (h *Handler) NatsHandleEnsureRoomKey(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleEnsureRoomKey(ctx, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to ensure room key", "error", err) - } -} - -func (h *Handler) handleEnsureRoomKey(ctx context.Context, data []byte) ([]byte, error) { +func (h *Handler) ensureRoomKey(c *natsrouter.Context, req model.RoomKeyEnsureRequest) (*model.RoomKeyEnsureResponse, error) { + var ctx context.Context = c if h.keyStore == nil { // Local Valkey disabled — surfaces to peer sites as a transient outage // (symmetric with the timeout-class failures in :808/:819/:828). return nil, errcode.Unavailable("room key store not configured") } - var req model.RoomKeyEnsureRequest - if err := json.Unmarshal(data, &req); err != nil { - // Per doc.go and pkg/errcode logging contract: json.SyntaxError / - // UnmarshalTypeError strings embed the offending substring and field - // shape from an unauthenticated payload — never WithCause(err) here. - // Same shape as message-gatekeeper:173. - return nil, errcode.BadRequest("invalid ensure-room-key request") - } if req.RoomID == "" { return nil, errcode.BadRequest("roomId is required") } @@ -1770,10 +1440,10 @@ func (h *Handler) handleEnsureRoomKey(ctx context.Context, data []byte) ([]byte, return nil, fmt.Errorf("ensure room key: get: %w", err) } if existing != nil { - return json.Marshal(model.RoomKeyEnsureResponse{ + return &model.RoomKeyEnsureResponse{ RoomID: req.RoomID, Version: existing.Version, - }) + }, nil } newPair, err := roomkeystore.GenerateKeyPair() @@ -1784,51 +1454,27 @@ func (h *Handler) handleEnsureRoomKey(ctx context.Context, data []byte) ([]byte, if err != nil { return nil, fmt.Errorf("ensure room key: set: %w", err) } - return json.Marshal(model.RoomKeyEnsureResponse{ + return &model.RoomKeyEnsureResponse{ RoomID: req.RoomID, Version: ver, - }) + }, nil } -func (h *Handler) natsRoomRename(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleRoomRename(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to rename", "error", err) - } -} - -func (h *Handler) handleRoomRename(ctx context.Context, subj string, data []byte) ([]byte, error) { - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("%w: %s", errInvalidRenameSubject, subj) - } - requestID := natsutil.RequestIDFromContext(ctx) +func (h *Handler) roomRename(c *natsrouter.Context, req model.RoomRenameRequest) (*model.StatusWithRequestReply, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") + requestID := natsutil.RequestIDFromContext(c) // Client body carries only newName — roomID and account are taken from the // subject (the authoritative identity), never from the wire body. - var body struct { - NewName string `json:"newName"` - } - if err := json.Unmarshal(data, &body); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } - slog.Debug("processing room.rename", "op", model.AsyncJobOpRoomRename, "requester", account, "roomID", roomID, - "requestID", requestID) + "request_id", requestID) - name := strings.TrimSpace(body.NewName) + name := strings.TrimSpace(req.NewName) if name == "" || utf8.RuneCountInString(name) > 100 { return nil, errInvalidName } @@ -1875,36 +1521,17 @@ func (h *Handler) handleRoomRename(ctx context.Context, subj string, data []byte if err := h.publishToStream(ctx, subject.RoomCanonical(h.siteID, "room.rename"), out, ""); err != nil { return nil, fmt.Errorf("publish to stream: %w", err) } - return json.Marshal(map[string]string{"status": "accepted", "requestId": requestID}) + return &model.StatusWithRequestReply{Status: "accepted", RequestID: requestID}, nil } -func (h *Handler) natsRoomRestricted(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleRoomRestricted(ctx, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to restricted", "error", err) - } -} - -// handleRoomRestricted is the sync chat.server.> RPC. Account in the body is +// roomRestricted is the sync chat.server.> RPC. Account in the body is // the audit identity (no subject prefix authenticates the caller — this RPC // is server-side admin tooling). Mongo writes + sys-message publish + outbox // fan-out happen inline; caller retries safely via dedup IDs. -func (h *Handler) handleRoomRestricted(ctx context.Context, data []byte) ([]byte, error) { - requestID := natsutil.RequestIDFromContext(ctx) +func (h *Handler) roomRestricted(c *natsrouter.Context, req model.RoomRestrictedRequest) (*model.StatusWithRequestReply, error) { + var ctx context.Context = c + requestID := natsutil.RequestIDFromContext(c) - var req model.RoomRestrictedRequest - if err := json.Unmarshal(data, &req); err != nil { - return nil, fmt.Errorf("invalid request: %w", err) - } if req.RoomID == "" || req.Account == "" { return nil, fmt.Errorf("%w: roomId and account are required", errInvalidRestrictedSubject) } @@ -1913,7 +1540,7 @@ func (h *Handler) handleRoomRestricted(ctx context.Context, data []byte) ([]byte slog.Info("processing room.restricted", "requester", req.Account, "roomID", req.RoomID, - "requestID", requestID) + "request_id", requestID) requesterUser, getUserErr := h.store.GetUser(ctx, req.Account) if getUserErr != nil && !errors.Is(getUserErr, ErrUserNotFound) { @@ -2051,30 +1678,13 @@ func (h *Handler) handleRoomRestricted(ctx context.Context, data []byte) ([]byte } } - return json.Marshal(map[string]string{"status": "ok", "requestId": requestID}) + return &model.StatusWithRequestReply{Status: "ok", RequestID: requestID}, nil } -func (h *Handler) natsMuteToggle(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleMuteToggle(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to mute toggle", "error", err) - } -} - -func (h *Handler) handleMuteToggle(ctx context.Context, subj string, _ []byte) ([]byte, error) { - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid mute-toggle subject: %s", subj) - } +func (h *Handler) muteToggle(c *natsrouter.Context) (*model.MuteToggleResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( @@ -2143,30 +1753,13 @@ func (h *Handler) handleMuteToggle(ctx context.Context, subj string, _ []byte) ( } } - return json.Marshal(model.MuteToggleResponse{Status: "ok", Muted: sub.Muted}) + return &model.MuteToggleResponse{Status: "ok", Muted: sub.Muted}, nil } -func (h *Handler) natsFavoriteToggle(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleFavoriteToggle(ctx, m.Msg.Subject, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - if err := m.Msg.Respond(resp); err != nil { - slog.Error("failed to respond to favorite toggle", "error", err) - } -} - -func (h *Handler) handleFavoriteToggle(ctx context.Context, subj string, _ []byte) ([]byte, error) { - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return nil, fmt.Errorf("invalid favorite-toggle subject: %s", subj) - } +func (h *Handler) favoriteToggle(c *natsrouter.Context) (*model.FavoriteToggleResponse, error) { + var ctx context.Context = c + account := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( @@ -2220,7 +1813,7 @@ func (h *Handler) handleFavoriteToggle(ctx context.Context, subj string, _ []byt } } - return json.Marshal(model.FavoriteToggleResponse{Status: "ok", Favorite: sub.Favorite}) + return &model.FavoriteToggleResponse{Status: "ok", Favorite: sub.Favorite}, nil } // authorizeRoomAppRead allows the request iff the caller has a @@ -2289,14 +1882,12 @@ func (h *Handler) buildTabURL(tmpl, roomID string) (string, bool) { return joined.String(), true } -func (h *Handler) handleGetRoomAppTabs(ctx context.Context, subj string) (model.GetRoomAppTabsResponse, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +func (h *Handler) getRoomAppTabs(c *natsrouter.Context) (*model.GetRoomAppTabsResponse, error) { + ctx, cancel := context.WithTimeout(c, 5*time.Second) defer cancel() - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return model.GetRoomAppTabsResponse{}, errcode.BadRequest("invalid request") - } + account := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( @@ -2307,12 +1898,12 @@ func (h *Handler) handleGetRoomAppTabs(ctx context.Context, subj string) (model. } if err := h.authorizeRoomAppRead(ctx, account, roomID); err != nil { - return model.GetRoomAppTabsResponse{}, err + return nil, err } apps, err := h.store.ListDefaultChannelTabApps(ctx) if err != nil { - return model.GetRoomAppTabsResponse{}, fmt.Errorf("list default channel-tab apps: %w", err) + return nil, fmt.Errorf("list default channel-tab apps: %w", err) } out := make([]model.RoomApp, 0, len(apps)) @@ -2321,14 +1912,14 @@ func (h *Handler) handleGetRoomAppTabs(ctx context.Context, subj string) (model. if app.ChannelTab == nil { slog.Warn("skipping app with nil ChannelTab", "appId", app.ID, "roomId", roomID, - "requestId", natsutil.RequestIDFromContext(ctx)) + "request_id", natsutil.RequestIDFromContext(ctx)) continue } tabURL, ok := h.buildTabURL(app.ChannelTab.URL.Default, roomID) if !ok { slog.Warn("skipping app with empty or unparseable channelTab url", "appId", app.ID, "roomId", roomID, - "requestId", natsutil.RequestIDFromContext(ctx)) + "request_id", natsutil.RequestIDFromContext(ctx)) continue } out = append(out, model.RoomApp{ @@ -2339,17 +1930,15 @@ func (h *Handler) handleGetRoomAppTabs(ctx context.Context, subj string) (model. AvatarURL: app.AvatarURL, }) } - return model.GetRoomAppTabsResponse{Apps: out}, nil + return boundedReply(h, &model.GetRoomAppTabsResponse{Apps: out}) } -func (h *Handler) handleGetRoomAppCommandMenu(ctx context.Context, subj string) (model.GetRoomAppCommandMenuResponse, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +func (h *Handler) getRoomAppCommandMenu(c *natsrouter.Context) (*model.GetRoomAppCommandMenuResponse, error) { + ctx, cancel := context.WithTimeout(c, 5*time.Second) defer cancel() - account, roomID, ok := subject.ParseUserRoomSubject(subj) - if !ok { - return model.GetRoomAppCommandMenuResponse{}, errcode.BadRequest("invalid request") - } + account := c.Param("account") + roomID := c.Param("roomID") if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes( @@ -2360,19 +1949,19 @@ func (h *Handler) handleGetRoomAppCommandMenu(ctx context.Context, subj string) } if err := h.authorizeRoomAppRead(ctx, account, roomID); err != nil { - return model.GetRoomAppCommandMenuResponse{}, err + return nil, err } bots, err := h.store.ListRoomBotApps(ctx, roomID) if err != nil { - return model.GetRoomAppCommandMenuResponse{}, fmt.Errorf("list room bot apps: %w", err) + return nil, fmt.Errorf("list room bot apps: %w", err) } if span := trace.SpanFromContext(ctx); span.IsRecording() { span.SetAttributes(attribute.Int("bot.count", len(bots))) } if len(bots) == 0 { - return model.GetRoomAppCommandMenuResponse{ + return &model.GetRoomAppCommandMenuResponse{ AppAssistants: make([]model.RoomAppAssistant, 0), }, nil } @@ -2383,7 +1972,7 @@ func (h *Handler) handleGetRoomAppCommandMenu(ctx context.Context, subj string) } menus, err := h.store.ListActiveCmdMenus(ctx, names) if err != nil { - return model.GetRoomAppCommandMenuResponse{}, fmt.Errorf("list active cmd menus: %w", err) + return nil, fmt.Errorf("list active cmd menus: %w", err) } byName := make(map[string][]model.CmdBlock, len(menus)) for _, m := range menus { @@ -2398,33 +1987,5 @@ func (h *Handler) handleGetRoomAppCommandMenu(ctx context.Context, subj string) CmdBlocks: byName[b.AssistantName], }) } - return model.GetRoomAppCommandMenuResponse{AppAssistants: out}, nil -} - -func (h *Handler) natsGetRoomAppCommandMenu(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleGetRoomAppCommandMenu(ctx, m.Msg.Subject) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - h.replyBoundedJSON(ctx, m.Msg, resp) -} - -func (h *Handler) natsGetRoomAppTabs(m otelnats.Msg) { - ctx, err := wrappedCtx(m) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - resp, err := h.handleGetRoomAppTabs(ctx, m.Msg.Subject) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - h.replyBoundedJSON(ctx, m.Msg, resp) + return boundedReply(h, &model.GetRoomAppCommandMenuResponse{AppAssistants: out}) } diff --git a/room-service/handler_test.go b/room-service/handler_test.go index 6af44fbed..296390923 100644 --- a/room-service/handler_test.go +++ b/room-service/handler_test.go @@ -12,7 +12,6 @@ import ( "testing" "time" - "github.com/Marz32onE/instrumentation-go/otel-nats/otelnats" "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,6 +21,7 @@ import ( "github.com/hmchangw/chat/pkg/errcode" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeystore" "github.com/hmchangw/chat/pkg/subject" @@ -70,15 +70,10 @@ func TestHandler_UpdateRole_Success(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - resp, err := h.handleUpdateRole(context.Background(), subj, data) + resp, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) - - var result map[string]string - require.NoError(t, json.Unmarshal(resp, &result)) - assert.Equal(t, "ok", result["status"]) + assert.Equal(t, "ok", resp.Status) require.NotNil(t, coreData, "expected subscription.update published via core NATS") assert.Equal(t, subject.SubscriptionUpdate("bob"), coreSubj) @@ -104,10 +99,8 @@ func TestHandler_UpdateRole_NonOwnerRejected(t *testing.T) { } req := model.UpdateRoleRequest{Account: "charlie", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("bob", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "bob", "roomID": "r1"}), req) require.ErrorIs(t, err, errOnlyOwners) } @@ -124,10 +117,8 @@ func TestHandler_UpdateRole_DMRejected(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.ErrorIs(t, err, errRoomTypeGuard) } @@ -140,10 +131,8 @@ func TestHandler_UpdateRole_InvalidRole(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: "admin"} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.ErrorIs(t, err, errInvalidRole) } @@ -169,10 +158,8 @@ func TestHandler_UpdateRole_AlreadyHasRole(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.ErrorIs(t, err, errAlreadyOwner) } @@ -210,10 +197,8 @@ func TestHandler_UpdateRole_PromoteOrgOnlyRejected(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.Error(t, err) assert.ErrorIs(t, err, errPromoteRequiresIndividual) } @@ -255,10 +240,8 @@ func TestHandler_UpdateRole_PromoteSubscriptionOnly_NoRoomMembers_Allowed(t *tes } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) assert.NotNil(t, coreData, "promote must publish subscription.update when target is a bare subscriber in a room with no orgs") } @@ -290,14 +273,10 @@ func TestHandler_UpdateRole_Demote_Success(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleMember} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - resp, err := h.handleUpdateRole(context.Background(), subj, data) + resp, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) - var result map[string]string - require.NoError(t, json.Unmarshal(resp, &result)) - assert.Equal(t, "ok", result["status"]) + assert.Equal(t, "ok", resp.Status) require.NotNil(t, coreData) var evt model.SubscriptionUpdateEvent require.NoError(t, json.Unmarshal(coreData, &evt)) @@ -334,10 +313,8 @@ func TestHandler_UpdateRole_CrossSiteOutbox(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) require.NotNil(t, outboxData, "cross-site target must publish a role_updated outbox event") @@ -380,10 +357,8 @@ func TestHandler_UpdateRole_SetOwnerRoleNotFound(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.Error(t, err) assert.ErrorIs(t, err, errTargetNotMember) } @@ -411,14 +386,10 @@ func TestHandler_UpdateRole_PublishCoreError_NonFatal(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - resp, err := h.handleUpdateRole(context.Background(), subj, data) + resp, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err, "publishCore failure must be non-fatal") - var result map[string]string - require.NoError(t, json.Unmarshal(resp, &result)) - assert.Equal(t, "ok", result["status"]) + assert.Equal(t, "ok", resp.Status) } func TestHandler_UpdateRole_DemoteNonOwner(t *testing.T) { @@ -443,10 +414,8 @@ func TestHandler_UpdateRole_DemoteNonOwner(t *testing.T) { } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleMember} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.ErrorIs(t, err, errNotOwner) } @@ -475,28 +444,13 @@ func TestHandler_UpdateRole_LastOwnerCannotDemote(t *testing.T) { } req := model.UpdateRoleRequest{Account: "alice", NewRole: model.RoleMember} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.ErrorIs(t, err, errCannotDemoteLast) } // --- Error-path tests --- -func TestHandler_UpdateRole_MalformedInput(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) - h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000, - publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, - } - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, []byte("not json")) - if err == nil { - t.Fatal("expected error for malformed input") - } -} - func TestHandler_UpdateRole_GetRoomError(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) @@ -505,9 +459,7 @@ func TestHandler_UpdateRole_GetRoomError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for GetRoom failure") } @@ -521,9 +473,7 @@ func TestHandler_UpdateRole_RoomIDMismatch(t *testing.T) { } // Payload RoomID "r-other" does not match subject RoomID "r1" req := model.UpdateRoleRequest{RoomID: "r-other", Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for RoomID mismatch") } @@ -541,9 +491,7 @@ func TestHandler_UpdateRole_RequesterSubError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for requester subscription failure") } @@ -562,9 +510,7 @@ func TestHandler_UpdateRole_TargetSubError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for target subscription failure") } @@ -588,9 +534,7 @@ func TestHandler_UpdateRole_CountOwnersError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } req := model.UpdateRoleRequest{Account: "alice", NewRole: model.RoleMember} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for CountOwners failure") } @@ -616,9 +560,7 @@ func TestHandler_UpdateRole_PublishError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return fmt.Errorf("nats down") }, } req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - _, err := h.handleUpdateRole(context.Background(), subj, data) + _, err := h.updateRole(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil { t.Fatal("expected error for outbox publish failure") } @@ -648,15 +590,11 @@ func TestHandler_RemoveMember_SelfLeave_Success(t *testing.T) { return nil }, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - resp, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + resp, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, subject.RoomCanonical("site-a", "member.remove"), publishedSubj) - var status map[string]string - require.NoError(t, json.Unmarshal(resp, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) require.NotNil(t, publishedData) var published model.RemoveMemberRequest @@ -688,9 +626,7 @@ func TestHandler_RemoveMember_OrgOnly_Rejected(t *testing.T) { store.EXPECT().GetSubscriptionWithMembership(gomock.Any(), "r1", "alice"). Return(&SubscriptionWithMembership{Subscription: sub, HasOrgMembership: true}, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove(tc.requester, "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: tc.target}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": tc.requester, "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: tc.target}) require.Error(t, err) assert.Contains(t, err.Error(), "org members cannot leave individually") }) @@ -715,9 +651,7 @@ func TestHandler_RemoveMember_SelfLeave_NoOrgs_Allowed(t *testing.T) { publishedData = data return nil }, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.NoError(t, err) require.NotNil(t, publishedData) } @@ -751,9 +685,7 @@ func TestHandler_RemoveMember_LastOwner_Rejected(t *testing.T) { store.EXPECT().CountMembersAndOwners(gomock.Any(), "r1"). Return(&RoomCounts{MemberCount: 3, OwnerCount: 1}, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove(tc.requester, "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": tc.requester, "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "last owner") }) @@ -773,9 +705,7 @@ func TestHandler_RemoveMember_LastMember_Rejected(t *testing.T) { store.EXPECT().CountMembersAndOwners(gomock.Any(), "r1"). Return(&RoomCounts{MemberCount: 1, OwnerCount: 0}, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "last member") } @@ -802,9 +732,7 @@ func TestHandler_RemoveMember_OwnerRemovesOther_Success(t *testing.T) { publishedData = data return nil }, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) - resp, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + resp, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) require.NoError(t, err) require.NotNil(t, resp) require.NotNil(t, publishedData) @@ -826,9 +754,7 @@ func TestHandler_RemoveMember_NonOwnerRemovesOther_Rejected(t *testing.T) { Return(&SubscriptionWithMembership{Subscription: targetSub, HasIndividualMembership: true}, nil) store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1").Return(requesterSub, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) require.Error(t, err) assert.Contains(t, err.Error(), "only owners can remove members") } @@ -847,9 +773,7 @@ func TestHandler_RemoveMember_OwnerRemovesOrg_Success(t *testing.T) { publishedData = data return nil }, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", OrgID: "eng-org"}) - resp, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + resp, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", OrgID: "eng-org"}) require.NoError(t, err) require.NotNil(t, resp) var published model.RemoveMemberRequest @@ -862,9 +786,7 @@ func TestHandler_RemoveMember_BothAccountAndOrgID_Rejected(t *testing.T) { store := NewMockRoomStore(ctrl) store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel}, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "bob", OrgID: "eng-org"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "bob", OrgID: "eng-org"}) require.Error(t, err) assert.Contains(t, err.Error(), "exactly one") } @@ -874,39 +796,16 @@ func TestHandler_RemoveMember_NeitherAccountNorOrgID_Rejected(t *testing.T) { store := NewMockRoomStore(ctrl) store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel}, nil) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - reqBody, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, reqBody) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1"}) require.Error(t, err) assert.Contains(t, err.Error(), "exactly one") } -func TestHandler_RemoveMember_InvalidSubject(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) - handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - _, err := handler.handleRemoveMember(context.Background(), "bogus", []byte("{}")) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid remove-member subject") -} - -func TestHandler_RemoveMember_InvalidJSON(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) - handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - _, err := handler.handleRemoveMember(context.Background(), reqSubj, []byte("{not json")) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid request") -} - func TestHandler_RemoveMember_RoomIDMismatch(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r2", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r2", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "room ID mismatch") } @@ -918,9 +817,7 @@ func TestHandler_RemoveMember_GetTargetError(t *testing.T) { store.EXPECT().GetSubscriptionWithMembership(gomock.Any(), "r1", "alice"). Return(nil, fmt.Errorf("db down")) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "get target subscription") } @@ -937,9 +834,7 @@ func TestHandler_RemoveMember_OwnerRemoves_RequesterLookupError(t *testing.T) { store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1"). Return(nil, fmt.Errorf("db down")) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "bob"}) require.Error(t, err) assert.Contains(t, err.Error(), "get requester subscription") } @@ -956,9 +851,7 @@ func TestHandler_RemoveMember_CountsError(t *testing.T) { store.EXPECT().CountMembersAndOwners(gomock.Any(), "r1"). Return(nil, fmt.Errorf("db down")) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "count members") } @@ -970,9 +863,7 @@ func TestHandler_RemoveMember_OrgPath_RequesterLookupError(t *testing.T) { store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1"). Return(nil, fmt.Errorf("db down")) handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", OrgID: "eng-org"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", OrgID: "eng-org"}) require.Error(t, err) assert.Contains(t, err.Error(), "get requester subscription") } @@ -991,9 +882,7 @@ func TestHandler_RemoveMember_PublishError(t *testing.T) { handler := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, func(_ context.Context, _ string, _ []byte, _ string) error { return fmt.Errorf("nats down") }, nil, nil, 0) - reqSubj := subject.MemberRemove("alice", "r1", "site-a") - body, _ := json.Marshal(model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) - _, err := handler.handleRemoveMember(context.Background(), reqSubj, body) + _, err := handler.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.RemoveMemberRequest{RoomID: "r1", Account: "alice"}) require.Error(t, err) assert.Contains(t, err.Error(), "publish to stream") } @@ -1011,9 +900,7 @@ func TestHandler_RemoveMember_RejectsNonChannelRoom(t *testing.T) { }, } req := model.RemoveMemberRequest{Account: "bob"} - data, _ := json.Marshal(req) - _, err := h.handleRemoveMember(context.Background(), - "chat.user.alice.request.room.r1.site-a.member.remove", data) + _, err := h.removeMember(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) if err == nil || !strings.Contains(err.Error(), "channel") { t.Fatalf("expected channel-type error, got %v", err) } @@ -1037,10 +924,8 @@ func TestHandler_AddMembers_DMRejected(t *testing.T) { } req := model.AddMembersRequest{RoomID: "r1", Users: []string{"bob"}} - data, _ := json.Marshal(req) - subj := subject.MemberAdd("alice", "r1", "site-a") - _, err := h.handleAddMembers(context.Background(), subj, data) + _, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.Error(t, err) assert.Contains(t, err.Error(), "non-channel room") } @@ -1061,10 +946,8 @@ func TestHandler_AddMembers_RestrictedNonOwnerRejected(t *testing.T) { } req := model.AddMembersRequest{RoomID: "r1", Users: []string{"charlie"}} - data, _ := json.Marshal(req) - subj := subject.MemberAdd("bob", "r1", "site-a") - _, err := h.handleAddMembers(context.Background(), subj, data) + _, err := h.addMembers(ctxParams(map[string]string{"account": "bob", "roomID": "r1"}), req) require.Error(t, err) assert.Contains(t, err.Error(), "only owners can add members") } @@ -1089,10 +972,8 @@ func TestHandler_AddMembers_CapacityExceeded(t *testing.T) { } req := model.AddMembersRequest{RoomID: "r1", Users: []string{"u1", "u2", "u3", "u4", "u5"}} - data, _ := json.Marshal(req) - subj := subject.MemberAdd("alice", "r1", "site-a") - _, err := h.handleAddMembers(context.Background(), subj, data) + _, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.Error(t, err) assert.Contains(t, err.Error(), "maximum capacity") } @@ -1115,14 +996,10 @@ func TestHandler_AddMembers_RestrictedOwnerAllowed(t *testing.T) { Return(1, nil) req := model.AddMembersRequest{RoomID: "r1", Users: []string{"bob"}} - reqData, _ := json.Marshal(req) - resp, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), reqData) + resp, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) - - var status map[string]string - require.NoError(t, json.Unmarshal(resp, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) } func TestHandler_AddMembers_EmptyAfterResolve(t *testing.T) { @@ -1143,14 +1020,10 @@ func TestHandler_AddMembers_EmptyAfterResolve(t *testing.T) { Return(0, nil) req := model.AddMembersRequest{RoomID: "r1", Users: []string{"alice"}} - reqData, _ := json.Marshal(req) - resp, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), reqData) + resp, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) - - var status map[string]string - require.NoError(t, json.Unmarshal(resp, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) } func TestHandler_AddMembers_RejectsDirectBot(t *testing.T) { @@ -1168,10 +1041,10 @@ func TestHandler_AddMembers_RejectsDirectBot(t *testing.T) { h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000, publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } - body, _ := json.Marshal(model.AddMembersRequest{ + req := model.AddMembersRequest{ Users: []string{"weather.bot"}, - }) - _, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), body) + } + _, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.Error(t, err) assert.True(t, errors.Is(err, errBotInChannel)) } @@ -1212,11 +1085,11 @@ func TestHandler_AddMembers_SilentlyFiltersBotsFromChannelRefs(t *testing.T) { }, } - body, _ := json.Marshal(model.AddMembersRequest{ + req := model.AddMembersRequest{ Users: []string{}, Channels: []model.ChannelRef{srcRef}, - }) - _, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), body) + } + _, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), req) require.NoError(t, err) var published model.AddMembersRequest @@ -1351,8 +1224,7 @@ func TestHandler_AddMembers_PhantomValidation(t *testing.T) { return nil }, } - body, _ := json.Marshal(tc.req) - _, err := h.handleAddMembers(context.Background(), subject.MemberAdd("alice", "r1", "site-a"), body) + _, err := h.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), tc.req) if tc.wantErr { require.Error(t, err) if tc.wantReason != "" { @@ -1441,8 +1313,7 @@ func TestHandler_CreateRoomChannel_PhantomValidation(t *testing.T) { return nil }, } - body, _ := json.Marshal(tc.req) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), tc.req) if tc.wantErr { require.Error(t, err) if tc.wantReason != "" { @@ -1757,7 +1628,6 @@ func TestHandler_ListMembers(t *testing.T) { const siteID = "site-a" const roomID = "r1" const requester = "alice" - subj := subject.MemberList(requester, roomID, siteID) existingMember := model.RoomMember{ ID: "rm1", RoomID: roomID, Ts: time.Unix(1, 0).UTC(), @@ -1775,15 +1645,13 @@ func TestHandler_ListMembers(t *testing.T) { } tests := []struct { name string - subject string body []byte setupMock func(*MockRoomStore) want want }{ { - name: "happy path returns members", - subject: subj, - body: nil, + name: "happy path returns members", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1793,9 +1661,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{members: []model.RoomMember{orgMember, existingMember}}, }, { - name: "fallback path returns synthesized individuals", - subject: subj, - body: nil, + name: "fallback path returns synthesized individuals", + body: nil, setupMock: func(s *MockRoomStore) { synth := model.RoomMember{ ID: "sub-xyz", RoomID: roomID, Ts: time.Unix(3, 0).UTC(), @@ -1812,9 +1679,8 @@ func TestHandler_ListMembers(t *testing.T) { }}}, }, { - name: "requester not a member", - subject: subj, - body: nil, + name: "requester not a member", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("missing: %w", model.ErrSubscriptionNotFound)) @@ -1822,16 +1688,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errIs: errNotRoomMember}, }, { - name: "invalid subject", - subject: "chat.garbage", - body: nil, - setupMock: func(s *MockRoomStore) {}, - want: want{errContains: "invalid list-members subject"}, - }, - { - name: "invalid JSON body", - subject: subj, - body: []byte("{not json"), + name: "invalid JSON body", + body: []byte("{not json"), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1839,9 +1697,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "invalid request"}, }, { - name: "non-positive limit: negative", - subject: subj, - body: []byte(`{"limit":-1}`), + name: "non-positive limit: negative", + body: []byte(`{"limit":-1}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1849,9 +1706,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "limit must be > 0"}, }, { - name: "non-positive limit: zero", - subject: subj, - body: []byte(`{"limit":0}`), + name: "non-positive limit: zero", + body: []byte(`{"limit":0}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1859,9 +1715,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "limit must be > 0"}, }, { - name: "negative offset", - subject: subj, - body: []byte(`{"offset":-1}`), + name: "negative offset", + body: []byte(`{"offset":-1}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1869,9 +1724,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "offset must be >= 0"}, }, { - name: "pagination passed through", - subject: subj, - body: []byte(`{"limit":10,"offset":5}`), + name: "pagination passed through", + body: []byte(`{"limit":10,"offset":5}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1887,9 +1741,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{members: []model.RoomMember{}}, }, { - name: "auth probe infra error", - subject: subj, - body: nil, + name: "auth probe infra error", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("mongo exploded")) @@ -1897,9 +1750,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "check room membership"}, }, { - name: "store error on list", - subject: subj, - body: nil, + name: "store error on list", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1909,9 +1761,8 @@ func TestHandler_ListMembers(t *testing.T) { want: want{errContains: "get room members"}, }, { - name: "enrich=true passed through to store", - subject: subj, - body: []byte(`{"enrich":true}`), + name: "enrich=true passed through to store", + body: []byte(`{"enrich":true}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -1945,7 +1796,9 @@ func TestHandler_ListMembers(t *testing.T) { tc.setupMock(store) h := &Handler{store: store, siteID: siteID} - resp, err := h.handleListMembers(context.Background(), tc.subject, tc.body) + c := ctxParams(map[string]string{"account": requester, "roomID": roomID}) + c.Msg = &nats.Msg{Data: tc.body} + resp, err := h.listMembers(c) if tc.want.errContains != "" { require.Error(t, err) @@ -1964,9 +1817,32 @@ func TestHandler_ListMembers(t *testing.T) { } } +// TestHandler_ListMembers_EmptyBody locks the optional-body contract: a nil +// request body must reach the happy path, not be rejected as malformed. +func TestHandler_ListMembers_EmptyBody(t *testing.T) { + const ( + siteID = "site-a" + roomID = "r1" + requester = "alice" + ) + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + store.EXPECT().GetSubscription(gomock.Any(), requester, roomID). + Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) + store.EXPECT().ListRoomMembers(gomock.Any(), roomID, (*int)(nil), (*int)(nil), false). + Return([]model.RoomMember{{ID: "rm1", RoomID: roomID, Member: model.RoomMemberEntry{ID: "alice", Type: model.RoomMemberIndividual, Account: "alice"}}}, nil) + + h := &Handler{store: store, siteID: siteID} + c := ctxParams(map[string]string{"account": requester, "roomID": roomID}) + c.Msg = &nats.Msg{Data: nil} + resp, err := h.listMembers(c) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Len(t, resp.Members, 1) +} + func TestHandler_ListOrgMembers(t *testing.T) { const orgID = "sect-eng" - subj := subject.OrgMembers("alice", orgID, "site-a") members := []model.OrgMember{ {ID: "u-a", Account: "a", EngName: "A", ChineseName: "AA", SiteID: "site-a"}, @@ -1981,35 +1857,29 @@ func TestHandler_ListOrgMembers(t *testing.T) { } tests := []struct { name string - subject string + orgID string setupMock func(*MockRoomStore) want want }{ { - name: "happy path returns members", - subject: subj, + name: "happy path returns members", + orgID: orgID, setupMock: func(s *MockRoomStore) { s.EXPECT().ListOrgMembers(gomock.Any(), orgID).Return(members, nil) }, want: want{members: members}, }, { - name: "invalid subject", - subject: "chat.garbage", - setupMock: func(s *MockRoomStore) {}, - want: want{errContains: "invalid org-members subject"}, - }, - { - name: "empty org returns RoomInvalidOrg-reason errcode", - subject: subj, + name: "empty org returns RoomInvalidOrg-reason errcode", + orgID: orgID, setupMock: func(s *MockRoomStore) { s.EXPECT().ListOrgMembers(gomock.Any(), orgID).Return(nil, errcode.BadRequest(fmt.Sprintf("list org members for %q", orgID), errcode.WithReason(errcode.RoomInvalidOrg))) }, want: want{wantReason: errcode.RoomInvalidOrg}, }, { - name: "store error is wrapped", - subject: subj, + name: "store error is wrapped", + orgID: orgID, setupMock: func(s *MockRoomStore) { s.EXPECT().ListOrgMembers(gomock.Any(), orgID). Return(nil, fmt.Errorf("mongo exploded")) @@ -2025,7 +1895,7 @@ func TestHandler_ListOrgMembers(t *testing.T) { tc.setupMock(store) h := &Handler{store: store, siteID: "site-a"} - resp, err := h.handleListOrgMembers(context.Background(), tc.subject) + resp, err := h.listOrgMembers(ctxParams(map[string]string{"account": "alice", "orgID": tc.orgID})) if tc.want.errContains != "" { require.Error(t, err) @@ -2063,15 +1933,15 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { tests := []struct { name string - payload []byte + req model.RoomsInfoBatchRequest setupStore func(*MockRoomStore) setupKeys func(*MockRoomKeyStore) wantErr string assertResp func(t *testing.T, resp model.RoomsInfoBatchResponse) }{ { - name: "happy path — 3 rooms, 2 keyed, order preserved", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r1", "r2", "r3"}}), + name: "happy path — 3 rooms, 2 keyed, order preserved", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r1", "r2", "r3"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r1", "r2", "r3"}).Return([]model.Room{ {ID: "r1", Name: "general", SiteID: "site-a"}, @@ -2111,8 +1981,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { }, }, { - name: "missing room → Found=false, LastMsgAt=nil", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r-missing"}}), + name: "missing room → Found=false, LastMsgAt=nil", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r-missing"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r-missing"}).Return([]model.Room{}, nil) }, @@ -2129,28 +1999,23 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { }, { name: "empty RoomIDs → must not be empty", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{}}), + req: model.RoomsInfoBatchRequest{RoomIDs: []string{}}, wantErr: "must not be empty", }, { name: "oversized batch → exceeds limit", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: func() []string { + req: model.RoomsInfoBatchRequest{RoomIDs: func() []string { ids := make([]string, 101) for i := range ids { ids[i] = fmt.Sprintf("r%d", i) } return ids - }()}), + }()}, wantErr: "exceeds limit", }, { - name: "invalid JSON → invalid request", - payload: []byte("not json"), - wantErr: "invalid request", - }, - { - name: "Mongo error → list rooms by ids", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}), + name: "Mongo error → list rooms by ids", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r1"}).Return(nil, errors.New("mongo timeout")) }, @@ -2160,8 +2025,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { wantErr: "list rooms by ids", }, { - name: "Valkey error → get room keys", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}), + name: "Valkey error → get room keys", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r1"}).Return([]model.Room{{ID: "r1", Name: "x", SiteID: "s"}}, nil).AnyTimes() }, @@ -2171,8 +2036,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { wantErr: "get room keys", }, { - name: "duplicate IDs → 2 entries", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r1", "r1"}}), + name: "duplicate IDs → 2 entries", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r1", "r1"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r1", "r1"}).Return([]model.Room{ {ID: "r1", Name: "general", SiteID: "site-a"}, @@ -2190,8 +2055,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { }, }, { - name: "LastMsgAt set → correct millis", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}), + name: "LastMsgAt set → correct millis", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r1"}}, setupStore: func(s *MockRoomStore) { s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r1"}).Return([]model.Room{ {ID: "r1", Name: "general", SiteID: "site-a", LastMsgAt: &now}, @@ -2207,8 +2072,8 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { }, }, { - name: "LastMsgAt zero-time in Mongo → nil in response", - payload: mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: []string{"r-zero"}}), + name: "LastMsgAt zero-time in Mongo → nil in response", + req: model.RoomsInfoBatchRequest{RoomIDs: []string{"r-zero"}}, setupStore: func(s *MockRoomStore) { zero := time.Time{} s.EXPECT().ListRoomsByIDs(gomock.Any(), []string{"r-zero"}).Return([]model.Room{ @@ -2251,7 +2116,7 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { maxBatchSize: 100, } - resp, err := h.handleRoomsInfoBatch(context.Background(), tc.payload) + resp, err := h.roomsInfoBatch(ctxParams(map[string]string{}), tc.req) if tc.wantErr != "" { require.Error(t, err) @@ -2260,10 +2125,9 @@ func TestHandler_handleRoomsInfoBatch(t *testing.T) { } require.NoError(t, err) - var batchResp model.RoomsInfoBatchResponse - require.NoError(t, json.Unmarshal(resp, &batchResp)) + require.NotNil(t, resp) if tc.assertResp != nil { - tc.assertResp(t, batchResp) + tc.assertResp(t, *resp) } }) } @@ -2294,123 +2158,27 @@ func TestHandler_handleRoomsInfoBatch_chunking(t *testing.T) { maxBatchSize: 1000, } - payload := mustJSON(t, model.RoomsInfoBatchRequest{RoomIDs: ids}) - resp, err := h.handleRoomsInfoBatch(context.Background(), payload) + resp, err := h.roomsInfoBatch(ctxParams(map[string]string{}), model.RoomsInfoBatchRequest{RoomIDs: ids}) require.NoError(t, err) + require.NotNil(t, resp) - var batchResp model.RoomsInfoBatchResponse - require.NoError(t, json.Unmarshal(resp, &batchResp)) - assert.Len(t, batchResp.Rooms, 600) - for i, ri := range batchResp.Rooms { + assert.Len(t, resp.Rooms, 600) + for i, ri := range resp.Rooms { assert.Equal(t, fmt.Sprintf("r%d", i), ri.RoomID) assert.False(t, ri.Found) } } -func TestHandler_handleUpdateRole_PropagatesRequestID(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) +// --- createRoom tests --- - store.EXPECT(). - GetRoom(gomock.Any(), "r1"). - Return(&model.Room{ID: "r1", Name: "general", Type: model.RoomTypeChannel}, nil) - store.EXPECT(). - GetSubscription(gomock.Any(), "alice", "r1"). - Return(&model.Subscription{User: model.SubscriptionUser{ID: "u1", Account: "alice"}, RoomID: "r1", Roles: []model.Role{model.RoleOwner}}, nil) - store.EXPECT(). - GetSubscriptionWithMembership(gomock.Any(), "r1", "bob"). - Return(&SubscriptionWithMembership{ - Subscription: &model.Subscription{User: model.SubscriptionUser{ID: "u2", Account: "bob"}, RoomID: "r1", Roles: []model.Role{model.RoleMember}}, - HasIndividualMembership: true, - }, nil) - - store.EXPECT().SetOwnerRole(gomock.Any(), "r1", "bob", true). - Return(&model.Subscription{User: model.SubscriptionUser{ID: "u2", Account: "bob"}, RoomID: "r1", Roles: []model.Role{model.RoleMember, model.RoleOwner}}, nil) - store.EXPECT().GetUserSiteID(gomock.Any(), "bob").Return("site-a", nil) +const testRequestID = "01970a4f-8c2d-7c9a-abcd-e0123456789f" - var capturedHeader nats.Header - h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000, - publishCore: func(ctx context.Context, _ string, _ []byte) error { - capturedHeader = natsutil.HeaderForContext(ctx) - return nil - }, - publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, - } - - ctx := natsutil.WithRequestID(context.Background(), "req-room-svc-test") - req := model.UpdateRoleRequest{Account: "bob", NewRole: model.RoleOwner} - data, _ := json.Marshal(req) - subj := subject.MemberRoleUpdate("alice", "r1", "site-a") - - _, err := h.handleUpdateRole(ctx, subj, data) - require.NoError(t, err) - require.NotNil(t, capturedHeader, "publish wrapper must build header from ctx") - assert.Equal(t, "req-room-svc-test", capturedHeader.Get(natsutil.RequestIDHeader)) -} - -func TestWrappedCtx_PropagatesValidUUIDFromHeader(t *testing.T) { - const inbound = "01970a4f-8c2d-7c9a-abcd-e0123456789f" - rawMsg := &nats.Msg{ - Subject: "chat.room.test", - Data: []byte("ignored"), - Header: nats.Header{natsutil.RequestIDHeader: []string{inbound}}, - } - m := otelnats.Msg{Msg: rawMsg, Ctx: context.Background()} - - got, err := wrappedCtx(m) - - require.NoError(t, err) - assert.Equal(t, inbound, natsutil.RequestIDFromContext(got), - "valid inbound UUID must pass through unchanged") -} - -// room-service handlers feed dedup-critical paths in room-worker -// (OutboxDedupID, messageDedupSeed, idgen.MessageIDFromRequestID) where a -// server-side mint would break client-retry dedup. wrappedCtx therefore uses -// the strict natsutil.RequireRequestID and surfaces an errcode.BadRequest when -// the inbound header is missing or malformed. -func TestWrappedCtx_MalformedHeaderRejects(t *testing.T) { - rawMsg := &nats.Msg{ - Subject: "chat.room.test", - Data: []byte("ignored"), - Header: nats.Header{natsutil.RequestIDHeader: []string{"not-a-uuid"}}, - } - m := otelnats.Msg{Msg: rawMsg, Ctx: context.Background()} - - _, err := wrappedCtx(m) - - require.Error(t, err) - var ec *errcode.Error - require.True(t, errors.As(err, &ec)) - assert.Equal(t, errcode.CodeBadRequest, ec.Code) -} - -func TestWrappedCtx_NoHeaderRejects(t *testing.T) { - rawMsg := &nats.Msg{ - Subject: "chat.room.test", - Data: []byte("ignored"), - Header: nats.Header{}, - } - m := otelnats.Msg{Msg: rawMsg, Ctx: context.Background()} - - _, err := wrappedCtx(m) - - require.Error(t, err) - var ec *errcode.Error - require.True(t, errors.As(err, &ec)) - assert.Equal(t, errcode.CodeBadRequest, ec.Code) -} - -// --- Phase 5c: handleCreateRoom (3-arg) tests --- - -// ctxWithReqID returns a context carrying a valid UUIDv7 request ID. -func ctxWithReqID() context.Context { - return natsutil.WithRequestID(context.Background(), idgen.GenerateRequestID()) -} - -// createRoomSubj builds the standard 7-token room.create subject for account/site. -func createRoomSubj(account, siteID string) string { - return subject.RoomCreate(account, siteID) +// ctxParams builds a *natsrouter.Context with subject params and a valid +// request ID on the underlying ctx (for handlers that echo/read it). +func ctxParams(params map[string]string) *natsrouter.Context { + c := natsrouter.NewContext(params) + c.SetContext(natsutil.WithRequestID(context.Background(), testRequestID)) + return c } // aliceUser is a helper that returns a fully populated User for "alice". @@ -2428,21 +2196,8 @@ func botUser() *model.User { return &model.User{ID: "u-bot", Account: "helper.bot", EngName: "Helper Bot", ChineseName: "助理機器人"} } -func TestHandleCreateRoom_InvalidSubject(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) - h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), "bad.subject", body) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid create-room subject") -} - -// Boundary-level reject behavior is tested via wrappedCtx (above); the -// helper itself is unit-tested in pkg/natsutil.RequireRequestID. The -// dedup-critical paths fanned out from room-service make server-side minting -// unsafe — see docs/error-handling.md §3a. +// Boundary reject behavior is covered by pkg/natsutil.RequireRequestID and +// pkg/natsrouter; dedup-critical paths make server-side minting unsafe (§3a). func TestHandleCreateRoom_EmptyPayload(t *testing.T) { ctrl := gomock.NewController(t) @@ -2450,8 +2205,7 @@ func TestHandleCreateRoom_EmptyPayload(t *testing.T) { // GetUser is NOT called for an empty request — the empty-check fires first. h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{}) require.Error(t, err) assert.True(t, errors.Is(err, errEmptyCreateRequest)) } @@ -2462,8 +2216,7 @@ func TestHandleCreateRoom_RequesterNotFound(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice").Return(nil, ErrUserNotFound) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.Error(t, err) assert.True(t, errcode.HasReason(err, errcode.RoomUserNotFound), "want RoomUserNotFound, got %v", err) } @@ -2474,8 +2227,7 @@ func TestHandleCreateRoom_RequesterMissingNameFields(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ID: "u-alice", Account: "alice"}, nil) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.Error(t, err) assert.True(t, errors.Is(err, errInvalidUserData)) } @@ -2488,8 +2240,7 @@ func TestHandleCreateRoom_SelfDMRejected(t *testing.T) { store := NewMockRoomStore(ctrl) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"alice"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"alice"}}) require.Error(t, err) assert.True(t, errors.Is(err, errSelfDM)) } @@ -2509,13 +2260,10 @@ func TestHandleCreateRoom_DM_HappyPath(t *testing.T) { }, } - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.NoError(t, err) require.NotNil(t, publishedData) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomReplyAccepted, reply.Status) assert.Equal(t, string(model.RoomTypeDM), reply.RoomType) assert.Equal(t, idgen.BuildDMRoomID("u-alice", "u-bob"), reply.RoomID) @@ -2531,12 +2279,9 @@ func TestHandleCreateRoom_DM_AlreadyExists(t *testing.T) { ) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.NoError(t, err) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomStatusExists, reply.Status) assert.Equal(t, "existing-dm-room", reply.RoomID) } @@ -2560,13 +2305,10 @@ func TestHandleCreateRoom_BotDM_HappyPath(t *testing.T) { }, } - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"helper.bot"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"helper.bot"}}) require.NoError(t, err) require.NotNil(t, publishedData) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomReplyAccepted, reply.Status) assert.Equal(t, string(model.RoomTypeBotDM), reply.RoomType) @@ -2601,11 +2343,10 @@ func TestHandleCreateRoom_BotDM_AppCounterpartNoNameFields(t *testing.T) { }, } - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"weather.bot"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + resp, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"weather.bot"}}) require.NoError(t, err) assert.True(t, published, "canonical event must publish for botDM with bot lacking name fields") - assert.Contains(t, string(resp), `"roomType":"botDM"`) + assert.Equal(t, string(model.RoomTypeBotDM), resp.RoomType) } func TestHandleCreateRoom_BotDM_Disabled(t *testing.T) { @@ -2623,8 +2364,7 @@ func TestHandleCreateRoom_BotDM_Disabled(t *testing.T) { }, nil) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"helper.bot"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"helper.bot"}}) require.Error(t, err) assert.True(t, errors.Is(err, errBotNotAvailable)) } @@ -2642,12 +2382,9 @@ func TestHandleCreateRoom_BotDM_DisabledButExisting(t *testing.T) { // GetApp must NOT be called when an existing DM is found. h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"helper.bot"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"helper.bot"}}) require.NoError(t, err) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomStatusExists, reply.Status) assert.Equal(t, "existing-bot-dm", reply.RoomID) } @@ -2667,13 +2404,10 @@ func TestHandleCreateRoom_Channel_HappyPath(t *testing.T) { }, } - body, _ := json.Marshal(model.CreateRoomRequest{Name: "general", Users: []string{"bob", "charlie"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "general", Users: []string{"bob", "charlie"}}) require.NoError(t, err) require.NotNil(t, publishedData) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomReplyAccepted, reply.Status) assert.Equal(t, string(model.RoomTypeChannel), reply.RoomType) assert.NotEmpty(t, reply.RoomID) @@ -2687,8 +2421,7 @@ func TestHandleCreateRoom_Channel_NameRequired(t *testing.T) { store := NewMockRoomStore(ctrl) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob", "charlie"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob", "charlie"}}) require.Error(t, err) assert.True(t, errors.Is(err, errChannelNameRequired), "expected errChannelNameRequired, got %v", err) } @@ -2698,8 +2431,7 @@ func TestHandleCreateRoom_Channel_NameWhitespaceOnly(t *testing.T) { store := NewMockRoomStore(ctrl) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Name: " ", Users: []string{"bob", "charlie"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: " ", Users: []string{"bob", "charlie"}}) require.Error(t, err) assert.True(t, errors.Is(err, errChannelNameRequired)) } @@ -2709,8 +2441,7 @@ func TestHandleCreateRoom_Channel_NameTooLong(t *testing.T) { store := NewMockRoomStore(ctrl) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Name: strings.Repeat("a", 101), Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: strings.Repeat("a", 101), Users: []string{"bob"}}) require.Error(t, err) assert.True(t, errors.Is(err, errChannelNameTooLong)) } @@ -2726,8 +2457,7 @@ func TestHandleCreateRoom_Channel_NameAtBoundary(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } - body, _ := json.Marshal(model.CreateRoomRequest{Name: strings.Repeat("世", 100), Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: strings.Repeat("世", 100), Users: []string{"bob"}}) require.NoError(t, err) } @@ -2737,8 +2467,7 @@ func TestHandleCreateRoom_Channel_BotRejected(t *testing.T) { store := NewMockRoomStore(ctrl) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Name: "general", Users: []string{"helper.bot"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "general", Users: []string{"helper.bot"}}) require.Error(t, err) assert.True(t, errors.Is(err, errBotInChannel)) } @@ -2751,8 +2480,7 @@ func TestHandleCreateRoom_Channel_ExceedsCapacity(t *testing.T) { store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(11, nil) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10} - body, _ := json.Marshal(model.CreateRoomRequest{Name: "big-room", Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "big-room", Users: []string{"bob"}}) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds maximum capacity") } @@ -2770,8 +2498,7 @@ func TestHandleCreateRoom_Channel_ChannelRefsExpandToCreatorOnly(t *testing.T) { store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(0, nil) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10} - body, _ := json.Marshal(model.CreateRoomRequest{Name: "solo", Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "solo", Users: []string{"bob"}}) require.Error(t, err) assert.True(t, errors.Is(err, errEmptyCreateRequest)) } @@ -2786,8 +2513,7 @@ func TestHandleCreateRoom_Channel_RejectsWhenCreatorWouldOverflow(t *testing.T) store.EXPECT().CountNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "", gomock.Any()).Return(10, nil) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10} - body, _ := json.Marshal(model.CreateRoomRequest{Name: "edge", Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "edge", Users: []string{"bob"}}) require.Error(t, err) assert.Contains(t, err.Error(), "exceeds maximum capacity") assert.Contains(t, err.Error(), "11 members") @@ -2803,23 +2529,11 @@ func TestHandleCreateRoom_Channel_AcceptsAtCreatorInclusiveCap(t *testing.T) { h := &Handler{store: store, siteID: "site-a", maxRoomSize: 10, publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} - body, _ := json.Marshal(model.CreateRoomRequest{Name: "edge", Users: []string{"bob"}}) - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Name: "edge", Users: []string{"bob"}}) require.NoError(t, err) } -// Malformed JSON must surface as a sanitized "invalid request" error, not panic. -func TestHandleCreateRoom_MalformedJSON(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockRoomStore(ctrl) - h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - - _, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), []byte("{not json")) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid request") -} - -// determineRoomType / handleCreateRoom must classify "p_" webhook bots as botDM. +// determineRoomType / createRoom must classify "p_" webhook bots as botDM. func TestHandleCreateRoom_BotDM_PUnderscoreWebhookBot(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) @@ -2840,17 +2554,14 @@ func TestHandleCreateRoom_BotDM_PUnderscoreWebhookBot(t *testing.T) { }, } - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"p_webhook"}}) - resp, err := h.handleCreateRoom(ctxWithReqID(), createRoomSubj("alice", "site-a"), body) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"p_webhook"}}) require.NoError(t, err) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, string(model.RoomTypeBotDM), reply.RoomType, "p_ webhook account must classify as botDM") assert.NotNil(t, publishedData) } -// --- Phase 5c: natsCreateRoom adapter tests --- +// --- createRoom reply-shape tests --- func TestNatsCreateRoom_DMExistsReply(t *testing.T) { // DM-exists is now a SUCCESS reply: {status:"exists", roomId:…}, not an error. @@ -2865,7 +2576,7 @@ func TestNatsCreateRoom_DMExistsReply(t *testing.T) { } func TestNatsCreateRoom_DMExistsSuccess_FlowTriggered(t *testing.T) { - // Verify handleCreateRoom returns a SUCCESS "exists" reply (not an error) + // Verify createRoom returns a SUCCESS "exists" reply (not an error) // when FindDMSubscription returns an existing subscription. ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) @@ -2876,16 +2587,9 @@ func TestNatsCreateRoom_DMExistsSuccess_FlowTriggered(t *testing.T) { ) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - reqBody, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - resp, err := h.handleCreateRoom( - natsutil.WithRequestID(context.Background(), idgen.GenerateRequestID()), - createRoomSubj("alice", "site-a"), - reqBody, - ) + reply, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.NoError(t, err) - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) assert.Equal(t, model.CreateRoomStatusExists, reply.Status) assert.Equal(t, "existing-dm", reply.RoomID) } @@ -2897,12 +2601,7 @@ func TestNatsCreateRoom_GenericErrorReply(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice").Return(nil, fmt.Errorf("mongo connection refused")) h := &Handler{store: store, siteID: "site-a", maxRoomSize: 1000} - body, _ := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - _, err := h.handleCreateRoom( - natsutil.WithRequestID(context.Background(), idgen.GenerateRequestID()), - createRoomSubj("alice", "site-a"), - body, - ) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), model.CreateRoomRequest{Users: []string{"bob"}}) require.Error(t, err) // Not a typed *errcode.Error — Classify will collapse it to internal. var ee *errcode.Error @@ -2937,19 +2636,12 @@ func newMessageReadFixture(t *testing.T) *messageReadFixture { return f } -func TestHandler_MessageRead_InvalidSubject(t *testing.T) { - f := newMessageReadFixture(t) - _, err := f.handler.handleMessageRead(context.Background(), "garbage", nil) - require.Error(t, err) -} - func TestHandler_MessageRead_NotMember(t *testing.T) { f := newMessageReadFixture(t) f.store.EXPECT(). GetSubscription(gomock.Any(), "alice", "r1"). Return(nil, model.ErrSubscriptionNotFound) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.ErrorIs(t, err, errNotRoomMember) } @@ -2973,13 +2665,10 @@ func TestHandler_MessageRead_HappyLocal_AlertClears(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&minT, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", &minT).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - resp, err := f.handler.handleMessageRead(context.Background(), subj, nil) + resp, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) - var got map[string]string - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "accepted", got["status"]) + assert.Equal(t, "accepted", resp.Status) assert.Equal(t, 0, f.publishCalls) } @@ -2999,8 +2688,7 @@ func TestHandler_MessageRead_AlertStaysTrueWithThreadUnread(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&lastSeen, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", &lastSeen).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3025,8 +2713,7 @@ func TestHandler_MessageRead_LastSeenNil_RecomputesAnyway(t *testing.T) { var nilTime *time.Time f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(nilTime, nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3040,8 +2727,7 @@ func TestHandler_MessageRead_RoomLastMsgNil_EarlyReturn(t *testing.T) { f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) f.store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", LastMsgAt: nil}, nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3061,8 +2747,7 @@ func TestHandler_MessageRead_CrossSite_PublishesOutbox(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&lastSeen, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", &lastSeen).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, 1, f.publishCalls) @@ -3096,8 +2781,7 @@ func TestHandler_MessageRead_CrossSite_PublishFailureAborts(t *testing.T) { // MinSubscriptionLastSeenByRoomID / UpdateRoomMinUserLastSeenAt must NOT run // after the publish failure. - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) } @@ -3116,8 +2800,7 @@ func TestHandler_MessageRead_GetUserSiteIDEmpty_NoPublish(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&lastSeen, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", &lastSeen).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -3133,8 +2816,7 @@ func TestHandler_MessageRead_GetUserSiteIDError_Aborts(t *testing.T) { // GetRoom may run concurrently via errgroup; allow it. f.store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1"}, nil).AnyTimes() - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -3158,8 +2840,7 @@ func TestHandler_MessageRead_MinNil_ClearsRoomField(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(nilTime, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", nilTime).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3172,8 +2853,7 @@ func TestHandler_MessageRead_UpdateSubscriptionReadError(t *testing.T) { f.store.EXPECT().UpdateSubscriptionRead(gomock.Any(), "r1", "alice", gomock.Any(), false). Return(errors.New("mongo down")) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) } @@ -3187,8 +2867,7 @@ func TestHandler_MessageRead_GetRoomError(t *testing.T) { f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) f.store.EXPECT().GetRoom(gomock.Any(), "r1").Return(nil, errors.New("mongo down")) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) } @@ -3206,8 +2885,7 @@ func TestHandler_MessageRead_MinSubscriptionError(t *testing.T) { f.store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", LastMsgAt: &lastMsg}, nil) f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(nil, errors.New("agg failed")) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) } @@ -3226,8 +2904,7 @@ func TestHandler_MessageRead_UpdateRoomMinError(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&lastSeen, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", gomock.Any()).Return(errors.New("mongo down")) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) } @@ -3256,8 +2933,7 @@ func TestHandler_MessageRead_FloorUnchanged_SkipsWrite(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&computedFloor, nil) // UpdateRoomMinUserLastSeenAt must NOT run — the floor is unchanged. - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3281,8 +2957,7 @@ func TestHandler_MessageRead_FloorNilStoredNil_SkipsWrite(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(nilTime, nil) // UpdateRoomMinUserLastSeenAt must NOT run — stored nil matches computed nil. - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3307,8 +2982,7 @@ func TestHandler_MessageRead_FloorChanged_Writes(t *testing.T) { f.store.EXPECT().MinSubscriptionLastSeenByRoomID(gomock.Any(), "r1").Return(&newFloor, nil) f.store.EXPECT().UpdateRoomMinUserLastSeenAt(gomock.Any(), "r1", &newFloor).Return(nil) - subj := subject.MessageRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageRead(context.Background(), subj, nil) + _, err := f.handler.messageRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) } @@ -3320,8 +2994,7 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { messageID = "m1" ) createdAt := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) - subj := subject.MessageReadReceipt(account, roomID, siteID) - body := mustJSON(t, model.ReadReceiptRequest{MessageID: messageID}) + req := model.ReadReceiptRequest{MessageID: messageID} type setup struct { store *MockRoomStore @@ -3329,8 +3002,7 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { } type tc struct { name string - subject string - body []byte + req model.ReadReceiptRequest prep func(s setup) wantErr error wantSubst string @@ -3339,9 +3011,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { tests := []tc{ { - name: "happy path", - subject: subj, - body: body, + name: "happy path", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3359,9 +3030,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { }, }, { - name: "empty readers", - subject: subj, - body: body, + name: "empty readers", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3372,22 +3042,14 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { }, wantReply: &model.ReadReceiptResponse{Readers: []model.ReadReceiptEntry{}}, }, - { - name: "invalid subject", - subject: "garbage", - body: body, - wantSubst: "invalid", - }, { name: "empty messageID", - subject: subj, - body: mustJSON(t, model.ReadReceiptRequest{}), + req: model.ReadReceiptRequest{}, wantSubst: "messageId", }, { - name: "not a room member", - subject: subj, - body: body, + name: "not a room member", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(nil, model.ErrSubscriptionNotFound) @@ -3397,9 +3059,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantErr: errNotRoomMember, }, { - name: "message not found", - subject: subj, - body: body, + name: "message not found", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3409,9 +3070,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantErr: errMessageNotFound, }, { - name: "message in another room", - subject: subj, - body: body, + name: "message in another room", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3421,9 +3081,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantErr: errMessageRoomMismatch, }, { - name: "not the sender", - subject: subj, - body: body, + name: "not the sender", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3433,9 +3092,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantErr: errNotMessageSender, }, { - name: "store error on subscription", - subject: subj, - body: body, + name: "store error on subscription", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(nil, fmt.Errorf("db down")) @@ -3445,9 +3103,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantSubst: "db down", }, { - name: "store error on message lookup", - subject: subj, - body: body, + name: "store error on message lookup", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3457,9 +3114,8 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { wantSubst: "cass down", }, { - name: "store error on aggregation", - subject: subj, - body: body, + name: "store error on aggregation", + req: req, prep: func(s setup) { s.store.EXPECT().GetSubscription(gomock.Any(), account, roomID). Return(&model.Subscription{}, nil) @@ -3484,7 +3140,7 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { } h := NewHandler(store, nil, nil, reader, siteID, 1000, 1000, time.Second, 5, nil, nil, nil, 0) - gotBytes, err := h.handleMessageReadReceipt(context.Background(), tt.subject, tt.body) + got, err := h.messageReadReceipt(ctxParams(map[string]string{"account": account, "roomID": roomID}), tt.req) if tt.wantErr != nil { require.ErrorIs(t, err, tt.wantErr) @@ -3496,9 +3152,7 @@ func TestHandler_handleMessageReadReceipt(t *testing.T) { return } require.NoError(t, err) - var got model.ReadReceiptResponse - require.NoError(t, json.Unmarshal(gotBytes, &got)) - require.Equal(t, *tt.wantReply, got) + require.Equal(t, tt.wantReply, got) }) } } @@ -3536,9 +3190,7 @@ func TestHandler_CreateRoom_WritesKeyBeforePublish(t *testing.T) { publishToStream: publish} req := model.CreateRoomRequest{Name: "general", Users: []string{"bob"}} - data, _ := json.Marshal(req) - _, err := h.handleCreateRoom(ctxWithReqID(), - "chat.user.alice.request.room.site-a.create", data) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), req) require.NoError(t, err) assert.Equal(t, 1, publishCalls) } @@ -3563,9 +3215,7 @@ func TestHandler_CreateRoom_AbortsOnKeyStoreSetError(t *testing.T) { } req := model.CreateRoomRequest{Name: "general", Users: []string{"bob"}} - data, _ := json.Marshal(req) - _, err := h.handleCreateRoom(ctxWithReqID(), - "chat.user.alice.request.room.site-a.create", data) + _, err := h.createRoom(ctxParams(map[string]string{"account": "alice"}), req) require.Error(t, err) assert.Contains(t, err.Error(), "store room key") } @@ -3583,19 +3233,16 @@ func TestHandler_EnsureRoomKey_KeyExists(t *testing.T) { keyStore.EXPECT().Get(gomock.Any(), "room-abc").Return(existing, nil) h := &Handler{keyStore: keyStore, siteID: "site-local"} - req := model.RoomKeyEnsureRequest{RoomID: "room-abc"} - data, _ := json.Marshal(req) - resp, err := h.handleEnsureRoomKey(context.Background(), data) + resp, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: "room-abc"}) require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "room-abc", resp.RoomID) + assert.Equal(t, 7, resp.Version) - var result model.RoomKeyEnsureResponse - require.NoError(t, json.Unmarshal(resp, &result)) - assert.Equal(t, "room-abc", result.RoomID) - assert.Equal(t, 7, result.Version) - - assert.NotContains(t, string(resp), "publicKey", "response must not include public key bytes") - assert.NotContains(t, string(resp), "privateKey", "response must not include private key bytes") + respJSON := mustJSON(t, resp) + assert.NotContains(t, string(respJSON), "publicKey", "response must not include public key bytes") + assert.NotContains(t, string(respJSON), "privateKey", "response must not include private key bytes") } func TestHandler_EnsureRoomKey_KeyNotFound_SetsNew(t *testing.T) { @@ -3613,29 +3260,17 @@ func TestHandler_EnsureRoomKey_KeyNotFound_SetsNew(t *testing.T) { }) h := &Handler{keyStore: keyStore, siteID: "site-local"} - req := model.RoomKeyEnsureRequest{RoomID: "room-new"} - data, _ := json.Marshal(req) - resp, err := h.handleEnsureRoomKey(context.Background(), data) + resp, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: "room-new"}) require.NoError(t, err) - - var result model.RoomKeyEnsureResponse - require.NoError(t, json.Unmarshal(resp, &result)) - assert.Equal(t, "room-new", result.RoomID) - assert.Equal(t, 0, result.Version) + require.NotNil(t, resp) + assert.Equal(t, "room-new", resp.RoomID) + assert.Equal(t, 0, resp.Version) assert.Len(t, capturedPair.PrivateKey, 32, "room secret must be 32 bytes — stored in Valkey") - assert.NotContains(t, string(resp), "publicKey", "response must not include public key bytes") - assert.NotContains(t, string(resp), "privateKey", "response must not include private key bytes") -} - -func TestHandler_EnsureRoomKey_MalformedRequest(t *testing.T) { - ctrl := gomock.NewController(t) - keyStore := NewMockRoomKeyStore(ctrl) - h := &Handler{keyStore: keyStore, siteID: "site-local"} - - _, err := h.handleEnsureRoomKey(context.Background(), []byte("{not json")) - require.Error(t, err) + respJSON := mustJSON(t, resp) + assert.NotContains(t, string(respJSON), "publicKey", "response must not include public key bytes") + assert.NotContains(t, string(respJSON), "privateKey", "response must not include private key bytes") } func TestHandler_EnsureRoomKey_MissingRoomID(t *testing.T) { @@ -3643,8 +3278,7 @@ func TestHandler_EnsureRoomKey_MissingRoomID(t *testing.T) { keyStore := NewMockRoomKeyStore(ctrl) h := &Handler{keyStore: keyStore, siteID: "site-local"} - data, _ := json.Marshal(model.RoomKeyEnsureRequest{RoomID: ""}) - _, err := h.handleEnsureRoomKey(context.Background(), data) + _, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: ""}) require.Error(t, err) } @@ -3654,9 +3288,8 @@ func TestHandler_EnsureRoomKey_GetError(t *testing.T) { keyStore.EXPECT().Get(gomock.Any(), "room-err").Return(nil, errors.New("valkey down")) h := &Handler{keyStore: keyStore, siteID: "site-local"} - data, _ := json.Marshal(model.RoomKeyEnsureRequest{RoomID: "room-err"}) - _, err := h.handleEnsureRoomKey(context.Background(), data) + _, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: "room-err"}) require.Error(t, err) } @@ -3667,17 +3300,15 @@ func TestHandler_EnsureRoomKey_SetError(t *testing.T) { keyStore.EXPECT().Set(gomock.Any(), "room-setfail", gomock.Any()).Return(0, errors.New("write failed")) h := &Handler{keyStore: keyStore, siteID: "site-local"} - data, _ := json.Marshal(model.RoomKeyEnsureRequest{RoomID: "room-setfail"}) - _, err := h.handleEnsureRoomKey(context.Background(), data) + _, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: "room-setfail"}) require.Error(t, err) } func TestHandler_EnsureRoomKey_NilKeyStore(t *testing.T) { h := &Handler{keyStore: nil, siteID: "site-local"} - data, _ := json.Marshal(model.RoomKeyEnsureRequest{RoomID: "room-abc"}) - _, err := h.handleEnsureRoomKey(context.Background(), data) + _, err := h.ensureRoomKey(ctxParams(map[string]string{}), model.RoomKeyEnsureRequest{RoomID: "room-abc"}) require.Error(t, err) } @@ -3709,13 +3340,6 @@ func newThreadReadFixture(t *testing.T) *threadReadFixture { return f } -func threadReadBody(t *testing.T, threadID string) []byte { - t.Helper() - b, err := json.Marshal(model.MessageThreadReadRequest{ThreadID: threadID}) - require.NoError(t, err) - return b -} - func baseThreadSub(account, roomID, parent, threadRoomID string) *model.ThreadSubscription { return &model.ThreadSubscription{ ID: "tsub-" + parent, @@ -3739,26 +3363,12 @@ func baseSubForThreadRead(account, roomID string, threadUnread []string, alert b } } -func TestHandler_MessageThreadRead_InvalidSubject(t *testing.T) { - f := newThreadReadFixture(t) - _, err := f.handler.handleMessageThreadRead(context.Background(), "garbage", threadReadBody(t, "p1")) - require.Error(t, err) -} - func TestHandler_MessageThreadRead_EmptyThreadID(t *testing.T) { f := newThreadReadFixture(t) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: ""}) require.ErrorIs(t, err, errInvalidThreadID) } -func TestHandler_MessageThreadRead_MalformedBody(t *testing.T) { - f := newThreadReadFixture(t) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, []byte("{")) - require.Error(t, err) -} - func TestHandler_MessageThreadRead_NotRoomMember(t *testing.T) { f := newThreadReadFixture(t) f.store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1"). @@ -3767,8 +3377,7 @@ func TestHandler_MessageThreadRead_NotRoomMember(t *testing.T) { Return(baseThreadSub("alice", "r1", "p1", "tr1"), nil).AnyTimes() f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.ErrorIs(t, err, errNotRoomMember) assert.Equal(t, 0, f.publishCalls) } @@ -3781,8 +3390,7 @@ func TestHandler_MessageThreadRead_ThreadSubNotFound(t *testing.T) { Return(nil, model.ErrThreadSubscriptionNotFound) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.ErrorIs(t, err, errThreadSubNotFound) assert.Equal(t, 0, f.publishCalls) } @@ -3802,8 +3410,7 @@ func TestHandler_MessageThreadRead_ThreadSubNotFound_SiblingsCancelled(t *testin f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice"). Return("", context.Canceled).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.ErrorIs(t, err, errThreadSubNotFound) } @@ -3816,8 +3423,7 @@ func TestHandler_MessageThreadRead_BothMiss_RoomNotMemberWins(t *testing.T) { Return(nil, model.ErrThreadSubscriptionNotFound).AnyTimes() f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.ErrorIs(t, err, errNotRoomMember, "iteration %d", i) } } @@ -3834,12 +3440,9 @@ func TestHandler_MessageThreadRead_HappyAlertClears(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - resp, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + resp, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) - var got map[string]string - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "accepted", got["status"]) + assert.Equal(t, "accepted", resp.Status) assert.Equal(t, 0, f.publishCalls) } @@ -3855,8 +3458,7 @@ func TestHandler_MessageThreadRead_HappyAlertStays(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) } @@ -3872,8 +3474,7 @@ func TestHandler_MessageThreadRead_IdempotentIDNotInArray(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) } @@ -3889,8 +3490,7 @@ func TestHandler_MessageThreadRead_AlertAlreadyFalse(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-a", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) } @@ -3906,8 +3506,7 @@ func TestHandler_MessageThreadRead_CrossSite_PublishesOutbox(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-b", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) require.Equal(t, 1, f.publishCalls) @@ -3943,8 +3542,7 @@ func TestHandler_MessageThreadRead_GetUserSiteID_Empty(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.NoError(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -3962,8 +3560,7 @@ func TestHandler_MessageThreadRead_GetUserSiteID_Error(t *testing.T) { f.store.EXPECT().UpdateThreadSubscriptionRead(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.Error(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -3981,8 +3578,7 @@ func TestHandler_MessageThreadRead_OutboxPublishError(t *testing.T) { Return(nil) f.store.EXPECT().GetUserSiteID(gomock.Any(), "alice").Return("site-b", nil) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.Error(t, err) require.Equal(t, 1, f.publishCalls) } @@ -3999,8 +3595,7 @@ func TestHandler_MessageThreadRead_UpdateSubscriptionError(t *testing.T) { f.store.EXPECT().UpdateThreadSubscriptionRead(gomock.Any(), "tr1", "alice", gomock.Any()). Return(nil).AnyTimes() - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.Error(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -4017,8 +3612,7 @@ func TestHandler_MessageThreadRead_UpdateThreadSubscriptionError(t *testing.T) { f.store.EXPECT().UpdateThreadSubscriptionRead(gomock.Any(), "tr1", "alice", gomock.Any()). Return(fmt.Errorf("mongo down")) - subj := subject.MessageThreadRead("alice", "r1", "site-a") - _, err := f.handler.handleMessageThreadRead(context.Background(), subj, threadReadBody(t, "p1")) + _, err := f.handler.messageThreadRead(ctxParams(map[string]string{"account": "alice", "roomID": "r1"}), model.MessageThreadReadRequest{ThreadID: "p1"}) require.Error(t, err) assert.Equal(t, 0, f.publishCalls) } @@ -4059,14 +3653,11 @@ func TestHandler_MuteToggle_Success(t *testing.T) { }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - resp, err := h.handleMuteToggle(context.Background(), subj, nil) + resp, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) - var got model.MuteToggleResponse - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "ok", got.Status) - assert.True(t, got.Muted) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Muted) require.Len(t, coreSubjects, 1) assert.Equal(t, subject.SubscriptionUpdate("alice"), coreSubjects[0]) @@ -4116,8 +3707,7 @@ func TestHandler_MuteToggle_CrossSitePublishesOutbox(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - _, err := h.handleMuteToggle(context.Background(), subj, nil) + _, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, subject.Outbox("site-a", "site-b", model.OutboxSubscriptionMuteToggled), streamSubj) @@ -4150,22 +3740,10 @@ func TestHandler_MuteToggle_NotRoomMember(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - _, err := h.handleMuteToggle(context.Background(), subj, nil) + _, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) assert.ErrorIs(t, err, errNotRoomMember) } -func TestHandler_MuteToggle_InvalidSubject(t *testing.T) { - h := &Handler{ - siteID: "site-a", - publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, - publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, - } - _, err := h.handleMuteToggle(context.Background(), "garbage.subject", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid mute-toggle subject") -} - func TestHandler_MuteToggle_StoreError(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) @@ -4179,8 +3757,7 @@ func TestHandler_MuteToggle_StoreError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - _, err := h.handleMuteToggle(context.Background(), subj, nil) + _, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "toggle subscription mute") } @@ -4206,8 +3783,7 @@ func TestHandler_MuteToggle_GetUserSiteIDError(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - _, err := h.handleMuteToggle(context.Background(), subj, nil) + _, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "get user siteId") } @@ -4236,8 +3812,7 @@ func TestHandler_MuteToggle_CrossSiteOutboxPublishFailure(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - _, err := h.handleMuteToggle(context.Background(), subj, nil) + _, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "publish mute-toggled outbox") } @@ -4248,7 +3823,6 @@ func TestHandler_natsGetRoomKey(t *testing.T) { account = "alice" roomID = "room-1" ) - subj := subject.RoomKeyGet(account, roomID, siteID) sampleKey := roomkeystore.RoomKeyPair{PrivateKey: bytes.Repeat([]byte{0x42}, 32)} sampleVersioned := &roomkeystore.VersionedKeyPair{Version: 7, KeyPair: sampleKey} @@ -4352,57 +3926,83 @@ func TestHandler_natsGetRoomKey(t *testing.T) { tc.setup(t, store, ks) h := NewHandler(store, ks, nil, nil, siteID, 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) - resp, err := h.handleGetRoomKey(t.Context(), subj, tc.body) + c := ctxParams(map[string]string{"account": account, "roomID": roomID}) + c.Msg = &nats.Msg{Data: tc.body} + resp, err := h.getRoomKey(c) if tc.want.errSubstr != "" { require.Error(t, err) require.Contains(t, err.Error(), tc.want.errSubstr) return } require.NoError(t, err) - require.JSONEq(t, tc.want.replyJSON, string(resp)) + require.NotNil(t, resp) + respJSON, mErr := json.Marshal(resp) + require.NoError(t, mErr) + require.JSONEq(t, tc.want.replyJSON, string(respJSON)) }) } } +// TestHandler_GetRoomKey_EmptyBody locks the optional-body contract: a nil +// request body must default to the current-version path, not be rejected. +func TestHandler_GetRoomKey_EmptyBody(t *testing.T) { + const ( + siteID = "site-a" + account = "alice" + roomID = "room-1" + ) + sampleKey := roomkeystore.RoomKeyPair{PrivateKey: bytes.Repeat([]byte{0x42}, 32)} + sampleVersioned := &roomkeystore.VersionedKeyPair{Version: 7, KeyPair: sampleKey} + + ctrl := gomock.NewController(t) + store := NewMockRoomStore(ctrl) + ks := NewMockRoomKeyStore(ctrl) + store.EXPECT().GetSubscription(gomock.Any(), account, roomID).Return(&model.Subscription{}, nil) + ks.EXPECT().Get(gomock.Any(), roomID).Return(sampleVersioned, nil) + + h := NewHandler(store, ks, nil, nil, siteID, 1000, 500, 5*time.Second, 5, nil, nil, nil, 0) + c := ctxParams(map[string]string{"account": account, "roomID": roomID}) + c.Msg = &nats.Msg{Data: nil} + resp, err := h.getRoomKey(c) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, roomID, resp.RoomID) + assert.Equal(t, 7, resp.Version) + assert.Equal(t, sampleKey.PrivateKey, resp.PrivateKey) +} + // --- RoomRename tests --- func TestHandleRoomRename_Validation(t *testing.T) { - const validReqID = "01970a4f-8c2d-7c9a-abcd-e0123456789f" + const validReqID = testRequestID tests := []struct { name string - subj string - body []byte - ctx context.Context + account string + roomID string + newName string setupStore func(*MockRoomStore) wantErr error }{ - { - name: "invalid subject", - subj: "bad.subject", - body: mustJSON(t, model.RenameRoomRequest{NewName: "new"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), - wantErr: errInvalidRenameSubject, - }, { name: "blank name after trim", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: " "}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + account: "alice", + roomID: "r1", + newName: " ", wantErr: errInvalidName, }, { name: "name too long (>100 chars)", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: strings.Repeat("x", 101)}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + account: "alice", + roomID: "r1", + newName: strings.Repeat("x", 101), wantErr: errInvalidName, }, { - name: "room not found", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "room not found", + account: "alice", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice"}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(nil, mongo.ErrNoDocuments) @@ -4410,10 +4010,10 @@ func TestHandleRoomRename_Validation(t *testing.T) { wantErr: errRoomNotFound, }, { - name: "wrong room type (DM)", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "wrong room type (DM)", + account: "alice", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice"}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeDM}, nil) @@ -4421,10 +4021,10 @@ func TestHandleRoomRename_Validation(t *testing.T) { wantErr: errRenameChannelOnly, }, { - name: "non-admin non-owner", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "non-admin non-owner", + account: "alice", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice", Roles: []model.UserRole{model.UserRoleUser}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel}, nil) @@ -4436,10 +4036,10 @@ func TestHandleRoomRename_Validation(t *testing.T) { wantErr: errOnlyOwnersOrAdmins, }, { - name: "owner subscription allowed", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "owner subscription allowed", + account: "alice", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice", Roles: []model.UserRole{model.UserRoleUser}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil) @@ -4450,10 +4050,10 @@ func TestHandleRoomRename_Validation(t *testing.T) { wantErr: nil, }, { - name: "room admin rejected (only owner or platform admin allowed)", - subj: subject.RoomRename("alice", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "room admin rejected (only owner or platform admin allowed)", + account: "alice", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice", Roles: []model.UserRole{model.UserRoleUser}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel}, nil) @@ -4464,10 +4064,10 @@ func TestHandleRoomRename_Validation(t *testing.T) { wantErr: errOnlyOwnersOrAdmins, }, { - name: "admin allowed without subscription", - subj: subject.RoomRename("admin1", "r1", "site-a"), - body: mustJSON(t, model.RenameRoomRequest{NewName: "new-name"}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + name: "admin allowed without subscription", + account: "admin1", + roomID: "r1", + newName: "new-name", setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site-a"}, nil) @@ -4487,9 +4087,15 @@ func TestHandleRoomRename_Validation(t *testing.T) { h := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, nil, nil, 0) - _, err := h.handleRoomRename(tt.ctx, tt.subj, tt.body) + resp, err := h.roomRename( + ctxParams(map[string]string{"account": tt.account, "roomID": tt.roomID}), + model.RoomRenameRequest{NewName: tt.newName}, + ) if tt.wantErr == nil { require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "accepted", resp.Status) + assert.Equal(t, validReqID, resp.RequestID) } else { require.Error(t, err) assert.ErrorIs(t, err, tt.wantErr) @@ -4511,25 +4117,22 @@ func happyPathRestrictedSuccessSetup(s *MockRoomStore) { } func TestHandleRoomRestricted_Validation(t *testing.T) { - const validReqID = "01970a4f-8c2d-7c9a-abcd-e0123456789f" + const validReqID = testRequestID tests := []struct { name string - body []byte - ctx context.Context + req model.RoomRestrictedRequest setupStore func(*MockRoomStore) wantErr error }{ { name: "missing roomID/account in body", - body: mustJSON(t, model.RoomRestrictedRequest{Restricted: true}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + req: model.RoomRestrictedRequest{Restricted: true}, wantErr: errInvalidRestrictedSubject, }, { name: "non-admin requester", - body: mustJSON(t, model.RoomRestrictedRequest{RoomID: "r1", Account: "alice", Restricted: true}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + req: model.RoomRestrictedRequest{RoomID: "r1", Account: "alice", Restricted: true}, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{Account: "alice", Roles: []model.UserRole{model.UserRoleUser}}, nil) }, @@ -4537,8 +4140,7 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "room not found", - body: mustJSON(t, model.RoomRestrictedRequest{RoomID: "r1", Account: "admin1", Restricted: true}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + req: model.RoomRestrictedRequest{RoomID: "r1", Account: "admin1", Restricted: true}, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(nil, mongo.ErrNoDocuments) @@ -4547,8 +4149,7 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "non-channel room", - body: mustJSON(t, model.RoomRestrictedRequest{RoomID: "r1", Account: "admin1", Restricted: true}), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + req: model.RoomRestrictedRequest{RoomID: "r1", Account: "admin1", Restricted: true}, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeDM}, nil) @@ -4557,11 +4158,10 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "restricted=true + ownerAccount given + owner not a member", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: true, OwnerAccount: "nonmember", - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: true, UserCount: 10}, nil) @@ -4571,10 +4171,9 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "transition false→true without ownerAccount", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: true, - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: false, UserCount: 10}, nil) @@ -4583,11 +4182,10 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "transition with UserCount < 5 (need at least 5)", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: true, OwnerAccount: "owner1", - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: false, UserCount: 3}, nil) @@ -4597,11 +4195,10 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "transition success (admin + ownerAccount + UserCount >= 5)", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: true, OwnerAccount: "owner1", - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: false, UserCount: 10}, nil) @@ -4612,10 +4209,9 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "unrestrict (no owner/threshold checks)", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: false, - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: true, UserCount: 10}, nil) @@ -4625,11 +4221,10 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { }, { name: "already-restricted owner change success", - body: mustJSON(t, model.RoomRestrictedRequest{ + req: model.RoomRestrictedRequest{ RoomID: "r1", Account: "admin1", Restricted: true, OwnerAccount: "owner2", - }), - ctx: natsutil.WithRequestID(context.Background(), validReqID), + }, setupStore: func(s *MockRoomStore) { s.EXPECT().GetUser(gomock.Any(), "admin1").Return(&model.User{Account: "admin1", Roles: []model.UserRole{model.UserRoleAdmin}}, nil) s.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, Restricted: true, UserCount: 2}, nil) @@ -4650,9 +4245,12 @@ func TestHandleRoomRestricted_Validation(t *testing.T) { h := NewHandler(store, nil, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, nil, nil, 0) - _, err := h.handleRoomRestricted(tt.ctx, tt.body) + resp, err := h.roomRestricted(ctxParams(map[string]string{}), tt.req) if tt.wantErr == nil { require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "ok", resp.Status) + assert.Equal(t, validReqID, resp.RequestID) } else { require.Error(t, err) assert.ErrorIs(t, err, tt.wantErr) @@ -4665,7 +4263,6 @@ func TestHandler_ListMemberStatuses(t *testing.T) { const siteID = "site-a" const roomID = "r1" const requester = "alice" - subj := subject.MemberStatuses(requester, roomID, siteID) stub := []model.MemberStatus{ {Account: "alice", EngName: "Alice", ChineseName: "愛", StatusIsShow: true, StatusText: "available"}, @@ -4679,15 +4276,13 @@ func TestHandler_ListMemberStatuses(t *testing.T) { } tests := []struct { name string - subject string body []byte setupMock func(*MockRoomStore) want want }{ { - name: "default limit 3, happy path", - subject: subj, - body: nil, + name: "default limit 3, happy path", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4702,9 +4297,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { // the clamp a 2-member room receiving a no-limit request would get // errMemberStatusesLimitInvalid from the default-3 vs cap-2 check — // an error the client did not cause. - name: "nil limit clamped to small UserCount", - subject: subj, - body: nil, + name: "nil limit clamped to small UserCount", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4718,9 +4312,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { // Empty-room short-circuit: no store call, empty response. Without // this branch the cap=0 would still trip the explicit-limit guard // against the default. - name: "nil limit + empty room returns empty without store call", - subject: subj, - body: nil, + name: "nil limit + empty room returns empty without store call", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4730,9 +4323,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{members: []model.MemberStatus{}}, }, { - name: "explicit limit passes through", - subject: subj, - body: []byte(`{"limit":7}`), + name: "explicit limit passes through", + body: []byte(`{"limit":7}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4747,9 +4339,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { // or may not be invoked depending on goroutine timing before // errgroup observes the membership error. AnyTimes() accepts // both racing outcomes. - name: "requester not a member", - subject: subj, - body: nil, + name: "requester not a member", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("missing: %w", model.ErrSubscriptionNotFound)) @@ -4764,9 +4355,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { // Plain errgroup.Group (no WithContext) prevents GetRoom's failure // from cancelling GetSubscription mid-flight and surfacing as // context.Canceled, which would mask the not-member signal. - name: "not-member takes precedence over GetRoom error", - subject: subj, - body: nil, + name: "not-member takes precedence over GetRoom error", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("missing: %w", model.ErrSubscriptionNotFound)) @@ -4776,9 +4366,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errIs: errNotRoomMember}, }, { - name: "GetSubscription infra error", - subject: subj, - body: nil, + name: "GetSubscription infra error", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("mongo exploded")) @@ -4788,9 +4377,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errContains: "check room membership"}, }, { - name: "limit zero", - subject: subj, - body: []byte(`{"limit":0}`), + name: "limit zero", + body: []byte(`{"limit":0}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4799,9 +4387,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errIs: errMemberStatusesLimitInvalid}, }, { - name: "limit negative", - subject: subj, - body: []byte(`{"limit":-1}`), + name: "limit negative", + body: []byte(`{"limit":-1}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4810,9 +4397,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errIs: errMemberStatusesLimitInvalid}, }, { - name: "limit exceeds room.UserCount", - subject: subj, - body: []byte(`{"limit":11}`), + name: "limit exceeds room.UserCount", + body: []byte(`{"limit":11}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4821,9 +4407,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errIs: errMemberStatusesLimitInvalid}, }, { - name: "limit equal to room.UserCount is valid", - subject: subj, - body: []byte(`{"limit":10}`), + name: "limit equal to room.UserCount is valid", + body: []byte(`{"limit":10}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4833,9 +4418,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{members: stub}, }, { - name: "GetRoom errors", - subject: subj, - body: nil, + name: "GetRoom errors", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4844,9 +4428,8 @@ func TestHandler_ListMemberStatuses(t *testing.T) { want: want{errContains: "get room"}, }, { - name: "store errors", - subject: subj, - body: nil, + name: "store errors", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -4856,18 +4439,10 @@ func TestHandler_ListMemberStatuses(t *testing.T) { }, want: want{errContains: "list member statuses"}, }, - { - name: "invalid subject", - subject: "chat.garbage", - body: nil, - setupMock: func(s *MockRoomStore) {}, - want: want{errContains: "invalid member-statuses subject"}, - }, { // Body parse now precedes the parallel store dispatch; a malformed // body short-circuits before any read. name: "malformed JSON body", - subject: subj, body: []byte("{not json"), setupMock: func(s *MockRoomStore) {}, want: want{errContains: "invalid request"}, @@ -4881,7 +4456,9 @@ func TestHandler_ListMemberStatuses(t *testing.T) { tc.setupMock(store) h := &Handler{store: store, siteID: siteID} - resp, err := h.handleListMemberStatuses(context.Background(), tc.subject, tc.body) + c := ctxParams(map[string]string{"account": requester, "roomID": roomID}) + c.Msg = &nats.Msg{Data: tc.body} + resp, err := h.listMemberStatuses(c) if tc.want.errContains != "" { require.Error(t, err) @@ -4927,14 +4504,11 @@ func TestHandler_MuteToggle_CorePublishFailureIsNonFatal(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, } - subj := subject.MuteToggle("alice", "r1", "site-a") - resp, err := h.handleMuteToggle(context.Background(), subj, nil) + resp, err := h.muteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err, "publishCore failure must be non-fatal — DB write is the source of truth") - var got model.MuteToggleResponse - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "ok", got.Status) - assert.True(t, got.Muted) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Muted) } // fakeDEKProvisioner records EnsureDEK calls and can be made to fail. @@ -5029,14 +4603,11 @@ func TestHandler_FavoriteToggle_Success(t *testing.T) { }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - resp, err := h.handleFavoriteToggle(context.Background(), subj, nil) + resp, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) - var got model.FavoriteToggleResponse - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "ok", got.Status) - assert.True(t, got.Favorite) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Favorite) require.Len(t, coreSubjects, 1) assert.Equal(t, subject.SubscriptionUpdate("alice"), coreSubjects[0]) @@ -5076,8 +4647,7 @@ func TestHandler_FavoriteToggle_CrossSitePublishesOutbox(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + _, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, subject.Outbox("site-a", "site-b", model.OutboxSubscriptionFavoriteToggled), streamSubj) @@ -5110,22 +4680,10 @@ func TestHandler_FavoriteToggle_NotRoomMember(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + _, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) assert.ErrorIs(t, err, errNotRoomMember) } -func TestHandler_FavoriteToggle_InvalidSubject(t *testing.T) { - h := &Handler{ - siteID: "site-a", - publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, - publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, - } - _, err := h.handleFavoriteToggle(context.Background(), "garbage.subject", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid favorite-toggle subject") -} - func TestHandler_FavoriteToggle_StoreError(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockRoomStore(ctrl) @@ -5139,8 +4697,7 @@ func TestHandler_FavoriteToggle_StoreError(t *testing.T) { publishToStream: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + _, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "toggle subscription favorite") } @@ -5167,8 +4724,7 @@ func TestHandler_FavoriteToggle_GetUserSiteIDError(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + _, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "get user siteId") } @@ -5197,8 +4753,7 @@ func TestHandler_FavoriteToggle_CrossSiteOutboxPublishFailure(t *testing.T) { publishCore: func(_ context.Context, _ string, _ []byte) error { return nil }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - _, err := h.handleFavoriteToggle(context.Background(), subj, nil) + _, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "publish favorite-toggled outbox") } @@ -5230,14 +4785,11 @@ func TestHandler_FavoriteToggle_CorePublishFailureIsNonFatal(t *testing.T) { }, } - subj := subject.FavoriteToggle("alice", "r1", "site-a") - resp, err := h.handleFavoriteToggle(context.Background(), subj, nil) + resp, err := h.favoriteToggle(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err, "publishCore failure must be non-fatal — DB write is the source of truth") - var got model.FavoriteToggleResponse - require.NoError(t, json.Unmarshal(resp, &got)) - assert.Equal(t, "ok", got.Status) - assert.True(t, got.Favorite) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Favorite) } func TestHandler_marshalBounded(t *testing.T) { @@ -5490,8 +5042,7 @@ func TestHandler_handleGetRoomAppTabs_MemberAllowed(t *testing.T) { mockTabApp("app1", "Calendar", "https://upstream/cal/${roomId}/${siteId}/index"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) require.Len(t, resp.Apps, 1) assert.Equal(t, "app1", resp.Apps[0].ID) @@ -5512,8 +5063,7 @@ func TestHandler_handleGetRoomAppTabs_AdminAllowed(t *testing.T) { Return(&model.Room{ID: "r1"}, nil) store.EXPECT().ListDefaultChannelTabApps(gomock.Any()).Return([]model.App{}, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Empty(t, resp.Apps) } @@ -5525,8 +5075,7 @@ func TestHandler_handleGetRoomAppTabs_Denied(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice"). Return(&model.User{Account: "alice", Roles: []model.UserRole{model.UserRoleUser}}, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - _, err := h.handleGetRoomAppTabs(context.Background(), subj) + _, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) assert.ErrorIs(t, err, errAppAccessDenied) } @@ -5537,8 +5086,7 @@ func TestHandler_handleGetRoomAppTabs_DeniedNoUser(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice"). Return(nil, ErrUserNotFound) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - _, err := h.handleGetRoomAppTabs(context.Background(), subj) + _, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) assert.ErrorIs(t, err, errAppAccessDenied) } @@ -5548,8 +5096,7 @@ func TestHandler_handleGetRoomAppTabs_EmptyResultIsEmptyArray(t *testing.T) { Return(&model.Subscription{User: model.SubscriptionUser{Account: "alice"}, RoomID: "r1"}, nil) store.EXPECT().ListDefaultChannelTabApps(gomock.Any()).Return(nil, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.NotNil(t, resp.Apps, "must initialize empty slice, not nil, so JSON marshals to []") assert.Len(t, resp.Apps, 0) @@ -5566,8 +5113,7 @@ func TestHandler_handleGetRoomAppTabs_URLRewritePathPrefix(t *testing.T) { mockTabApp("app1", "Calendar", "https://upstream/tab/${roomId}"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, "https://chat.example.com/chat/tab/r1", resp.Apps[0].TabURL) } @@ -5580,8 +5126,7 @@ func TestHandler_handleGetRoomAppTabs_URLRewriteStripsUserinfo(t *testing.T) { mockTabApp("app1", "X", "https://user:pass@upstream/path/${roomId}"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.NotContains(t, resp.Apps[0].TabURL, "user") assert.NotContains(t, resp.Apps[0].TabURL, "pass") @@ -5596,8 +5141,7 @@ func TestHandler_handleGetRoomAppTabs_URLRewritePreservesQueryAndFragment(t *tes mockTabApp("app1", "X", "https://upstream/path?room=${roomId}#tab=${siteId}"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Equal(t, "https://chat.example.com/path?room=r1#tab=site-a", resp.Apps[0].TabURL) } @@ -5613,8 +5157,7 @@ func TestHandler_handleGetRoomAppTabs_URLRewriteSkipsEmptyAndMalformed(t *testin mockTabApp("ok2", "OK2", "https://upstream/ok2/${roomId}"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) require.Len(t, resp.Apps, 2, "empty and malformed must be skipped") assert.Equal(t, "ok1", resp.Apps[0].ID) @@ -5632,19 +5175,12 @@ func TestHandler_handleGetRoomAppTabs_SkipsAppWithNilChannelTab(t *testing.T) { mockTabApp("ok1", "OK1", "https://upstream/ok1/${roomId}"), }, nil) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppTabs(context.Background(), subj) + resp, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) require.Len(t, resp.Apps, 1, "app with nil ChannelTab must be skipped") assert.Equal(t, "ok1", resp.Apps[0].ID) } -func TestHandler_handleGetRoomAppTabs_InvalidSubject(t *testing.T) { - h, _, _ := newTabsTestHandler(t, "https://chat.example.com") - _, err := h.handleGetRoomAppTabs(context.Background(), "not.a.valid.subject") - assert.Error(t, err) -} - func TestHandler_handleGetRoomAppTabs_StoreListError(t *testing.T) { h, store, _ := newTabsTestHandler(t, "https://chat.example.com") store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1"). @@ -5652,8 +5188,7 @@ func TestHandler_handleGetRoomAppTabs_StoreListError(t *testing.T) { store.EXPECT().ListDefaultChannelTabApps(gomock.Any()). Return(nil, errors.New("mongo down")) - subj := subject.RoomAppTabs("alice", "r1", "site-a") - _, err := h.handleGetRoomAppTabs(context.Background(), subj) + _, err := h.getRoomAppTabs(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "mongo down") } @@ -5668,8 +5203,9 @@ func TestHandler_handleGetRoomAppTabs_ContextTimeout(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) cancel() - subj := subject.RoomAppTabs("alice", "r1", "site-a") - _, err := h.handleGetRoomAppTabs(parent, subj) + c := natsrouter.NewContext(map[string]string{"account": "alice", "roomID": "r1"}) + c.SetContext(parent) + _, err := h.getRoomAppTabs(c) require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), @@ -5690,8 +5226,7 @@ func TestHandler_handleGetRoomAppCommandMenu_MemberAllowed_NoBots(t *testing.T) store.EXPECT().ListRoomBotApps(gomock.Any(), "r1").Return([]RoomBotAppEntry{}, nil) // ListActiveCmdMenus must NOT be called. - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + resp, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.NotNil(t, resp.AppAssistants) assert.Len(t, resp.AppAssistants, 0) @@ -5714,8 +5249,7 @@ func TestHandler_handleGetRoomAppCommandMenu_MemberAllowed_WithMenus(t *testing. {Name: "weather.bot", CmdBlocks: []model.CmdBlock{{Text: "/forecast"}}}, }, nil) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + resp, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) require.Len(t, resp.AppAssistants, 2) assert.Equal(t, "Stocks", resp.AppAssistants[0].AppName) @@ -5736,8 +5270,7 @@ func TestHandler_handleGetRoomAppCommandMenu_MemberAllowed_BotWithoutMenu(t *tes store.EXPECT().ListActiveCmdMenus(gomock.Any(), []string{"silent.bot"}). Return([]model.BotCmdMenu{}, nil) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + resp, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) require.Len(t, resp.AppAssistants, 1) assert.Equal(t, "Silent", resp.AppAssistants[0].AppName) @@ -5755,8 +5288,7 @@ func TestHandler_handleGetRoomAppCommandMenu_AdminAllowed(t *testing.T) { Return(&model.Room{ID: "r1"}, nil) store.EXPECT().ListRoomBotApps(gomock.Any(), "r1").Return([]RoomBotAppEntry{}, nil) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - resp, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + resp, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.NoError(t, err) assert.Len(t, resp.AppAssistants, 0) } @@ -5768,25 +5300,17 @@ func TestHandler_handleGetRoomAppCommandMenu_Denied(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice"). Return(&model.User{Account: "alice"}, nil) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - _, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + _, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) assert.ErrorIs(t, err, errAppAccessDenied) } -func TestHandler_handleGetRoomAppCommandMenu_InvalidSubject(t *testing.T) { - h, _ := newCmdMenuTestHandler(t) - _, err := h.handleGetRoomAppCommandMenu(context.Background(), "not.a.valid.subject") - assert.Error(t, err) -} - func TestHandler_handleGetRoomAppCommandMenu_StoreListRoomBotAppsError(t *testing.T) { h, store := newCmdMenuTestHandler(t) store.EXPECT().GetSubscription(gomock.Any(), "alice", "r1"). Return(&model.Subscription{User: model.SubscriptionUser{Account: "alice"}, RoomID: "r1"}, nil) store.EXPECT().ListRoomBotApps(gomock.Any(), "r1").Return(nil, errors.New("mongo down")) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - _, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + _, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "mongo down") } @@ -5801,8 +5325,7 @@ func TestHandler_handleGetRoomAppCommandMenu_StoreListActiveCmdMenusError(t *tes store.EXPECT().ListActiveCmdMenus(gomock.Any(), []string{"weather.bot"}). Return(nil, errors.New("mongo down")) - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - _, err := h.handleGetRoomAppCommandMenu(context.Background(), subj) + _, err := h.getRoomAppCommandMenu(ctxParams(map[string]string{"account": "alice", "roomID": "r1"})) require.Error(t, err) assert.Contains(t, err.Error(), "mongo down") } @@ -5817,8 +5340,9 @@ func TestHandler_handleGetRoomAppCommandMenu_ContextTimeout(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) cancel() - subj := subject.RoomAppCmdMenu("alice", "r1", "site-a") - _, err := h.handleGetRoomAppCommandMenu(parent, subj) + c := natsrouter.NewContext(map[string]string{"account": "alice", "roomID": "r1"}) + c.SetContext(parent) + _, err := h.getRoomAppCommandMenu(c) require.Error(t, err) assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), @@ -5829,7 +5353,6 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { const siteID = "site-a" const roomID = "r1" const requester = "alice" - subj := subject.MentionableSubscriptions(requester, roomID, siteID) stub := []model.MentionableSubscription{ {OptionType: "user", UserID: "u-bob", Account: "bob", SiteID: "site-a", @@ -5843,15 +5366,13 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { } tests := []struct { name string - subject string body []byte setupMock func(*MockRoomStore) want want }{ { - name: "default limit 3, empty filter, happy path", - subject: subj, - body: nil, + name: "default limit 3, empty filter, happy path", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5867,9 +5388,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { // Nil-Limit clamps to UserCount+AppCount (the cap), not the default 3. // A small room with 1 user + 1 app would otherwise spuriously fail // validation with the default 3 vs cap 2. - name: "nil limit clamped to small UserCount+AppCount", - subject: subj, - body: nil, + name: "nil limit clamped to small UserCount+AppCount", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5883,9 +5403,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { }, { // Empty-room short-circuit: skip the store call, return empty. - name: "nil limit + empty room returns empty without store call", - subject: subj, - body: nil, + name: "nil limit + empty room returns empty without store call", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5895,9 +5414,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{subs: []model.MentionableSubscription{}}, }, { - name: "explicit limit and filter passed through", - subject: subj, - body: []byte(`{"limit":3,"filter":"bo"}`), + name: "explicit limit and filter passed through", + body: []byte(`{"limit":3,"filter":"bo"}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5910,9 +5428,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{subs: stub}, }, { - name: "regex metacharacters in filter are escaped", - subject: subj, - body: []byte(`{"limit":3,"filter":"a.b(c"}`), + name: "regex metacharacters in filter are escaped", + body: []byte(`{"limit":3,"filter":"a.b(c"}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5929,9 +5446,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { // or may not be invoked depending on goroutine timing before // errgroup observes the membership error. AnyTimes() accepts // both racing outcomes. - name: "requester not a member", - subject: subj, - body: nil, + name: "requester not a member", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("missing: %w", model.ErrSubscriptionNotFound)) @@ -5946,9 +5462,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { // Plain errgroup.Group (no WithContext) prevents GetRoom's failure // from cancelling GetSubscription mid-flight and surfacing as // context.Canceled, which would mask the not-member signal. - name: "not-member takes precedence over GetRoom error", - subject: subj, - body: nil, + name: "not-member takes precedence over GetRoom error", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("missing: %w", model.ErrSubscriptionNotFound)) @@ -5958,9 +5473,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errIs: errNotRoomMember}, }, { - name: "GetSubscription infra error", - subject: subj, - body: nil, + name: "GetSubscription infra error", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(nil, fmt.Errorf("mongo exploded")) @@ -5970,9 +5484,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errContains: "check room membership"}, }, { - name: "limit zero", - subject: subj, - body: []byte(`{"limit":0}`), + name: "limit zero", + body: []byte(`{"limit":0}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5982,9 +5495,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errIs: errMentionableLimitInvalid}, }, { - name: "limit negative", - subject: subj, - body: []byte(`{"limit":-1}`), + name: "limit negative", + body: []byte(`{"limit":-1}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -5994,9 +5506,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errIs: errMentionableLimitInvalid}, }, { - name: "limit exceeds UserCount + AppCount", - subject: subj, - body: []byte(`{"limit":8}`), + name: "limit exceeds UserCount + AppCount", + body: []byte(`{"limit":8}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -6006,9 +5517,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errIs: errMentionableLimitInvalid}, }, { - name: "limit at cap is accepted", - subject: subj, - body: []byte(`{"limit":7}`), + name: "limit at cap is accepted", + body: []byte(`{"limit":7}`), setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -6021,9 +5531,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{subs: stub}, }, { - name: "GetRoom errors", - subject: subj, - body: nil, + name: "GetRoom errors", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -6032,9 +5541,8 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { want: want{errContains: "get room"}, }, { - name: "store errors", - subject: subj, - body: nil, + name: "store errors", + body: nil, setupMock: func(s *MockRoomStore) { s.EXPECT().GetSubscription(gomock.Any(), requester, roomID). Return(&model.Subscription{User: model.SubscriptionUser{Account: requester}, RoomID: roomID}, nil) @@ -6046,18 +5554,10 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { }, want: want{errContains: "list mentionable subscriptions"}, }, - { - name: "invalid subject", - subject: "chat.garbage", - body: nil, - setupMock: func(s *MockRoomStore) {}, - want: want{errContains: "invalid mentionable-subscriptions subject"}, - }, { // Body parse now precedes the parallel store dispatch; a malformed // body short-circuits before any read. name: "malformed JSON body", - subject: subj, body: []byte("{not json"), setupMock: func(s *MockRoomStore) {}, want: want{errContains: "invalid request"}, @@ -6071,7 +5571,9 @@ func TestHandler_ListMentionableSubscriptions(t *testing.T) { tc.setupMock(store) h := &Handler{store: store, siteID: siteID} - resp, err := h.handleListMentionableSubscriptions(context.Background(), tc.subject, tc.body) + c := ctxParams(map[string]string{"account": requester, "roomID": roomID}) + c.Msg = &nats.Msg{Data: tc.body} + resp, err := h.listMentionableSubscriptions(c) if tc.want.errContains != "" { require.Error(t, err) diff --git a/room-service/helper.go b/room-service/helper.go index 1523c5398..7ba43d980 100644 --- a/room-service/helper.go +++ b/room-service/helper.go @@ -4,14 +4,10 @@ import ( "context" "encoding/json" "fmt" - "log/slog" "regexp" "time" - "github.com/nats-io/nats.go" - "github.com/hmchangw/chat/pkg/errcode" - "github.com/hmchangw/chat/pkg/errcode/errnats" "github.com/hmchangw/chat/pkg/model" ) @@ -92,7 +88,6 @@ var ( errRenameChannelOnly = errcode.BadRequest("rename is only allowed in channel rooms", errcode.WithReason(errcode.RoomNonChannelOperation)) errRestrictedChannelOnly = errcode.BadRequest("restricted change is only allowed in channel rooms", errcode.WithReason(errcode.RoomNonChannelOperation)) errRoomNotFound = errcode.NotFound("room not found") - errInvalidRenameSubject = errcode.BadRequest("invalid rename subject") errInvalidRestrictedSubject = errcode.BadRequest("invalid restricted subject") // App-read RPC sentinels (app.tabs / app.cmd-menu). Forbidden when the // caller is neither a room member nor a platform admin; Internal when a @@ -223,19 +218,13 @@ func (h *Handler) marshalBounded(v any) ([]byte, error) { return body, nil } -// replyBoundedJSON sends v on msg's reply subject, enforcing the negotiated -// NATS max_payload. nats* handlers use this in place of natsutil.ReplyJSON -// when a response payload could exceed max_payload; an oversize payload is -// surfaced to the caller via errnats.Reply rather than silently dropped. -func (h *Handler) replyBoundedJSON(ctx context.Context, msg *nats.Msg, v any) { - body, err := h.marshalBounded(v) - if err != nil { - errnats.Reply(ctx, msg, err) - return - } - if err := msg.Respond(body); err != nil { - slog.ErrorContext(ctx, "reply failed", "error", err) +// boundedReply size-checks resp against maxResponseBytes and returns it for the +// router to marshal, or errResponseTooLarge if it would overflow the payload. +func boundedReply[T any](h *Handler, resp *T) (*T, error) { + if _, err := h.marshalBounded(resp); err != nil { + return nil, err } + return resp, nil } // isURLSafeIDToken reports whether s is safe to inline into a URL diff --git a/room-service/integration_test.go b/room-service/integration_test.go index 28f449033..360d58ba1 100644 --- a/room-service/integration_test.go +++ b/room-service/integration_test.go @@ -26,6 +26,7 @@ import ( "github.com/hmchangw/chat/pkg/errcode" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeystore" "github.com/hmchangw/chat/pkg/stream" @@ -1247,13 +1248,9 @@ func TestAddMembers_SameSiteChannel_RoomMembersPath(t *testing.T) { req := model.AddMembersRequest{ Channels: []model.ChannelRef{{RoomID: "source", SiteID: "site-a"}}, } - data, err := json.Marshal(req) - require.NoError(t, err) - result, err := handler.handleAddMembers(ctx, subject.MemberAdd("alice", "target", "site-a"), data) + resp, err := handler.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "target"}), req) require.NoError(t, err) - var status map[string]string - require.NoError(t, json.Unmarshal(result, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) // Verify the canonical event was published with the merged-but-unresolved members. // Source channel contributes: bob, carol (individuals) + eng-org (org). @@ -1309,13 +1306,9 @@ func TestAddMembers_SameSiteChannel_SubscriptionsFallback(t *testing.T) { handler := NewHandler(store, keyStore, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, publish, func(context.Context, string, []byte) error { return nil }, nil, 0) req := model.AddMembersRequest{Channels: []model.ChannelRef{{RoomID: "source", SiteID: "site-a"}}} - data, err := json.Marshal(req) - require.NoError(t, err) - result, err := handler.handleAddMembers(ctx, subject.MemberAdd("alice", "target", "site-a"), data) + resp, err := handler.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "target"}), req) require.NoError(t, err) - var status map[string]string - require.NoError(t, json.Unmarshal(result, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) // Verify the canonical event was published with the merged-but-unresolved members. // Source channel subscriptions: bob, carol, dave, alice (requester). @@ -1339,8 +1332,6 @@ func TestAddMembers_RequesterNotSubscribed_Rejected(t *testing.T) { keyStore := setupValkey(t) store := NewMongoStore(db) - ctx := context.Background() - mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}) mustInsertRoom(t, db, &model.Room{ID: "source", Type: model.RoomTypeChannel, SiteID: "site-a"}) // Requester subscribed to target but NOT source @@ -1351,9 +1342,7 @@ func TestAddMembers_RequesterNotSubscribed_Rejected(t *testing.T) { handler := NewHandler(store, keyStore, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) req := model.AddMembersRequest{Channels: []model.ChannelRef{{RoomID: "source", SiteID: "site-a"}}} - data, err := json.Marshal(req) - require.NoError(t, err) - _, err = handler.handleAddMembers(ctx, subject.MemberAdd("alice", "target", "site-a"), data) + _, err := handler.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "target"}), req) require.Error(t, err) assert.True(t, errors.Is(err, errNotRoomMember)) } @@ -1378,10 +1367,9 @@ func TestAddMembers_TwoSiteEndToEnd(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = otelNCb.Drain() }) - // Test bypasses wrappedCtx by calling handleAddMembers directly, so we stamp - // a request_id here — the cross-site memberlist client forwards it to site-B, - // whose handler now enforces RequireRequestID (strict). - ctx := natsutil.WithRequestID(context.Background(), idgen.GenerateRequestID()) + // ctxParams seeds a valid request_id on the *natsrouter.Context (forwarded + // cross-site to site-B's strict RequireRequestID). This plain ctx is setup-only. + ctx := context.Background() // Site-A: target room; requester subscribed; user document needed for ResolveAccounts. mustInsertRoom(t, dbA, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}) @@ -1405,9 +1393,12 @@ func TestAddMembers_TwoSiteEndToEnd(t *testing.T) { mustInsertSub(t, dbB, &model.Subscription{ID: "sb2", RoomID: "source", User: model.SubscriptionUser{ID: "u2", Account: "carol"}}) mustInsertSub(t, dbB, &model.Subscription{ID: "sb3", RoomID: "source", User: model.SubscriptionUser{ID: "req", Account: "alice"}}) - // Site-B handler registers member.list endpoint (RegisterCRUD subscribes to MemberListWildcard). + // Site-B handler registers member.list endpoint (Register subscribes to MemberListWildcard). handlerB := NewHandler(storeB, keyStore, nil, nil, "site-b", 1000, 500, 5*time.Second, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, handlerB.RegisterCRUD(otelNCb)) + routerB := natsrouter.New(otelNCb, "room-service") + routerB.Use(natsrouter.RequireRequestID()) + handlerB.Register(routerB) + t.Cleanup(func() { _ = routerB.Shutdown(context.Background()) }) require.NoError(t, otelNCb.NatsConn().Flush()) // Site-A's cross-site client: connect a plain nats.Conn directly to site-B's server. @@ -1430,14 +1421,9 @@ func TestAddMembers_TwoSiteEndToEnd(t *testing.T) { // Call add-members on site-A with a site-B source channel req := model.AddMembersRequest{Channels: []model.ChannelRef{{RoomID: "source", SiteID: "site-b"}}} - data, err := json.Marshal(req) + resp, err := handlerA.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "target"}), req) require.NoError(t, err) - result, err := handlerA.handleAddMembers(ctx, subject.MemberAdd("alice", "target", "site-a"), data) - require.NoError(t, err) - - var status map[string]string - require.NoError(t, json.Unmarshal(result, &status)) - assert.Equal(t, "accepted", status["status"]) + assert.Equal(t, "accepted", resp.Status) // Verify the canonical event has site-B members (bob, carol, alice). // Already-subscribed filtering (alice on target) happens in room-worker via @@ -1464,8 +1450,6 @@ func TestAddMembers_CrossSiteTimeout(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = otelNC.Drain() }) - ctx := context.Background() - // Target on site-a, requester subscribed. mustInsertRoom(t, db, &model.Room{ID: "target", Type: model.RoomTypeChannel, SiteID: "site-a"}) mustInsertSub(t, db, &model.Subscription{ID: "s1", RoomID: "target", User: model.SubscriptionUser{ID: "req", Account: "alice"}, Roles: []model.Role{model.RoleOwner}}) @@ -1489,10 +1473,7 @@ func TestAddMembers_CrossSiteTimeout(t *testing.T) { handler := NewHandler(store, keyStore, memberListClient, nil, "site-a", 1000, 500, 200*time.Millisecond, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) req := model.AddMembersRequest{Channels: []model.ChannelRef{{RoomID: "source", SiteID: "site-b"}}} - data, err := json.Marshal(req) - require.NoError(t, err) - - _, err = handler.handleAddMembers(ctx, subject.MemberAdd("alice", "target", "site-a"), data) + _, err = handler.addMembers(ctxParams(map[string]string{"account": "alice", "roomID": "target"}), req) require.Error(t, err) // Cross-site member.list deadline → Unavailable errcode naming the offending // site+roomId so the requester can see which channel source stalled. @@ -1533,7 +1514,10 @@ func TestRoomsInfoBatchRPC(t *testing.T) { t.Cleanup(func() { _ = otelNC.Drain() }) handler := NewHandler(store, keyStore, nil, nil, "site-a", 1000, 500, 5*time.Second, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, handler.RegisterCRUD(otelNC)) + router := natsrouter.New(otelNC, "room-service") + router.Use(natsrouter.RequireRequestID()) + handler.Register(router) + t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) require.NoError(t, otelNC.NatsConn().Flush()) nc, err := nats.Connect(natsURL) @@ -1582,7 +1566,7 @@ func TestRoomsInfoBatchRPC(t *testing.T) { assert.Nil(t, resp.Rooms[3].KeyVersion) } -// TestIntegration_CreateRoom_PersistsKeyInValkey verifies that handleCreateRoom +// TestIntegration_CreateRoom_PersistsKeyInValkey verifies that createRoom // generates and stores a room keypair in Valkey before publishing the canonical // event. This ensures room-worker's "key MUST exist" gate will always succeed // on the first delivery. @@ -1611,18 +1595,15 @@ func TestIntegration_CreateRoom_PersistsKeyInValkey(t *testing.T) { reqID := idgen.GenerateRequestID() ctx = natsutil.WithRequestID(ctx, reqID) + c := natsrouter.NewContext(map[string]string{"account": "alice"}) + c.SetContext(ctx) - body, err := json.Marshal(model.CreateRoomRequest{ + reply, err := h.createRoom(c, model.CreateRoomRequest{ Name: "crypto team", Users: []string{"bob"}, }) require.NoError(t, err) - - resp, err := h.handleCreateRoom(ctx, subject.RoomCreate("alice", "site-A"), body) - require.NoError(t, err) - - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) + require.NotNil(t, reply) assert.Equal(t, model.CreateRoomReplyAccepted, reply.Status) assert.NotEmpty(t, reply.RoomID) @@ -1668,10 +1649,9 @@ func TestIntegration_HandleGetRoomKey(t *testing.T) { // 1. Member fetches current key. { body, _ := json.Marshal(model.RoomKeyGetRequest{}) - resp, err := h.handleGetRoomKey(ctx, subject.RoomKeyGet("alice", roomID, "site-A"), body) + got, err := h.getRoomKey(roomKeyGetCtx(ctx, "alice", roomID, body)) require.NoError(t, err) - var got model.RoomKeyGetResponse - require.NoError(t, json.Unmarshal(resp, &got)) + require.NotNil(t, got) assert.Equal(t, roomID, got.RoomID) assert.Equal(t, ver, got.Version) assert.Equal(t, pair.PrivateKey, got.PrivateKey) @@ -1681,10 +1661,9 @@ func TestIntegration_HandleGetRoomKey(t *testing.T) { { v := ver body, _ := json.Marshal(model.RoomKeyGetRequest{Version: &v}) - resp, err := h.handleGetRoomKey(ctx, subject.RoomKeyGet("alice", roomID, "site-A"), body) + got, err := h.getRoomKey(roomKeyGetCtx(ctx, "alice", roomID, body)) require.NoError(t, err) - var got model.RoomKeyGetResponse - require.NoError(t, json.Unmarshal(resp, &got)) + require.NotNil(t, got) assert.Equal(t, ver, got.Version) assert.Equal(t, pair.PrivateKey, got.PrivateKey) } @@ -1692,13 +1671,22 @@ func TestIntegration_HandleGetRoomKey(t *testing.T) { // 3. Non-member rejected. { body, _ := json.Marshal(model.RoomKeyGetRequest{}) - _, err := h.handleGetRoomKey(ctx, subject.RoomKeyGet("bob", roomID, "site-A"), body) + _, err := h.getRoomKey(roomKeyGetCtx(ctx, "bob", roomID, body)) // errNotRoomMember (a typed *errcode.Error) is returned and surfaced for // clients via errnats.Reply; assert on identity, not the message text. require.ErrorIs(t, err, errNotRoomMember) } } +// roomKeyGetCtx builds a *natsrouter.Context for the getRoomKey handler with the +// account/roomID params and request body the handler reads from c.Msg.Data. +func roomKeyGetCtx(ctx context.Context, account, roomID string, body []byte) *natsrouter.Context { + c := natsrouter.NewContext(map[string]string{"account": account, "roomID": roomID}) + c.SetContext(ctx) + c.Msg = &nats.Msg{Data: body} + return c +} + // mustInsertUser inserts a user document directly into the users collection. func mustInsertUser(t *testing.T, db *mongo.Database, u *model.User) { t.Helper() @@ -1760,18 +1748,15 @@ func TestCreateRoomChannelEndToEnd(t *testing.T) { reqID := idgen.GenerateRequestID() ctx = natsutil.WithRequestID(ctx, reqID) + c := natsrouter.NewContext(map[string]string{"account": "alice"}) + c.SetContext(ctx) - body, err := json.Marshal(model.CreateRoomRequest{ + got, err := h.createRoom(c, model.CreateRoomRequest{ Name: "deal team", Users: []string{"bob"}, }) require.NoError(t, err) - - resp, err := h.handleCreateRoom(ctx, subject.RoomCreate("alice", "site-A"), body) - require.NoError(t, err) - - var got model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &got)) + require.NotNil(t, got) assert.Equal(t, model.CreateRoomReplyAccepted, got.Status) assert.Equal(t, "channel", got.RoomType) assert.NotEmpty(t, got.RoomID) @@ -1813,15 +1798,12 @@ func TestCreateRoomDMAlreadyExists(t *testing.T) { reqID := idgen.GenerateRequestID() ctx = natsutil.WithRequestID(ctx, reqID) + c := natsrouter.NewContext(map[string]string{"account": "alice"}) + c.SetContext(ctx) - body, err := json.Marshal(model.CreateRoomRequest{Users: []string{"bob"}}) - require.NoError(t, err) - - resp, herr := h.handleCreateRoom(ctx, subject.RoomCreate("alice", "site-A"), body) + reply, herr := h.createRoom(c, model.CreateRoomRequest{Users: []string{"bob"}}) require.NoError(t, herr) - - var reply model.CreateRoomReply - require.NoError(t, json.Unmarshal(resp, &reply)) + require.NotNil(t, reply) assert.Equal(t, model.CreateRoomStatusExists, reply.Status) assert.Equal(t, roomID, reply.RoomID) } @@ -2663,7 +2645,10 @@ func TestIntegration_RoomRename(t *testing.T) { keyStore := setupValkey(t) h := NewHandler(store, keyStore, nil, nil, siteID, 1000, 500, 5*time.Second, 5, publishToStream, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, h.RegisterCRUD(handlerNC)) + router := natsrouter.New(handlerNC, "room-service") + router.Use(natsrouter.RequireRequestID()) + h.Register(router) + t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) require.NoError(t, handlerNC.NatsConn().Flush()) // Seed: channel room + alice as owner. @@ -2729,7 +2714,10 @@ func TestIntegration_RoomRename(t *testing.T) { h := NewHandler(store, keyStore, nil, nil, siteID, 1000, 500, 5*time.Second, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, h.RegisterCRUD(handlerNC)) + router := natsrouter.New(handlerNC, "room-service") + router.Use(natsrouter.RequireRequestID()) + h.Register(router) + t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) require.NoError(t, handlerNC.NatsConn().Flush()) const roomID = "room-rename-2" @@ -2803,7 +2791,10 @@ func TestIntegration_RoomRestricted(t *testing.T) { keyStore := setupValkey(t) h := NewHandler(store, keyStore, nil, nil, siteID, 1000, 500, 5*time.Second, 5, publishToStream, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, h.RegisterCRUD(handlerNC)) + router := natsrouter.New(handlerNC, "room-service") + router.Use(natsrouter.RequireRequestID()) + h.Register(router) + t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) require.NoError(t, handlerNC.NatsConn().Flush()) // Seed: channel room + admin + 5 members (the restricted-transition @@ -2892,7 +2883,10 @@ func TestIntegration_RoomRestricted(t *testing.T) { h := NewHandler(store, keyStore, nil, nil, siteID, 1000, 500, 5*time.Second, 5, func(context.Context, string, []byte, string) error { return nil }, func(context.Context, string, []byte) error { return nil }, nil, 0) - require.NoError(t, h.RegisterCRUD(handlerNC)) + router := natsrouter.New(handlerNC, "room-service") + router.Use(natsrouter.RequireRequestID()) + h.Register(router) + t.Cleanup(func() { _ = router.Shutdown(context.Background()) }) require.NoError(t, handlerNC.NatsConn().Flush()) const roomID = "room-restricted-2" @@ -3062,7 +3056,7 @@ func TestMongoStore_ListMentionableSubscriptions_Integration(t *testing.T) { mustInsertSub(t, db, &model.Subscription{ID: "sub-b", User: model.SubscriptionUser{ID: "u-bob", Account: "bob"}, RoomID: "r1", SiteID: "site-a"}) mustInsertSub(t, db, &model.Subscription{ID: "sub-bot", - User: model.SubscriptionUser{ID: "u-bot", Account: "helper.bot", IsBot: true}, + User: model.SubscriptionUser{ID: "u-bot", Account: "helper.bot", IsBot: true}, RoomID: "r1", SiteID: "site-a"}) } @@ -3144,13 +3138,13 @@ func TestMongoStore_ListMentionableSubscriptions_Integration(t *testing.T) { }) require.NoError(t, err) mustInsertSub(t, db, &model.Subscription{ID: "sub-bot", - User: model.SubscriptionUser{ID: "u-bot", Account: "helper.bot", IsBot: true}, + User: model.SubscriptionUser{ID: "u-bot", Account: "helper.bot", IsBot: true}, RoomID: "r1", SiteID: "site-a"}) // Decoy account that would also match if "." were treated as a wildcard // ("helperXbot"). It is a normal (non-bot) account so it classifies as a user. mustInsertUser(t, db, &model.User{ID: "u-x", Account: "helperxbot", EngName: "X"}) mustInsertSub(t, db, &model.Subscription{ID: "sub-x", - User: model.SubscriptionUser{ID: "u-x", Account: "helperxbot"}, + User: model.SubscriptionUser{ID: "u-x", Account: "helperxbot"}, RoomID: "r1", SiteID: "site-a"}) // `helper\.bot` is regexp.QuoteMeta("helper.bot") — the escaped form the @@ -3204,7 +3198,7 @@ func TestMongoStore_ListMentionableSubscriptions_Integration(t *testing.T) { store := NewMongoStore(db) mustInsertUser(t, db, &model.User{ID: "u-ghost", Account: "ghost.bot"}) mustInsertSub(t, db, &model.Subscription{ID: "sub-ghost", - User: model.SubscriptionUser{ID: "u-ghost", Account: "ghost.bot", IsBot: true}, + User: model.SubscriptionUser{ID: "u-ghost", Account: "ghost.bot", IsBot: true}, RoomID: "r1", SiteID: "site-a"}) got, err := store.ListMentionableSubscriptions(ctx, "r1", "", "", 5) diff --git a/room-service/main.go b/room-service/main.go index 469abbbd7..246172cf3 100644 --- a/room-service/main.go +++ b/room-service/main.go @@ -16,6 +16,7 @@ import ( "github.com/hmchangw/chat/pkg/atrest" "github.com/hmchangw/chat/pkg/cassutil" "github.com/hmchangw/chat/pkg/mongoutil" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/otelutil" "github.com/hmchangw/chat/pkg/roomkeystore" @@ -179,14 +180,14 @@ func main() { ) handler.dekProvisioner = dekProvisioner - if err := handler.RegisterCRUD(nc); err != nil { - slog.Error("register CRUD handlers failed", "error", err) - os.Exit(1) - } + router := natsrouter.New(nc, "room-service") + router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) + handler.Register(router) slog.Info("room-service running", "site", cfg.SiteID) shutdown.Wait(ctx, 25*time.Second, + func(ctx context.Context) error { return router.Shutdown(ctx) }, func(ctx context.Context) error { return nc.Drain() }, func(ctx context.Context) error { return tracerShutdown(ctx) }, func(ctx context.Context) error { diff --git a/room-service/memberlist_client.go b/room-service/memberlist_client.go index 304dac0b2..435a1d07e 100644 --- a/room-service/memberlist_client.go +++ b/room-service/memberlist_client.go @@ -54,9 +54,8 @@ func (c *natsMemberListClient) ListMembers(ctx context.Context, requester string reqCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() - // natsutil.NewMsg forwards the X-Request-ID from ctx; the remote - // room-service.handleListMembers uses RequireRequestID (strict) and would - // reject a header-less call with bad_request. + // natsutil.NewMsg forwards the X-Request-ID from ctx; the remote member.list + // endpoint's RequireRequestID middleware rejects a header-less call. out := natsutil.NewMsg(reqCtx, subject.MemberList(requester, ch.RoomID, ch.SiteID), body) reply, err := c.nc.RequestMsgWithContext(reqCtx, out) if err != nil { diff --git a/room-service/store.go b/room-service/store.go index 12c5bf072..739af974b 100644 --- a/room-service/store.go +++ b/room-service/store.go @@ -74,7 +74,7 @@ type RoomStore interface { // dropped from the candidate set. create-channel passes the requester's // account so an org-expanded requester is not double-counted against the // cap (the requester is added separately as the owner). - // Used by handleAddMembers and handleCreateRoomChannel for capacity validation. + // Used by addMembers and handleCreateRoomChannel for capacity validation. // Delegates to pkg/pipelines.GetNewMembersPipeline + a $count terminal stage. CountNewMembers(ctx context.Context, orgIDs, directAccounts []string, roomID, excludeAccount string) (int, error) // ListRoomMembers returns the members of roomID. When enrich=true, the @@ -88,7 +88,7 @@ type RoomStore interface { // not valid"). ListOrgMembers(ctx context.Context, orgID string) ([]model.OrgMember, error) // FindExistingOrgIDs returns the subset of orgIDs that match at least - // one user via sectId or deptId. Used by handleAddMembers and + // one user via sectId or deptId. Used by addMembers and // handleCreateRoomChannel to reject requests carrying phantom org IDs // before they reach the canonical stream — without this gate the // worker would write a room_members row and fan out a "members added" diff --git a/room-worker/handler.go b/room-worker/handler.go index 1d48da83e..74d119781 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -11,8 +11,6 @@ import ( "sync" "time" - "github.com/Marz32onE/instrumentation-go/otel-nats/otelnats" - "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "go.mongodb.org/mongo-driver/v2/mongo" "go.opentelemetry.io/otel/attribute" @@ -20,9 +18,9 @@ import ( "golang.org/x/sync/errgroup" "github.com/hmchangw/chat/pkg/errcode" - "github.com/hmchangw/chat/pkg/errcode/errnats" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeymetrics" "github.com/hmchangw/chat/pkg/roomkeysender" @@ -1578,20 +1576,12 @@ var ( errRoomIDCollision = permanent(errcode.Conflict("room id collision (existing room metadata mismatch)")) ) -// handleSyncCreateDM creates a DM, self-DM, or botDM room and returns the requester's subscription. -// Errors flow through the centralized errcode.Classify path (the legacy -// sanitizeSyncDMError helper was retired by the errcode migration). -func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.SyncCreateDMReply, error) { +// serverCreateDM creates a DM, self-DM, or botDM room and returns the requester's +// subscription; the router unmarshals req and supplies the request ID. +func (h *Handler) serverCreateDM(c *natsrouter.Context, req model.SyncCreateDMRequest) (*model.SyncCreateDMReply, error) { + var ctx context.Context = c requestID := natsutil.RequestIDFromContext(ctx) - var req model.SyncCreateDMRequest - if err := json.Unmarshal(data, &req); err != nil { - // Single %w on the errcode sentinel preserves errors.Is identity; - // the json.Unmarshal error text is folded in as %v so it surfaces in - // Classify's server-side log line without adding a second errcode to - // the chain (the semgrep no-multi-%w rule trips on two %w verbs). - return nil, fmt.Errorf("%w: %v", errInvalidSyncDMRequest, err) - } if err := validateSyncCreateDMShape(&req); err != nil { return nil, err } @@ -1731,7 +1721,7 @@ func (h *Handler) createSelfDM(ctx context.Context, requester *model.User, reque CreatedAt: now, UpdatedAt: now, } - // Provision the at-rest DEK before persisting the room (see handleSyncCreateDM). + // Provision the at-rest DEK before persisting the room (see serverCreateDM). if h.dekProvisioner != nil { if err := h.dekProvisioner.EnsureDEK(ctx, room.ID); err != nil { return nil, fmt.Errorf("provision at-rest DEK for self-DM room %s: %w", room.ID, err) @@ -1955,31 +1945,6 @@ func (h *Handler) publishSyncDMOutbox(ctx context.Context, room *model.Room, req ) } -// requireDedupRequestID is the strict X-Request-ID gate used by sync entry -// points (natsServerCreateDM) whose downstream pipeline derives JetStream -// Nats-Msg-Id and message-ID dedup keys from the request ID. Silently minting -// would break client-retry dedup; see docs/error-handling.md §3a. Thin wrapper -// over natsutil.RequireRequestID so the test sits in the same package. -func requireDedupRequestID(ctx context.Context, headers nats.Header, subject string) (context.Context, string, error) { - return natsutil.RequireRequestID(ctx, headers, subject) -} - -// natsServerCreateDM is the NATS entry point for chat.server.request.room.{siteID}.create.dm. -func (h *Handler) natsServerCreateDM(m otelnats.Msg) { - ctx, id, err := requireDedupRequestID(m.Context(), m.Msg.Header, m.Msg.Subject) - if err != nil { - errnats.Reply(errcode.WithLogValues(m.Context(), "subject", m.Msg.Subject), m.Msg, err) - return - } - ctx = errcode.WithLogValues(ctx, "request_id", id, "subject", m.Msg.Subject) - reply, err := h.handleSyncCreateDM(ctx, m.Msg.Data) - if err != nil { - errnats.Reply(ctx, m.Msg, err) - return - } - natsutil.ReplyJSON(m.Msg, reply) -} - // fanOutRoomKeyToSurvivors sends the already-fetched room key to every survivor // account (local + remote). NATS supercluster routes user-subjects to home // sites. survivorAccounts is a pre-computed post-deletion snapshot supplied by diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index c4939d4d8..823e2264b 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -23,6 +23,7 @@ import ( "github.com/hmchangw/chat/pkg/errcode/errnats" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeysender" "github.com/hmchangw/chat/pkg/roomkeystore" @@ -2389,9 +2390,18 @@ func TestFillAsyncError_PermanentInternalCollision(t *testing.T) { assert.True(t, errors.As(jobErr, &pe)) } -// newRequestCtx returns a context carrying a syntactically-valid X-Request-ID. -func newRequestCtx() context.Context { - return natsutil.WithRequestID(context.Background(), "01970a4f-8c2d-7c9a-abcd-e0123456789f") +// dmCtx builds a *natsrouter.Context carrying the canonical request ID for +// serverCreateDM tests (normally stamped by RequireRequestID from the header). +func dmCtx() *natsrouter.Context { + return dmCtxWithID(testRequestID) +} + +// dmCtxWithID is dmCtx with a caller-chosen request ID for cases that pin a +// specific dedup seed. +func dmCtxWithID(id string) *natsrouter.Context { + c := natsrouter.NewContext(map[string]string{}) + c.SetContext(natsutil.WithRequestID(context.Background(), id)) + return c } // dmCapturedPublish + dmPublishCapture are unit-test-local equivalents of the @@ -2426,14 +2436,6 @@ func newSyncDMTestHandler(t *testing.T) (*Handler, *MockSubscriptionStore, *dmPu return h, store, capture } -// marshalReq JSON-encodes v or fails the test on error. -func marshalReq(t *testing.T, v any) []byte { - t.Helper() - data, err := json.Marshal(v) - require.NoError(t, err) - return data -} - // assertSyncDMInternal asserts err marshals (via errnats) to an internal-code // envelope with the generic "internal error" message and no leaked cause. func assertSyncDMInternal(t *testing.T, err error) { @@ -2478,11 +2480,8 @@ func TestSyncDMErrorEnvelope(t *testing.T) { // TestHandleSyncCreateDM_MissingRequestID retired — see the comment above // TestProcessAddMembers_RequiresRequestID. -func TestHandleSyncCreateDM_InvalidJSON(t *testing.T) { - h := &Handler{siteID: "site-a"} - _, err := h.handleSyncCreateDM(newRequestCtx(), []byte("{not json")) - assert.ErrorIs(t, err, errInvalidSyncDMRequest) -} +// TestHandleSyncCreateDM_InvalidJSON retired — malformed JSON is now rejected by +// natsrouter.Register's unmarshal step, covered by pkg/natsrouter tests. func TestHandleSyncCreateDM_InvalidRoomType(t *testing.T) { h := &Handler{siteID: "site-a"} @@ -2491,8 +2490,7 @@ func TestHandleSyncCreateDM_InvalidRoomType(t *testing.T) { RequesterAccount: "alice", OtherAccount: "bob", } - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errInvalidSyncDMRequest) } @@ -2503,8 +2501,7 @@ func TestHandleSyncCreateDM_EmptyAccounts(t *testing.T) { {RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: ""}, } for _, req := range cases { - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errInvalidSyncDMRequest) } } @@ -2531,8 +2528,7 @@ func TestHandleSyncCreateDM_SelfDM(t *testing.T) { }) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "alice"} - data := marshalReq(t, req) - reply, err := h.handleSyncCreateDM(newRequestCtx(), data) + reply, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.NotNil(t, reply) assert.True(t, reply.Success) @@ -2573,8 +2569,7 @@ func TestHandleSyncCreateDM_SelfBotDMRejected(t *testing.T) { RequesterAccount: "alice", OtherAccount: "alice", } - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errInvalidSyncDMRequest) } @@ -2607,8 +2602,8 @@ func TestHandleSyncCreateDM_SelfDM_StoreErrors(t *testing.T) { store.EXPECT().FindUsersByAccounts(gomock.Any(), gomock.Any()).Return([]model.User{alice}, nil) tc.setup(store) - data := marshalReq(t, model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "alice"}) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "alice"} + _, err := h.serverCreateDM(dmCtx(), req) require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErrIn) assert.Empty(t, capture.captured, "no publish on store error") @@ -2626,8 +2621,7 @@ func TestHandleSyncCreateDM_RequesterNotFound(t *testing.T) { RequesterAccount: "alice", OtherAccount: "bob", } - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errUserLookupFailed) } @@ -2641,8 +2635,7 @@ func TestHandleSyncCreateDM_OtherNotFound(t *testing.T) { RequesterAccount: "alice", OtherAccount: "bob", } - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errUserLookupFailed) } @@ -2656,8 +2649,7 @@ func TestHandleSyncCreateDM_CrossSiteRequester(t *testing.T) { RequesterAccount: "alice", OtherAccount: "bob", } - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errCrossSiteRequester) } @@ -2687,8 +2679,7 @@ func TestHandleSyncCreateDM_RoomCollisionMismatch(t *testing.T) { store.EXPECT().GetRoom(gomock.Any(), gomock.Any()).Return(&existing, nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) assert.ErrorIs(t, err, errRoomIDCollision) }) } @@ -2730,8 +2721,7 @@ func TestHandleSyncCreateDM_DM_PersistsSubsAndReturnsRequester(t *testing.T) { }, nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - reply, err := h.handleSyncCreateDM(newRequestCtx(), data) + reply, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.NotNil(t, reply) assert.True(t, reply.Success) @@ -2783,8 +2773,7 @@ func TestHandleSyncCreateDM_BotDM_RequesterSubIsSubscribedTrue(t *testing.T) { }, nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeBotDM, RequesterAccount: "alice", OtherAccount: "helper.bot"} - data := marshalReq(t, req) - reply, err := h.handleSyncCreateDM(newRequestCtx(), data) + reply, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.NotNil(t, reply) assert.True(t, reply.Subscription.IsSubscribed) @@ -2822,8 +2811,7 @@ func TestHandleSyncCreateDM_ReturnsCanonicalPersistedSub(t *testing.T) { }, nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - reply, err := h.handleSyncCreateDM(newRequestCtx(), data) + reply, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.NotNil(t, reply) assert.Equal(t, "canonical-sub", reply.Subscription.ID) @@ -2838,8 +2826,7 @@ func TestHandleSyncCreateDM_GetUserTransientError_Internal(t *testing.T) { store.EXPECT().FindUsersByAccounts(gomock.Any(), gomock.Any()).Return(nil, errors.New("mongo: connection refused")) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.Error(t, err) assert.NotErrorIs(t, err, errUserLookupFailed, "transient error must not be tagged as user-not-found") @@ -2860,8 +2847,7 @@ func TestHandleSyncCreateDM_PublishesSubscriptionUpdateForBothUsers(t *testing.T nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) subjects := map[string]int{} @@ -2886,8 +2872,7 @@ func TestHandleSyncCreateDM_CrossSite_EmitsOutbox(t *testing.T) { nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) var outbox *dmCapturedPublish @@ -2912,7 +2897,7 @@ func TestHandleSyncCreateDM_CrossSite_EmitsOutbox(t *testing.T) { assert.Equal(t, "site-a", payload.SiteID) assert.Equal(t, []string{"bob"}, payload.Accounts) assert.Equal(t, "alice", payload.RequesterAccount) - assert.Equal(t, "01970a4f-8c2d-7c9a-abcd-e0123456789f:site-b", outbox.msgID) + assert.Equal(t, testRequestID+":site-b", outbox.msgID) } func TestHandleSyncCreateDM_SameSite_NoOutbox(t *testing.T) { @@ -2929,8 +2914,7 @@ func TestHandleSyncCreateDM_SameSite_NoOutbox(t *testing.T) { nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) for _, p := range capture.captured { @@ -2963,8 +2947,7 @@ func TestHandleSyncCreateDM_OutboxPublishFails_FailsRequest(t *testing.T) { nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.Error(t, err) assertSyncDMInternal(t, err) } @@ -2981,8 +2964,7 @@ func TestHandleSyncCreateDM_BulkCreateSubsTransientError(t *testing.T) { Return(errors.New("mongo: connection reset")) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.Error(t, err) assertSyncDMInternal(t, err) } @@ -3023,8 +3005,7 @@ func TestHandleSyncCreateDM_IdempotentRecreate_UsesExistingCreatedAt(t *testing. nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.Len(t, captured, 2) @@ -3068,8 +3049,7 @@ func TestHandleSyncCreateDM_BotDM_Recreate_PreservesExistingCreatedAt(t *testing nil) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeBotDM, RequesterAccount: "alice", OtherAccount: "helper.bot"} - data := marshalReq(t, req) - _, err := h.handleSyncCreateDM(newRequestCtx(), data) + _, err := h.serverCreateDM(dmCtx(), req) require.NoError(t, err) require.Len(t, captured, 2) @@ -4206,7 +4186,6 @@ func TestHandleSyncCreateDM_SetsParticipantFieldsOnInitialCreate(t *testing.T) { ctrl := gomock.NewController(t) mockStore := NewMockSubscriptionStore(ctrl) h := &Handler{store: mockStore, siteID: "site-A", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} - ctx := natsutil.WithRequestID(context.Background(), testRequestID) requester := model.User{ID: "u_zzz", Account: "alice", EngName: "Alice", ChineseName: "愛", SiteID: "site-A"} other := model.User{ID: "u_aaa", Account: "bob", EngName: "Bob", ChineseName: "鮑", SiteID: "site-A"} @@ -4226,14 +4205,13 @@ func TestHandleSyncCreateDM_SetsParticipantFieldsOnInitialCreate(t *testing.T) { &model.Subscription{User: model.SubscriptionUser{ID: other.ID, Account: other.Account}}, nil) - reqBody, err := json.Marshal(model.SyncCreateDMRequest{ + req := model.SyncCreateDMRequest{ RequesterAccount: "alice", OtherAccount: "bob", RoomType: model.RoomTypeDM, - }) - require.NoError(t, err) + } - _, err = h.handleSyncCreateDM(ctx, reqBody) + _, err := h.serverCreateDM(dmCtxWithID(testRequestID), req) require.NoError(t, err) require.NotNil(t, captured) assert.Equal(t, []string{"u_aaa", "u_zzz"}, captured.UIDs) @@ -4345,40 +4323,8 @@ func TestHandler_ProcessAddMembers_HasOrgRoomMembersError_FailsClosed(t *testing assert.Contains(t, err.Error(), "check existing org room members") } -// natsServerCreateDM and the JetStream consume loop call this helper to -// validate the inbound X-Request-ID before any downstream dedup-key derivation -// runs. Missing/malformed → BadRequest (no server-side mint). The asymmetric -// policy vs the consume loop (which still mints defensively) lives in -// docs/error-handling.md §3a. -func TestRequireDedupRequestID(t *testing.T) { - const validUUID = "01970a4f-8c2d-7c9a-abcd-e0123456789f" - - t.Run("valid_passes", func(t *testing.T) { - h := nats.Header{natsutil.RequestIDHeader: []string{validUUID}} - ctx, id, err := requireDedupRequestID(context.Background(), h, "chat.test.subject") - require.NoError(t, err) - assert.Equal(t, validUUID, id) - assert.Equal(t, validUUID, natsutil.RequestIDFromContext(ctx)) - }) - - cases := []struct { - name string - headers nats.Header - }{ - {name: "nil_rejects", headers: nil}, - {name: "empty_rejects", headers: nats.Header{}}, - {name: "malformed_rejects", headers: nats.Header{natsutil.RequestIDHeader: []string{"not-a-uuid"}}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, _, err := requireDedupRequestID(context.Background(), tc.headers, "chat.test.subject") - require.Error(t, err) - var ec *errcode.Error - require.True(t, errors.As(err, &ec)) - assert.Equal(t, errcode.CodeBadRequest, ec.Code) - }) - } -} +// TestRequireDedupRequestID retired — the strict X-Request-ID gate now lives in +// pkg/natsrouter.RequireRequestID (see TestRequireRequestID_* there). // TestHandler_RotateAndFanOut_ErrNoCurrentKey_UsesPredictedVersion pins the // contract that when Rotate returns ErrNoCurrentKey (Valkey lost the key between @@ -4538,10 +4484,8 @@ func TestHandleSyncCreateDM_SelfDM_ProvisionsDEK(t *testing.T) { } req := model.SyncCreateDMRequest{RequesterAccount: "alice", OtherAccount: "alice", RoomType: model.RoomTypeDM} - data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "0193abcd-0193-7abc-89ab-0193abcd0011") - reply, err := h.handleSyncCreateDM(ctx, data) + reply, err := h.serverCreateDM(dmCtxWithID("0193abcd-0193-7abc-89ab-0193abcd0011"), req) require.NoError(t, err) require.True(t, reply.Success) require.Len(t, prov.calls, 1) @@ -4567,10 +4511,8 @@ func TestHandleSyncCreateDM_DEKFailure_AbortsBeforeCreate(t *testing.T) { } req := model.SyncCreateDMRequest{RequesterAccount: "alice", OtherAccount: "bob", RoomType: model.RoomTypeDM} - data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "0193abcd-0193-7abc-89ab-0193abcd0011") - _, err := h.handleSyncCreateDM(ctx, data) + _, err := h.serverCreateDM(dmCtxWithID("0193abcd-0193-7abc-89ab-0193abcd0011"), req) require.Error(t, err) assert.Len(t, prov.calls, 1, "EnsureDEK should have been attempted once") } @@ -4697,7 +4639,7 @@ func TestProcessRoomRename_UnmarshalFailure(t *testing.T) { defer ctrl.Finish() store := NewMockSubscriptionStore(ctrl) - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID var publishedSubjects []string h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, _ []byte, _ string) error { publishedSubjects = append(publishedSubjects, subj) @@ -4721,7 +4663,7 @@ func TestProcessRoomRename_RoomNotFound(t *testing.T) { defer ctrl.Finish() store := NewMockSubscriptionStore(ctrl) - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID store.EXPECT().UpdateRoomName(gomock.Any(), "r1", "renamed").Return(ErrRoomNotFound) var asyncResults []model.AsyncJobResult @@ -4750,7 +4692,7 @@ func TestProcessRoomRename_NotChannelRoom(t *testing.T) { defer ctrl.Finish() store := NewMockSubscriptionStore(ctrl) - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID store.EXPECT().UpdateRoomName(gomock.Any(), "r1", "renamed").Return(ErrNotChannelRoom) var asyncResults []model.AsyncJobResult @@ -4779,7 +4721,7 @@ func TestProcessRoomRename_TransientSubscriptionUpdateError(t *testing.T) { defer ctrl.Finish() store := NewMockSubscriptionStore(ctrl) - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID store.EXPECT().UpdateRoomName(gomock.Any(), "r1", "renamed").Return(nil) store.EXPECT().UpdateSubscriptionNamesForRoom(gomock.Any(), "r1", "renamed").Return(errors.New("mongo timeout")) @@ -4800,7 +4742,7 @@ func TestProcessRoomRename_HappyPathNoRemoteSites(t *testing.T) { store := NewMockSubscriptionStore(ctrl) const roomID, newName = "r1", "renamed" - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID subs := []model.Subscription{ {ID: "s1", User: model.SubscriptionUser{ID: "u1", Account: "alice"}, RoomID: roomID}, @@ -4844,7 +4786,7 @@ func TestProcessRoomRename_HappyPathWithRemoteSite(t *testing.T) { store := NewMockSubscriptionStore(ctrl) const roomID, newName = "r1", "renamed" - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID ts := int64(1700000000000) subs := []model.Subscription{ @@ -4910,7 +4852,7 @@ func TestProcessRoomRename_ErrorThenOkRetrySequence(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() store := NewMockSubscriptionStore(ctrl) - requestID := "01970a4f-8c2d-7c9a-abcd-e0123456789f" + requestID := testRequestID store.EXPECT().UpdateRoomName(gomock.Any(), "r1", "x").Return(errors.New("mongo timeout")) store.EXPECT().UpdateRoomName(gomock.Any(), "r1", "x").Return(nil) diff --git a/room-worker/integration_test.go b/room-worker/integration_test.go index a56e995d4..749d3a9f5 100644 --- a/room-worker/integration_test.go +++ b/room-worker/integration_test.go @@ -22,6 +22,7 @@ import ( "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/model" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/roomkeysender" "github.com/hmchangw/chat/pkg/roomkeystore" @@ -1007,10 +1008,12 @@ func TestProcessRemoveIndividual_PublishesLocalInbox_Integration(t *testing.T) { // --- Sync DM endpoint integration tests --- -const integSyncDMRequestID = "01970a4f-8c2d-7c9a-abcd-e0123456789f" - -func newIntegSyncDMCtx() context.Context { - return natsutil.WithRequestID(context.Background(), integSyncDMRequestID) +// newIntegSyncDMCtx returns a *natsrouter.Context with the canonical request ID +// for serverCreateDM; it also satisfies context.Context for store calls. +func newIntegSyncDMCtx() *natsrouter.Context { + c := natsrouter.NewContext(map[string]string{}) + c.SetContext(natsutil.WithRequestID(context.Background(), testRequestID)) + return c } func TestSyncCreateDM_DM_PersistsRoomAndSubs(t *testing.T) { @@ -1026,8 +1029,7 @@ func TestSyncCreateDM_DM_PersistsRoomAndSubs(t *testing.T) { handler := NewHandler(store, siteID, cap.fn(), testKeyStore, testKeySender) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data, _ := json.Marshal(req) - got, err := handler.handleSyncCreateDM(ctx, data) + got, err := handler.serverCreateDM(ctx, req) require.NoError(t, err) require.NotNil(t, got) assert.True(t, got.Success) @@ -1070,9 +1072,7 @@ func TestSyncCreateDM_SelfDM_PersistsSingleFavoritedSub(t *testing.T) { handler := NewHandler(store, siteID, cap.fn(), testKeyStore, testKeySender) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "alice"} - data, err := json.Marshal(req) - require.NoError(t, err) - got, err := handler.handleSyncCreateDM(ctx, data) + got, err := handler.serverCreateDM(ctx, req) require.NoError(t, err) require.NotNil(t, got) assert.True(t, got.Success) @@ -1127,8 +1127,7 @@ func TestSyncCreateDM_BotDM_CrossSiteOutbox(t *testing.T) { handler := NewHandler(store, siteID, cap.fn(), testKeyStore, testKeySender) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeBotDM, RequesterAccount: "alice", OtherAccount: "helper.bot"} - data, _ := json.Marshal(req) - _, err := handler.handleSyncCreateDM(newIntegSyncDMCtx(), data) + _, err := handler.serverCreateDM(newIntegSyncDMCtx(), req) require.NoError(t, err) pubs := cap.outboxOnPrefix(subject.Outbox(siteID, "site-B", model.OutboxMemberAdded)) @@ -1148,11 +1147,10 @@ func TestSyncCreateDM_RetryIdempotent(t *testing.T) { handler := NewHandler(store, siteID, cap.fn(), testKeyStore, testKeySender) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data, _ := json.Marshal(req) - r1, err := handler.handleSyncCreateDM(ctx, data) + r1, err := handler.serverCreateDM(newIntegSyncDMCtx(), req) require.NoError(t, err) - r2, err := handler.handleSyncCreateDM(ctx, data) + r2, err := handler.serverCreateDM(newIntegSyncDMCtx(), req) require.NoError(t, err) require.NotNil(t, r1) require.NotNil(t, r2) @@ -1185,9 +1183,7 @@ func TestSyncCreateDM_CrossSite_OutboxPayloadConverges(t *testing.T) { handler := NewHandler(store, siteID, cap1.fn(), testKeyStore, testKeySender) req := model.SyncCreateDMRequest{RoomType: model.RoomTypeDM, RequesterAccount: "alice", OtherAccount: "bob"} - data, err := json.Marshal(req) - require.NoError(t, err) - _, err = handler.handleSyncCreateDM(ctx, data) + _, err := handler.serverCreateDM(newIntegSyncDMCtx(), req) require.NoError(t, err) // 1. Local Mongo room.ID equals the deterministic BuildDMRoomID. @@ -1215,7 +1211,7 @@ func TestSyncCreateDM_CrossSite_OutboxPayloadConverges(t *testing.T) { // on the wire, JetStream OUTBOX dedup would reject the second emit. cap2 := &publishCapture{} handler2 := NewHandler(store, siteID, cap2.fn(), testKeyStore, testKeySender) - _, err = handler2.handleSyncCreateDM(ctx, data) + _, err = handler2.serverCreateDM(newIntegSyncDMCtx(), req) require.NoError(t, err) pubs2 := cap2.outboxOnPrefix(subject.Outbox(siteID, "site-B", model.OutboxMemberAdded)) require.Len(t, pubs2, 1) diff --git a/room-worker/main.go b/room-worker/main.go index 4c6ee2f99..8779d4ee9 100644 --- a/room-worker/main.go +++ b/room-worker/main.go @@ -16,6 +16,7 @@ import ( "github.com/hmchangw/chat/pkg/atrest" "github.com/hmchangw/chat/pkg/idgen" "github.com/hmchangw/chat/pkg/mongoutil" + "github.com/hmchangw/chat/pkg/natsrouter" "github.com/hmchangw/chat/pkg/natsutil" "github.com/hmchangw/chat/pkg/otelutil" "github.com/hmchangw/chat/pkg/roomkeysender" @@ -113,7 +114,7 @@ func main() { keySender := roomkeysender.NewSender(nc.NatsConn()) // Eager at-rest DEK provisioning for synchronously-created DM rooms (the - // handleSyncCreateDM path bypasses room-service's create-room flow). nil when + // serverCreateDM path bypasses room-service's create-room flow). nil when // disabled; message-worker's lazy creation remains the fallback. var vaultWrapper atrest.KeyWrapperCloser var dekProvisioner DEKProvisioner @@ -149,10 +150,9 @@ func main() { handler.SetKeyFanoutWorkers(cfg.KeyFanoutWorkers) handler.dekProvisioner = dekProvisioner - if _, err := nc.QueueSubscribe(subject.RoomCreateDMSync(cfg.SiteID), "room-worker", handler.natsServerCreateDM); err != nil { - slog.Error("subscribe sync DM endpoint failed", "error", err) - os.Exit(1) - } + router := natsrouter.New(nc, "room-worker") + router.Use(natsrouter.Recovery(), natsrouter.RequireRequestID(), natsrouter.Logging()) + natsrouter.Register(router, subject.RoomCreateDMSync(cfg.SiteID), handler.serverCreateDM) cons, err := js.CreateOrUpdateConsumer(ctx, streamCfg.Name, buildConsumerConfig(cfg.Consumer)) if err != nil { @@ -211,6 +211,7 @@ func main() { return fmt.Errorf("worker drain timed out: %w", ctx.Err()) } }, + func(ctx context.Context) error { return router.Shutdown(ctx) }, func(ctx context.Context) error { return nc.Drain() }, func(ctx context.Context) error { mongoutil.Disconnect(ctx, mongoClient); return nil }, func(ctx context.Context) error { return keyStore.Close() },