From e63a4e01569028cd1877338a8e144d2ae32f7066 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:02:00 +0000 Subject: [PATCH 01/15] docs(spec): add room-worker membership fixes design spec Spec for the room-worker membership filtering, system-message sender/content population, request-ID validation, fail-closed HasOrgRoomMembers handling, and DM participant fields work. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-13-room-worker-membership-fixes-design.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md diff --git a/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md b/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md new file mode 100644 index 000000000..c8fa1e0c6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md @@ -0,0 +1,205 @@ +# Room-Worker Membership Fixes — Design + +**Date:** 2026-05-13 +**Branch:** `claude/fix-org-membership-duplication-l5LfI` +**Service:** `room-worker` + +## 1. Bugs + +1. **Org-member duplication.** Users brought in via org expansion get both an individual `room_members` doc AND the org `room_members` doc, so they appear twice in `ListRoomMembers`. +2. **`members_added` empty body.** Sys-message carries `SysMsgData` but no `Content` — channel bubble renders blank. +3. **`member_removed` / `member_left` no sender + empty body.** Sys-message sets no `UserID`, `UserAccount`, or `Content` — UI renders "Unknown sent the message" with empty body. + +No wire-schema changes. No data migration for existing duplicates — rooms with pre-existing duplicate `room_members` docs remain duplicated until a separate cleanup job (out of scope) is run. + +## 2. Fixes + +### 2.1 Individual `room_members` write rule + +A user gets an individual `room_members` doc iff their account is in the request's direct-individual set: + +- `processAddMembers` ([handler.go:649-661](../../../room-worker/handler.go#L649-L661)): direct set = `req.Users`. +- `processCreateRoomChannel` ([handler.go:1056-1063](../../../room-worker/handler.go#L1056-L1063)): direct set = `req.ResolvedUsers ∪ {requester.Account}`. + +Channel-ref accounts/orgs are already merged into `req.Users`/`req.Orgs` (and `req.ResolvedUsers`/`req.ResolvedOrgs`) upstream in room-service, so the whitelist picks them up automatically. Org doc writing and the `writeIndividuals` gate are unchanged. + +This filter applies *inside* the existing gates — `processCreateRoomChannel`'s `if len(req.ResolvedOrgs) > 0` block at [handler.go:1054](../../../room-worker/handler.go#L1054) (no-orgs lite-mode continues to skip `room_members` entirely per the comment at [handler.go:1076-1079](../../../room-worker/handler.go#L1076-L1079)) and `processAddMembers`'s `writeIndividuals` gate. Neither gate is replaced. + +### 2.2 Backfill gate + +The backfill at [handler.go:677-708](../../../room-worker/handler.go#L677-L708) materializes individual docs for pre-existing subscribers. Current gate (`writeIndividuals && len(req.Orgs) > 0`) fires on every org-bearing add; after 2.1 it would re-introduce the duplication for previously org-expanded users. + +Tighten to the **first-org transition only**: run backfill iff `len(req.Orgs) > 0 && !hadOrgsBefore`. + +The current block at [handler.go:637-644](../../../room-worker/handler.go#L637-L644) short-circuits `HasOrgRoomMembers` when `len(req.Orgs) > 0`, so `hadOrgsBefore` isn't actually available in the org-bearing path. Restructure to always query first: + +```go +hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID) +if err != nil { + slog.Warn("check existing org room members failed", "error", err, "roomID", req.RoomID) +} +writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore +``` + +Cost: one extra indexed Mongo read on the org-bearing path (which is already about to do a bulk insert). Benefit: the backfill gate reduces to a trivial boolean check and is correct in every path. + +### 2.3 `members_added` Content + +- `processAddMembers` ([handler.go:776-784](../../../room-worker/handler.go#L776-L784)) — count-sensitive on `len(subs)`, but only the **direct-add** case takes the single form: + - `len(subs) == 1 && len(req.Orgs) == 0`: `"{req.engName} {req.chineseName} added {u.engName} {u.chineseName} to the channel"` + - otherwise (multi-direct, or any org-bearing add even when the org expands to one user): `"{req.engName} {req.chineseName} added members to the channel"` + + Rationale: when the requester adds an org that happens to have one member, the message "Alice added Bob to the channel" misleadingly implies an individual add — and future org members would later appear without any matching sys-message. Pinning the single form to `len(req.Orgs) == 0` keeps the message honest about what was actually added. +- `publishChannelSysMessages` ([handler.go:1248-1256](../../../room-worker/handler.go#L1248-L1256)) — always multi form: + - `"{req.engName} {req.chineseName} added members to the channel"` + +`processAddMembers` needs the requester's `EngName`/`ChineseName`; fetch via `store.GetUser(ctx, req.RequesterAccount)`. The alternative — appending the requester to the existing `FindUsersByAccounts` call at [handler.go:572](../../../room-worker/handler.go#L572) and excluding it from the sub-build loop at [handler.go:606-631](../../../room-worker/handler.go#L606-L631) — saves one Mongo roundtrip but adds branching to the hot path; on a low-throughput RPC the dedicated fetch is the cleaner choice. + +A miss is a permanent error: `newPermanent("requester %s not found", req.RequesterAccount)`. Empty `EngName` or `ChineseName` is also a permanent error, mirroring the create-room validation at [handler.go:1025-1027](../../../room-worker/handler.go#L1025-L1027). The same empty-name check must be added for the *added* users returned by `FindUsersByAccounts` — existing code at [handler.go:585-589](../../../room-worker/handler.go#L585-L589) only checks for missing accounts. + +`publishChannelSysMessages` already has `requester *model.User` in scope (validated at create-room time). + +### 2.4 `member_removed` / `member_left` — sender + Content + +**Sender envelope (when emitted):** set `UserAccount = req.Requester`. `UserID` is unused by broadcast-worker's sender enrichment ([handler.go:73](../../../broadcast-worker/handler.go#L73)), so it stays empty — keeps `RemoveMemberRequest` wire schema untouched. + +**Content text (passive voice — no requester name in the body):** + +| Path | Sys-type | Content | +|---|---|---| +| `processRemoveIndividual` self-leave | `member_left` | `"{user.engName} {user.chineseName} left the channel"` | +| `processRemoveIndividual` other | `member_removed` | `"{user.engName} {user.chineseName} has been removed from the channel"` | +| `processRemoveOrg` | `member_removed` | `"{sectName} has been removed from the channel"` | + +The removed user is already fetched at [handler.go:259](../../../room-worker/handler.go#L259). No requester lookup is needed on the remove paths. If the fetched user has empty `EngName` or `ChineseName`, return a permanent error (same validation as [handler.go:1025-1027](../../../room-worker/handler.go#L1025-L1027)) so the formatter never produces a malformed body. + +**`sectName` source for `processRemoveOrg`:** `SectName` is NOT on `RemoveMemberRequest`. Currently harvested from `toRemove` ([handler.go:433-437](../../../room-worker/handler.go#L433-L437)), which is empty when every org member also has an individual sub. Change the loop to iterate the **unfiltered** `members` slice returned by `GetOrgMembersWithIndividualStatus` and pick the first non-empty `OrgMemberStatus.SectName`. If every member's `SectName` is empty (a data inconsistency upstream), return a permanent error rather than emit a malformed sys-message. The same value continues to populate `MemberRemoved.SectName`. + +### 2.5 When to emit on the remove paths + +- `processRemoveIndividual` full removal (no org overlap; sub + indiv room_members both deleted): **emit**. +- `processRemoveIndividual` demote-only (user is also reachable via an org; indiv room_members deleted, sub preserved, owner→member if applicable): **skip**. The user has not actually left. +- `processRemoveOrg`: **always emit** (org's room_members doc is deleted). Holds even when some org members keep individual subs. + +### 2.6 Helpers + +New `room-worker/sysmsg.go`: + +- `formatAddedSingle(requester, added *model.User) string` — `"{req.engName} {req.chineseName} added {added.engName} {added.chineseName} to the channel"`. Used by `processAddMembers` when `len(subs) == 1`. +- `formatAddedMulti(requester *model.User) string` — `"{req.engName} {req.chineseName} added members to the channel"`. Used by `processAddMembers` when `len(subs) >= 2` and by `publishChannelSysMessages` unconditionally. +- `formatRemovedUserContent(user *model.User) string` +- `formatRemovedOrgContent(sectName string) string` +- `formatLeftContent(user *model.User) string` + +Display-name composition: `strings.TrimSpace(u.EngName + " " + u.ChineseName)`. Empty-name inputs are *not* handled by the formatters — callers validate `EngName`/`ChineseName` non-empty before invocation (§2.3, §2.4). The formatters trust their inputs. + +Unit tests in `room-worker/sysmsg_test.go`. No store interface changes. + +## 3. DM Participant Fields + +Unrelated to the membership-correctness work above, but folded into this spec per project direction: every `dm` and `botDM` room gets two new `model.Room` fields that expose the two participants directly on the room document, so clients and downstream services don't have to follow the room → subscription join to learn who the pair is. + +### 3.1 Field shape + +Add to `pkg/model/room.go`: + +```go +type Room struct { + // ...existing fields unchanged... + UIDs []string `json:"uids,omitempty" bson:"uids,omitempty"` + Accounts []string `json:"accounts,omitempty" bson:"accounts,omitempty"` +} +``` + +Both are `omitempty` on `json` and `bson`. Non-DM rooms (channel, discussion) MUST never carry them — the field is omitted from the BSON document, not stored as an empty array. Legacy `dm`/`botDM` rooms created before this change also continue to omit them (see §3.3). + +### 3.2 Pairing invariant + +`UIDs` is sorted lexicographically. `Accounts` is permuted to mirror the `UIDs` ordering — that is, `UIDs[i]` and `Accounts[i]` describe the same user. The two arrays are not independently sorted. + +New helper in `pkg/model/room.go`: + +```go +// BuildDMParticipants returns ([uidA, uidB], [accountA, accountB]) sorted by +// UID and paired by index: UIDs[i] and Accounts[i] describe the same user. +// Callers must pass exactly two distinct *User values; this is enforced +// upstream (room-service capacity check + room-worker counterpart fetch). +func BuildDMParticipants(a, b *User) (uids, accounts []string) +``` + +### 3.3 Where they are set + +Forward-only. No migration for existing DM/botDM rooms — any consumer that filters on `uids`/`accounts` must tolerate absent values for legacy rooms. + +Three call sites: + +1. **`processCreateRoomDM`** ([room-worker/handler.go](../../../room-worker/handler.go)) — after the existing `GetUser(counterpart)` and `BulkCreateSubscriptions`, call a new store method `UpdateDMParticipants(ctx, roomID, uids, accounts)` to `$set` the two fields on the already-persisted room. +2. **`processCreateRoomBotDM`** ([room-worker/handler.go](../../../room-worker/handler.go)) — same pattern; the bot's `ID` and `Account` populate one slot of the pair. +3. **`handleSyncCreateDM`** ([room-worker/handler.go](../../../room-worker/handler.go)) — already fetches both users before `CreateRoom`. Set `room.UIDs` and `room.Accounts` on the `&model.Room{...}` literal directly. No `UpdateDMParticipants` call needed on this path. + +The two-write shape on the async path (one `CreateRoom`, one `UpdateDMParticipants`) is the accepted trade-off for keeping DM-specific logic inside the DM-specific handlers and leaving `processCreateRoom`'s collision-handling dispatch untouched. The `$set` is idempotent so JetStream redelivery converges; a worker crash between the two writes resolves on replay. + +### 3.4 Store interface change + +Add to `room-worker/store.go`: + +```go +type SubscriptionStore interface { + // ...existing methods unchanged... + UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error +} +``` + +MongoDB implementation: `UpdateOne({"_id": roomID}, {"$set": {"uids": uids, "accounts": accounts}})`. `MatchedCount == 0` is an error: the handler creates the room before this call, so a zero-match means the doc disappeared (race delete, wrong roomID, replica lag) — surface as a wrapped error so the handler returns it and JetStream retries. + +`make generate SERVICE=room-worker` regenerates `mock_store_test.go` after the interface change. + +### 3.5 Test plan + +- `pkg/model/model_test.go` — extend the round-trip helper coverage to include `UIDs`/`Accounts` populated and `nil` cases. +- `pkg/model/room_test.go` — unit test `BuildDMParticipants` covering: sort by UID, accounts mirror permutation, a case where UID order ≠ Account order (proves pairing is honored), and exactly two outputs. +- `room-worker/handler_test.go` — extend existing DM/botDM create tests (`TestProcessCreateRoom_DM_BuildsTwoSubs`, `TestProcessCreateRoom_BotDM_HasIsSubscribed`, the relevant `TestHandleSyncCreateDM_*` cases) to assert the captured room carries the expected `UIDs`/`Accounts`. For the async paths, also assert `UpdateDMParticipants` was called with the correct sorted/paired args. +- `room-worker/handler_test.go` — add a sibling case for create-channel confirming `UIDs`/`Accounts` remain absent on a captured channel room (proves the `omitempty` guarantee). + +## 4. Acceptance Criteria + +**A. `room_members` correctness** +- A1. Add `Users=[u1], Orgs=[o1]` (o1 has `[u1, u2]`): 1 indiv doc for `u1`, 1 org doc for `o1`. No indiv for `u2`. +- A2. Add `Users=[], Orgs=[o1]` (o1 has `[u1]`): 1 org doc for `o1`. No indiv for `u1`. +- A3. Add `Users=[u1], Orgs=[]` to a room that already has an org member: 1 indiv doc for `u1` only. +- A4. Create channel `ResolvedUsers=[u1], ResolvedOrgs=[o1]` (o1 has `[u1, u2]`), requester `r`: indiv docs for `r` and `u1`, org doc for `o1`. No indiv for `u2`. +- A5. First-org transition: pre-existing direct-individual subs get individual docs materialized. +- A6. Subsequent org-bearing add (room already has org docs): backfill skipped; previously org-only users stay org-only. + +**B. `members_added` Content** +- B1. `processAddMembers` `len(subs)==1`: `"{req} added {u} to the channel"`. +- B2. `processAddMembers` `len(subs)≥2`: `"{req} added members to the channel"`. +- B3. `publishChannelSysMessages` any `len(subs)-1 ≥ 1`: `"{req} added members to the channel"` (no single-name special case). + +**C. Remove sys-messages** +- C1. Self-leave (full removal): `Type=member_left`, `UserAccount=req.Requester`, Content = `"{user} left the channel"`. +- C2. Removed-by-other (full removal): `Type=member_removed`, `UserAccount=req.Requester`, Content = `"{user} has been removed from the channel"`. +- C3. Org remove (any `len(toRemove)`, including 0): `Type=member_removed`, `UserAccount=req.Requester`, Content = `"{sectName} has been removed from the channel"`. `sectName` is correctly populated even when `toRemove` is empty. +- C4. Demote-only individual remove: no sys-message published. +- C5. Org remove when some org members also have individual subs: sys-message in C3 still published. + +**D. Negative** +- D1. `processAddMembers` requester lookup miss → permanent error, no sys-message. +- D2. `processAddMembers` requester has empty `EngName` or `ChineseName` → permanent error, no sys-message. +- D3. `processAddMembers` any added user has empty `EngName` or `ChineseName` → permanent error, no sys-message. +- D4. `processRemoveIndividual` target user has empty `EngName` or `ChineseName` → permanent error, no sys-message. +- D5. `processRemoveOrg` every member's `SectName` is empty → permanent error, no sys-message. + +**E. Verification** +- `make lint SERVICE=room-worker` passes. +- `make test SERVICE=room-worker` passes with race detector and ≥80% coverage. +- `make generate SERVICE=room-worker` produces no diff against the regenerated `mock_store_test.go` (after the §3.4 interface change). + +**F. DM Participant Fields (§3)** +- F1. Async DM create (`processCreateRoomDM`): the persisted room carries `UIDs = sort([requester.ID, other.ID])` and `Accounts` permuted to mirror that order so `UIDs[i]` and `Accounts[i]` describe the same user. Set via `UpdateDMParticipants` after the counterpart fetch. +- F2. Async botDM create (`processCreateRoomBotDM`): same shape as F1 with the bot's `ID`/`Account` in one slot of the pair. +- F3. Sync DM create (`handleSyncCreateDM`): both fields set on the initial `&model.Room{...}` literal — no `UpdateDMParticipants` call on this path. Same sort/pairing invariant as F1. +- F4. Channel create: `UIDs` and `Accounts` are absent from the BSON document (not stored as empty arrays). Verified by capturing the room passed to `CreateRoom` and confirming both fields are nil. +- F5. Pairing under non-aligned sort: a DM between user `{ID:"zzz", Account:"aaa"}` and user `{ID:"aaa", Account:"zzz"}` yields `UIDs=["aaa","zzz"]` and `Accounts=["zzz","aaa"]` — `UIDs[0]/Accounts[0]` and `UIDs[1]/Accounts[1]` each describe the same user. Independently sorting both arrays would have produced `Accounts=["aaa","zzz"]`, which is incorrect. +- F6. JetStream redelivery: `UpdateDMParticipants` is `$set`, so a replayed `processCreateRoomDM` after a partial-state crash converges to the same final document. +- F7. Backward compatibility: a pre-existing DM room without `uids`/`accounts` is not modified by code paths other than `processCreateRoomDM`/`processCreateRoomBotDM` on a fresh create. No migration job runs. From f805c33df6ddece94b9e5af52279751c169173f7 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:02:09 +0000 Subject: [PATCH 02/15] docs(plan): add room-worker membership fixes implementation plan Task-by-task plan covering the membership-fix work: filter individual room docs, populate sender/content on system messages, fail-closed HasOrgRoomMembers, X-Request-ID UUID validation, and DM participant fields (Tasks 11-17). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-14-room-worker-membership-fixes.md | 1855 +++++++++++++++++ 1 file changed, 1855 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md diff --git a/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md b/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md new file mode 100644 index 000000000..93ae61f0c --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md @@ -0,0 +1,1855 @@ +# Room-Worker Membership Fixes 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:** Fix three room-worker bugs — org-member duplication in `room_members`, empty `Content` on `members_added` sys-messages, and missing sender + empty `Content` on `member_removed` / `member_left` — without changing wire schemas or migrating existing data. + +**Architecture:** Introduce a `room-worker/sysmsg.go` helper file with five formatter functions. Apply localized changes inside `processAddMembers`, `processCreateRoomChannel`, `processRemoveIndividual`, `processRemoveOrg`, and `publishChannelSysMessages` in `room-worker/handler.go`. All changes are internal to `room-worker`; no store interface, model, or wire-protocol changes. + +**Tech Stack:** Go 1.25, NATS JetStream, MongoDB driver v2, `go.uber.org/mock` (mockgen), `stretchr/testify`. + +**Spec:** [docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md](../specs/2026-05-13-room-worker-membership-fixes-design.md) + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `room-worker/sysmsg.go` | Create | Five `formatX` helpers + `displayName` | +| `room-worker/sysmsg_test.go` | Create | Unit tests for the helpers | +| `room-worker/handler.go` | Modify | `processAddMembers`, `processCreateRoomChannel`, `processRemoveIndividual`, `processRemoveOrg`, `publishChannelSysMessages` | +| `room-worker/handler_test.go` | Modify | New table-driven tests for filter rule, backfill gate, Content, sender, name validation | + +No `store.go` changes — every method the plan needs is already on the `SubscriptionStore` interface. + +--- + +## Task 1: Formatter helpers + +**Files:** +- Create: `room-worker/sysmsg.go` +- Create: `room-worker/sysmsg_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `room-worker/sysmsg_test.go`: + +```go +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hmchangw/chat/pkg/model" +) + +func TestFormatAddedSingle(t *testing.T) { + got := formatAddedSingle( + &model.User{EngName: "Alice", ChineseName: "愛麗絲"}, + &model.User{EngName: "Bob", ChineseName: "鮑勃"}, + ) + assert.Equal(t, "Alice 愛麗絲 added Bob 鮑勃 to the channel", got) +} + +func TestFormatAddedMulti(t *testing.T) { + got := formatAddedMulti(&model.User{EngName: "Alice", ChineseName: "愛麗絲"}) + assert.Equal(t, "Alice 愛麗絲 added members to the channel", got) +} + +func TestFormatRemovedUserContent(t *testing.T) { + got := formatRemovedUserContent(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) + assert.Equal(t, "Bob 鮑勃 has been removed from the channel", got) +} + +func TestFormatRemovedOrgContent(t *testing.T) { + got := formatRemovedOrgContent("Engineering") + assert.Equal(t, "Engineering has been removed from the channel", got) +} + +func TestFormatLeftContent(t *testing.T) { + got := formatLeftContent(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) + assert.Equal(t, "Bob 鮑勃 left the channel", got) +} + +func TestDisplayName_TrimsSingleSide(t *testing.T) { + // Spec §2.6: TrimSpace(EngName + " " + ChineseName) — when one side is empty, + // the result still has no leading/trailing whitespace. Callers are responsible + // for rejecting fully-empty inputs; this test pins the trim behavior only. + assert.Equal(t, "Bob left the channel", formatLeftContent(&model.User{EngName: "Bob"})) + assert.Equal(t, "鮑勃 left the channel", formatLeftContent(&model.User{ChineseName: "鮑勃"})) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test SERVICE=room-worker` +Expected: FAIL with `undefined: formatAddedSingle` (or similar) for each formatter. + +- [ ] **Step 3: Write minimal implementation** + +Create `room-worker/sysmsg.go`: + +```go +package main + +import ( + "strings" + + "github.com/hmchangw/chat/pkg/model" +) + +func displayName(u *model.User) string { + return strings.TrimSpace(u.EngName + " " + u.ChineseName) +} + +func formatAddedSingle(requester, added *model.User) string { + return displayName(requester) + " added " + displayName(added) + " to the channel" +} + +func formatAddedMulti(requester *model.User) string { + return displayName(requester) + " added members to the channel" +} + +func formatRemovedUserContent(user *model.User) string { + return displayName(user) + " has been removed from the channel" +} + +func formatRemovedOrgContent(sectName string) string { + return sectName + " has been removed from the channel" +} + +func formatLeftContent(user *model.User) string { + return displayName(user) + " left the channel" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test SERVICE=room-worker` +Expected: PASS for all six tests in `sysmsg_test.go`. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/sysmsg.go room-worker/sysmsg_test.go +git commit -m "feat(room-worker): add system-message formatter helpers" +``` + +--- + +## Task 2: Restructure `HasOrgRoomMembers` call + tighten backfill gate + +Implements spec §2.2. Backfill must fire only on the first-org transition (`len(req.Orgs) > 0 && !hadOrgsBefore`). + +**Files:** +- Modify: `room-worker/handler.go:637-644` and `:677` +- Test: `room-worker/handler_test.go` (new test function) + +- [ ] **Step 1: Write the failing tests** + +Append to `room-worker/handler_test.go`: + +```go +func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID). + Return([]string{"u_new"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). + Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) // first org + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).Return(nil) + + // First-org transition MUST call GetSubscriptionAccounts (backfill kickoff). + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{"existing_user"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"existing_user"}). + Return([]model.User{{ID: "u_e", Account: "existing_user", SiteID: "site-a", EngName: "Ex", ChineseName: "存"}}, nil) + + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) +} + +func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o_new"}, []string(nil), roomID). + Return([]string{"u_new"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). + Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + // Restructured code calls HasOrgRoomMembers unconditionally. + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(true, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).Return(nil) + // NO GetSubscriptionAccounts expectation — backfill must be skipped. + + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o_new"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) +} +``` + +Note: the `GetUser(gomock.Any(), "alice")` expectation in both tests anticipates Task 5's requester fetch. The handler doesn't call `GetUser` yet, so this expectation is unmet today — that's deliberate, by the end of Task 5 both tests must pass without changes. If you'd rather keep Task 2 strictly self-contained, drop the `GetUser` line here and re-add it in Task 5 alongside the other mock-expectation updates that step calls out. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run BackfillRunsOnFirstOrgTransition` +Expected: FAIL — current code (handler.go:637-644) short-circuits `HasOrgRoomMembers` when `len(req.Orgs) > 0`, so the expectation is unmet. + +Run: `make test SERVICE=room-worker -run BackfillSkippedWhenRoomAlreadyHasOrgs` +Expected: FAIL — current backfill gate fires whenever `len(req.Orgs) > 0`, triggering unexpected `GetSubscriptionAccounts` call. + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:637-644`: + +```go + // Fail closed: defaulting hadOrgsBefore=false on error would trigger spurious first-org backfill. + hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID) + if err != nil { + return fmt.Errorf("check existing org room members: %w", err) + } + writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore +``` + +Replace `room-worker/handler.go:677` (the backfill gate): + +```go + if len(req.Orgs) > 0 && !hadOrgsBefore { +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run BackfillRunsOnFirstOrgTransition` +Expected: PASS. + +Run: `make test SERVICE=room-worker -run BackfillSkippedWhenRoomAlreadyHasOrgs` +Expected: PASS (assuming Task 5's `GetUser` expectation is honored or removed). + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "fix(room-worker): tighten backfill gate to first-org transition only" +``` + +--- + +## Task 3: Filter `processAddMembers` individual `room_members` write to `req.Users` + +Implements spec §2.1 for `processAddMembers`. A user gets an individual `room_members` doc iff their account is in `req.Users`. + +**Files:** +- Modify: `room-worker/handler.go:649-661` +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`: + +```go +// A1: Users=[u1], Orgs=[o1] (o1 has [u1, u2]). Expect indiv only for u1, org for o1. +func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string{"u1"}, roomID). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{}, nil) // no pre-existing subs + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) + + // Collect entries by type. + var indivAccts []string + var orgIDs []string + for _, m := range captured { + switch m.Member.Type { + case model.RoomMemberIndividual: + indivAccts = append(indivAccts, m.Member.Account) + case model.RoomMemberOrg: + orgIDs = append(orgIDs, m.Member.ID) + } + } + assert.ElementsMatch(t, []string{"u1"}, indivAccts, "indiv docs limited to req.Users") + assert.ElementsMatch(t, []string{"o1"}, orgIDs) +} + +// A2: Users=[], Orgs=[o1]. Expect org only, no indivs. +func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{}, nil) + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) + + for _, m := range captured { + assert.NotEqual(t, model.RoomMemberIndividual, m.Member.Type, "no indiv docs should be written") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run IndivFilter` +Expected: FAIL — current loop at handler.go:650-661 writes indiv docs for every sub, so `u2` will be in `indivAccts` (A1 test) and `u1` will be in indivs (A2 test). + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:649-661`: + +```go + allowedIndiv := make(map[string]struct{}, len(req.Users)) + for _, acc := range req.Users { + allowedIndiv[acc] = struct{}{} + } + if writeIndividuals { + for _, sub := range subs { + if _, ok := allowedIndiv[sub.User.Account]; !ok { + continue + } + roomMembers = append(roomMembers, &model.RoomMember{ + ID: idgen.GenerateUUIDv7(), + RoomID: req.RoomID, + Ts: acceptedAt, + Member: model.RoomMemberEntry{ + ID: sub.User.ID, + Type: model.RoomMemberIndividual, + Account: sub.User.Account, + }, + }) + } + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run IndivFilter` +Expected: PASS. + +Also run the existing `TestHandler_ProcessAddMembers_WithOrgs` to confirm no regression: + +Run: `make test SERVICE=room-worker -run TestHandler_ProcessAddMembers_WithOrgs` +Expected: PASS (may need its expected-doc list updated if it currently relies on the buggy behavior — investigate before adjusting; do NOT change assertions that test correct membership). + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "fix(room-worker): filter processAddMembers indiv docs to req.Users only" +``` + +--- + +## Task 4: Filter `processCreateRoomChannel` individual write to `ResolvedUsers ∪ {requester}` + +Implements spec §2.1 for create-room. The filter runs inside the existing `if len(req.ResolvedOrgs) > 0` gate; no-orgs lite-mode is preserved. + +**Files:** +- Modify: `room-worker/handler.go:1054-1075` +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`: + +```go +// A4: Create channel ResolvedUsers=[u1], ResolvedOrgs=[o1] (o1 has [u1, u2]), +// requester r. Expect indiv docs for r and u1, org doc for o1, no indiv for u2. +func TestHandler_ProcessCreateRoom_Channel_IndivFilter(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + requester := &model.User{ID: "r_id", Account: "r", SiteID: "site-a", EngName: "Req", ChineseName: "請"} + + store.EXPECT().ListNewMembersForNewRoom(gomock.Any(), []string{"o1"}, []string{"u1"}, "r"). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + room := &model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} + req := &model.CreateRoomRequest{ + RoomID: roomID, + ResolvedUsers: []string{"u1"}, + ResolvedOrgs: []string{"o1"}, + Timestamp: 1, + } + require.NoError(t, h.processCreateRoomChannel(context.Background(), req, room, requester, "req-1", time.UnixMilli(1).UTC(), time.UnixMilli(2).UTC())) + + var indivAccts []string + var orgIDs []string + for _, m := range captured { + switch m.Member.Type { + case model.RoomMemberIndividual: + indivAccts = append(indivAccts, m.Member.Account) + case model.RoomMemberOrg: + orgIDs = append(orgIDs, m.Member.ID) + } + } + assert.ElementsMatch(t, []string{"r", "u1"}, indivAccts, "indiv docs limited to ResolvedUsers ∪ {requester}") + assert.ElementsMatch(t, []string{"o1"}, orgIDs) +} +``` + +Note: this test calls `processCreateRoomChannel` directly. The existing test file may not yet invoke this function in isolation; if name/signature drift causes issues, mirror the wiring already used by `TestHandler_ProcessAddMembers_*`. The `finishCreateRoom` path will fire publishes — capture or ignore them via the no-op `publish` closure. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test SERVICE=room-worker -run TestHandler_ProcessCreateRoom_Channel_IndivFilter` +Expected: FAIL — current loop at handler.go:1056-1063 writes indiv docs for every sub (r, u1, u2), so `u2` appears in `indivAccts`. + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:1054-1075` (keep the comment block at 1076-1079 intact): + +```go + if len(req.ResolvedOrgs) > 0 { + allowedIndiv := make(map[string]struct{}, len(req.ResolvedUsers)+1) + allowedIndiv[requester.Account] = struct{}{} + for _, acc := range req.ResolvedUsers { + allowedIndiv[acc] = struct{}{} + } + members := make([]*model.RoomMember, 0, len(subs)+len(req.ResolvedOrgs)) + for _, sub := range subs { + if _, ok := allowedIndiv[sub.User.Account]; !ok { + continue + } + members = append(members, &model.RoomMember{ + ID: idgen.GenerateUUIDv7(), + RoomID: room.ID, + Ts: acceptedAt, + Member: model.RoomMemberEntry{ID: sub.User.ID, Type: model.RoomMemberIndividual, Account: sub.User.Account}, + }) + } + for _, org := range req.ResolvedOrgs { + members = append(members, &model.RoomMember{ + ID: idgen.GenerateUUIDv7(), + RoomID: room.ID, + Ts: acceptedAt, + Member: model.RoomMemberEntry{ID: org, Type: model.RoomMemberOrg}, + }) + } + if err := h.store.BulkCreateRoomMembers(ctx, members); err != nil { + return fmt.Errorf("bulk create room members: %w", err) + } + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test SERVICE=room-worker -run TestHandler_ProcessCreateRoom_Channel_IndivFilter` +Expected: PASS. + +Also run the full create-room test set: + +Run: `make test SERVICE=room-worker -run TestHandler_ProcessCreateRoom` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "fix(room-worker): filter create-room indiv docs to ResolvedUsers ∪ {requester}" +``` + +--- + +## Task 5: Requester fetch + empty-name validation in `processAddMembers` + +Implements spec §2.3. Fetch requester via `store.GetUser`; permanent error on miss or empty `EngName`/`ChineseName`. Also validate added users' name fields. + +**Files:** +- Modify: `room-worker/handler.go` (around line 590, after existing user-validation loop) +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `room-worker/handler_test.go`: + +```go +// D1: requester not found → permanent error. +func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "missing-requester").Return(nil, ErrUserNotFound) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "missing-id", RequesterAccount: "missing-requester", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + + err := h.processAddMembers(context.Background(), data) + require.Error(t, err) + var perm *permanentError + assert.ErrorAs(t, err, &perm, "miss should be a permanent error") +} + +// D2: requester has empty EngName → permanent error. +func TestHandler_ProcessAddMembers_RequesterEmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "", ChineseName: "愛"}, nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + + err := h.processAddMembers(context.Background(), data) + require.Error(t, err) + var perm *permanentError + assert.ErrorAs(t, err, &perm) +} + +// D3: added user has empty ChineseName → permanent error. +func TestHandler_ProcessAddMembers_AddedUserEmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: ""}}, nil) + // Validation for added users should fire before requester fetch; do not mock GetUser here. + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + + err := h.processAddMembers(context.Background(), data) + require.Error(t, err) + var perm *permanentError + assert.ErrorAs(t, err, &perm) +} +``` + +Note: the exact `permanentError` type name comes from the existing `newPermanent` constructor in this service. If the type isn't exported, switch to checking the error string (e.g., `assert.Contains(t, err.Error(), "requester")`); the rest of the codebase already does this in negative tests — copy the closest existing pattern. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run RequesterNotFound` +Expected: FAIL — current code doesn't fetch the requester at all, so `GetUser` expectation is unmet (or the handler succeeds, then the assertion on `err` fails). + +Same for the other two. + +- [ ] **Step 3: Write minimal implementation** + +After the existing missing-account loop at `room-worker/handler.go:585-589`, insert: + +```go + for i := range users { + if users[i].EngName == "" || users[i].ChineseName == "" { + return newPermanent("user %s missing required name fields (room %s)", users[i].Account, req.RoomID) + } + } + + requester, err := h.store.GetUser(ctx, req.RequesterAccount) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("requester %s not found", req.RequesterAccount) + } + return fmt.Errorf("get requester: %w", err) + } + if requester.EngName == "" || requester.ChineseName == "" { + return newPermanent("requester %s missing required name fields", req.RequesterAccount) + } +``` + +This produces a `requester *model.User` in scope for Task 6 to use. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run "RequesterNotFound|RequesterEmptyName|AddedUserEmptyName"` +Expected: PASS. + +Run: `make test SERVICE=room-worker` +Expected: All PASS. Some pre-existing tests (e.g., `TestHandler_ProcessAddMembers`) may now need to provide a `GetUser` mock expectation for the requester — update them minimally with a valid `*model.User` return. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): fetch requester and validate name fields in processAddMembers" +``` + +--- + +## Task 6: `processAddMembers` `members_added` Content + +Implements spec §2.3 Content rules. Count-sensitive: 1 → single form, ≥2 → multi form. + +**Files:** +- Modify: `room-worker/handler.go:776-784` +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `room-worker/handler_test.go`: + +```go +// B1: len(subs)==1 → single form. +func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + // No BulkCreateRoomMembers expected (no orgs, no pre-existing orgs → lite-mode add). + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) + + sysMsg := findSysMsg(t, published, "site-a", "members_added") + assert.Equal(t, "Alice 愛 added U1 一 to the channel", sysMsg.Content) +} + +// B2: len(subs)>=2 → multi form. +func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1", "u2"}, roomID). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1", "u2"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + require.NoError(t, h.processAddMembers(context.Background(), data)) + + sysMsg := findSysMsg(t, published, "site-a", "members_added") + assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) +} + +// findSysMsg locates a published canonical-message envelope on the given site +// with the given Message.Type and returns the inner Message. Add once near the +// top of handler_test.go alongside findMemberAddEvent. +func findSysMsg(t *testing.T, published []publishedMsg, siteID, msgType string) model.Message { + t.Helper() + want := subject.MsgCanonicalCreated(siteID) + for _, p := range published { + if p.subj != want { + continue + } + var evt model.MessageEvent + if err := json.Unmarshal(p.data, &evt); err != nil { + t.Fatalf("unmarshal MessageEvent: %v", err) + } + if evt.Message.Type == msgType { + return evt.Message + } + } + t.Fatalf("no %s sys-message published on %s", msgType, siteID) + return model.Message{} +} +``` + +If `findSysMsg` already exists with a different signature, reuse the existing helper instead. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run "Content_Single|Content_Multi"` +Expected: FAIL — `sysMsg.Content` is currently `""` for both. + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:776-784`. Look up the single added user via `userMap` (already built at handler.go:576-579) rather than `users[0]` — `users` ordering tracks `accounts` from `ListNewMembers`, which can differ from `subs` if the resolved set is filtered upstream: + +```go + content := formatAddedMulti(requester) + if len(subs) == 1 { + onlyUser := userMap[subs[0].User.Account] + content = formatAddedSingle(requester, &onlyUser) + } + sysMsg := model.Message{ + ID: idgen.MessageIDFromRequestID(seed, "addmembers"), + RoomID: req.RoomID, + UserID: req.RequesterID, + UserAccount: req.RequesterAccount, + Type: "members_added", + Content: content, + SysMsgData: sysMsgData, + CreatedAt: acceptedAt, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run "Content_Single|Content_Multi"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): populate Content on processAddMembers members_added sys-msg" +``` + +--- + +## Task 7: `publishChannelSysMessages` `members_added` Content + +Implements spec §2.3 multi-form Content for create-room sys-message. + +**Files:** +- Modify: `room-worker/handler.go:1248-1256` +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`: + +```go +// B3: create-room channel publishes members_added with always-multi form. +func TestHandler_PublishChannelSysMessages_MembersAddedContent(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + room := &model.Room{ID: "r1", Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} + requester := &model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"} + req := &model.CreateRoomRequest{RoomID: "r1", Users: []string{"u1", "u2"}} + + require.NoError(t, h.publishChannelSysMessages(context.Background(), req, room, requester, 2, "req-1", time.UnixMilli(1).UTC())) + + sysMsg := findSysMsg(t, published, "site-a", model.MessageTypeMembersAdded) + assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test SERVICE=room-worker -run TestHandler_PublishChannelSysMessages_MembersAddedContent` +Expected: FAIL — `Content` is currently empty. + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:1248-1256`: + +```go + msg2 := model.Message{ + ID: idgen.MessageIDFromRequestID(requestID, "members_added"), + RoomID: room.ID, + UserID: requester.ID, + UserAccount: requester.Account, + Type: model.MessageTypeMembersAdded, + Content: formatAddedMulti(requester), + SysMsgData: sysData2, + CreatedAt: acceptedAt.Add(time.Millisecond), + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test SERVICE=room-worker -run TestHandler_PublishChannelSysMessages_MembersAddedContent` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): populate Content on create-room members_added sys-msg" +``` + +--- + +## Task 8: `processRemoveIndividual` sender + Content + name validation + +Implements spec §2.4 for individual removes and §2.5 (demote-only skip is already in code at line 270-276 — keep). Sets `UserAccount = req.Requester`, populates `Content`, validates fetched user's name fields. + +**Files:** +- Modify: `room-worker/handler.go:259-262` (add name validation) and `:353-358` (envelope + Content) +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `room-worker/handler_test.go`: + +```go +// C1: self-leave full removal → member_left with sender + Content. +func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetUserWithMembership(gomock.Any(), roomID, "bob"). + Return(&model.UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + HasOrgMembership: false, + Roles: []model.Role{model.RoleMember}, + }, nil) + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil) + store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "bob", Account: "bob", Timestamp: 1} + require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_left") + assert.Equal(t, "bob", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID, "UserID must stay empty per spec §2.4") + assert.Equal(t, "Bob 鮑 left the channel", sysMsg.Content) +} + +// C2: removed-by-other full removal → member_removed with sender + Content. +func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetUserWithMembership(gomock.Any(), roomID, "bob"). + Return(&model.UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil) + store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", Account: "bob", Timestamp: 1} + require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_removed") + assert.Equal(t, "alice", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID) + assert.Equal(t, "Bob 鮑 has been removed from the channel", sysMsg.Content) +} + +// D4: target user has empty ChineseName → permanent error. +func TestHandler_ProcessRemoveIndividual_EmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + store.EXPECT().GetUserWithMembership(gomock.Any(), "r1", "bob"). + Return(&model.UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob"}, + }, nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", Timestamp: 1} + + err := h.processRemoveIndividual(context.Background(), &req) + require.Error(t, err) + var perm *permanentError + assert.ErrorAs(t, err, &perm) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run "RemoveIndividual_SelfLeave_Content|RemoveIndividual_RemovedByOther_Content|RemoveIndividual_EmptyName"` +Expected: FAIL — `UserAccount` and `Content` are empty in current sysMsg literal; empty-name path returns nil today. + +- [ ] **Step 3: Write minimal implementation** + +After `room-worker/handler.go:259-262` (the `GetUserWithMembership` block), insert name validation: + +```go + if user.EngName == "" || user.ChineseName == "" { + return newPermanent("user %s missing required name fields (room %s)", req.Account, req.RoomID) + } +``` + +Replace `room-worker/handler.go:353-358`: + +```go + var content string + if isSelfLeave { + content = formatLeftContent(&user.User) + } else { + content = formatRemovedUserContent(&user.User) + } + sysMsg := model.Message{ + ID: idgen.MessageIDFromRequestID(seed, "rmindiv"), + RoomID: req.RoomID, + UserAccount: req.Requester, + Type: evtType, + Content: content, + SysMsgData: sysMsgData, + CreatedAt: now, + } +``` + +Note: `user` here is the local variable from line 259, type `*model.UserWithMembership`. That type embeds `model.User` (see `room-worker/store.go:15-32`), so `&user.User` is the correct way to pass a `*model.User` to the formatter. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run "RemoveIndividual_SelfLeave_Content|RemoveIndividual_RemovedByOther_Content|RemoveIndividual_EmptyName"` +Expected: PASS. + +Also confirm the demote-only path still skips sys-message publishing: + +Run: `make test SERVICE=room-worker -run TestHandler_ProcessRemoveMember_SelfLeave_DualMembership` +Expected: PASS (no change to this path). + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): set sender + Content on member_removed / member_left sys-msg" +``` + +--- + +## Task 9: `processRemoveOrg` unfiltered SectName + sender + Content + empty-SectName error + +Implements spec §2.4 for org removes: iterate unfiltered `members` for SectName, permanent error if every member has empty SectName, set `UserAccount = req.Requester`, populate Content. + +**Files:** +- Modify: `room-worker/handler.go:433-437` and `:483-496` +- Test: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `room-worker/handler_test.go`: + +```go +// C3: org remove with every member also having individual subs (toRemove empty) +// — SectName still populated from unfiltered members; sys-message still published. +func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1"). + Return([]OrgMemberStatus{ + {Account: "u1", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true}, + {Account: "u2", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true}, + }, nil) + // toRemove is empty → no DeleteSubscriptionsByAccounts call expected. + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1} + require.NoError(t, h.processRemoveOrg(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_removed") + assert.Equal(t, "alice", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID) + assert.Equal(t, "Engineering has been removed from the channel", sysMsg.Content) +} + +// D5: every member SectName empty → permanent error. +func TestHandler_ProcessRemoveOrg_AllSectNamesEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), "r1", "o1"). + Return([]OrgMemberStatus{ + {Account: "u1", SiteID: "site-a", SectName: "", HasIndividualMembership: false}, + }, nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", OrgID: "o1", Timestamp: 1} + + err := h.processRemoveOrg(context.Background(), &req) + require.Error(t, err) + var perm *permanentError + assert.ErrorAs(t, err, &perm) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test SERVICE=room-worker -run "ProcessRemoveOrg_AllOverlap|ProcessRemoveOrg_AllSectNamesEmpty"` +Expected: FAIL — current code harvests SectName only from `toRemove` (which is empty here, yielding `""`) and doesn't set `UserAccount`/`Content`. + +- [ ] **Step 3: Write minimal implementation** + +Replace `room-worker/handler.go:433-437` (the `sectName` harvest) with iteration over the unfiltered `members` slice. The original loop must remain to publish per-account subscription updates for `toRemove`; only the SectName extraction moves: + +```go + sectName := "" + for _, m := range members { + if m.SectName != "" { + sectName = m.SectName + break + } + } + if sectName == "" { + return newPermanent("org %s missing SectName on all members (room %s)", req.OrgID, req.RoomID) + } + for _, m := range toRemove { + subEvt := model.SubscriptionUpdateEvent{ +``` + +(Keep the per-account `SubscriptionUpdateEvent` publish loop body that follows at lines 438-451 unchanged.) + +Replace `room-worker/handler.go:490-496`: + +```go + sysMsg := model.Message{ + ID: idgen.MessageIDFromRequestID(seed, "rmorg"), + RoomID: req.RoomID, + UserAccount: req.Requester, + Type: "member_removed", + Content: formatRemovedOrgContent(sectName), + SysMsgData: sysMsgPayload, + CreatedAt: now, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test SERVICE=room-worker -run "ProcessRemoveOrg_AllOverlap|ProcessRemoveOrg_AllSectNamesEmpty"` +Expected: PASS. + +Also run the full remove-org test set: + +Run: `make test SERVICE=room-worker -run ProcessRemoveMember_OwnerRemovesOrg` +Expected: PASS (this pre-existing test exercises the happy path; its assertions may need expansion to cover Content but should not fail outright). + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): harvest SectName from unfiltered org members; set sender + Content" +``` + +--- + +## Task 10: Final verification + +- [ ] **Step 1: Run lint** + +Run: `make lint SERVICE=room-worker` +Expected: PASS, no warnings. + +- [ ] **Step 2: Run full test suite with race detector** + +Run: `make test SERVICE=room-worker` +Expected: All PASS. + +- [ ] **Step 3: Run mockgen and confirm no unexpected diff** + +Run: `make generate SERVICE=room-worker && git diff --stat` +Expected: No diff for tasks 1–10 (the membership fixes don't change `SubscriptionStore`). Task 12 adds `UpdateDMParticipants` to the interface; if you're running Task 10 verification AFTER Task 12, expect a single new entry in `mock_store_test.go` and confirm it is the regenerated mock for the new method, not unrelated churn. + +- [ ] **Step 4: Confirm coverage threshold** + +Run: +```bash +go test -tags '' -coverprofile=/tmp/cover.out ./room-worker/... +go tool cover -func=/tmp/cover.out | tail -1 +``` +Expected: total coverage ≥ 80%. If below, add cases for any uncovered error branches introduced in Tasks 5, 8, 9. + +- [ ] **Step 5: Commit any coverage fill-in** + +```bash +git add room-worker/handler_test.go +git commit -m "test(room-worker): cover remaining branches for membership fixes" +``` + +(Skip the commit if no changes were needed.) + +--- + +## Task 11: Add `UIDs`/`Accounts` fields + `BuildDMParticipants` helper to `pkg/model` + +Implements spec §3.1 (field shape) and §3.2 (pairing invariant). Pure-data additions; no service code touched yet. + +**Files:** +- Create: `pkg/model/room_test.go` +- Modify: `pkg/model/room.go` +- Modify: `pkg/model/model_test.go` (optional — only if the file has a generic round-trip cycle for `Room`) + +- [ ] **Step 1: Write the failing tests** + +Create `pkg/model/room_test.go`: + +```go +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildDMParticipants_SortsByUID(t *testing.T) { + a := &User{ID: "zzz", Account: "alpha"} + b := &User{ID: "aaa", Account: "zebra"} + uids, accounts := BuildDMParticipants(a, b) + assert.Equal(t, []string{"aaa", "zzz"}, uids) + assert.Equal(t, []string{"zebra", "alpha"}, accounts, "accounts mirror uid permutation") +} + +// Spec §4 F5: non-aligned sort. Users {ID:"zzz", Account:"aaa"} and +// {ID:"aaa", Account:"zzz"}. UIDs sort ascending; Accounts permute to +// preserve the same-user pairing at each index — NOT independently sorted. +func TestBuildDMParticipants_PreservesPairingUnderNonAlignedSort(t *testing.T) { + user1 := &User{ID: "zzz", Account: "aaa"} + user2 := &User{ID: "aaa", Account: "zzz"} + uids, accounts := BuildDMParticipants(user1, user2) + assert.Equal(t, []string{"aaa", "zzz"}, uids) + assert.Equal(t, []string{"zzz", "aaa"}, accounts) +} + +func TestBuildDMParticipants_AlreadySortedInput(t *testing.T) { + a := &User{ID: "aaa", Account: "alpha"} + b := &User{ID: "bbb", Account: "beta"} + uids, accounts := BuildDMParticipants(a, b) + assert.Equal(t, []string{"aaa", "bbb"}, uids) + assert.Equal(t, []string{"alpha", "beta"}, accounts) +} + +func TestBuildDMParticipants_SwapInputOrderProducesSameResult(t *testing.T) { + a := &User{ID: "u1", Account: "alice"} + b := &User{ID: "u2", Account: "bob"} + uidsAB, accountsAB := BuildDMParticipants(a, b) + uidsBA, accountsBA := BuildDMParticipants(b, a) + assert.Equal(t, uidsAB, uidsBA, "callers passing args in either order must get the same result") + assert.Equal(t, accountsAB, accountsBA) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/model/` +Expected: FAIL with `undefined: BuildDMParticipants` (compile error). + +- [ ] **Step 3: Write minimal implementation** + +Edit `pkg/model/room.go`. Add the two fields inside `Room` (immediately after the existing `Restricted` field): + +```go +UIDs []string `json:"uids,omitempty" bson:"uids,omitempty"` +Accounts []string `json:"accounts,omitempty" bson:"accounts,omitempty"` +``` + +Append the helper to the bottom of the same file: + +```go +// BuildDMParticipants returns sorted-by-UID, paired-by-index participant +// lists for a dm or botDM room. UIDs[i] and Accounts[i] always describe +// the same user. Callers must pass exactly two distinct *User values; +// upstream (room-service capacity check + room-worker counterpart fetch) +// already enforces this invariant. +func BuildDMParticipants(a, b *User) (uids, accounts []string) { + if a.ID < b.ID { + return []string{a.ID, b.ID}, []string{a.Account, b.Account} + } + return []string{b.ID, a.ID}, []string{b.Account, a.Account} +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/model/` +Expected: PASS for all four `TestBuildDMParticipants_*` tests. + +- [ ] **Step 5: Optional — extend the round-trip helper coverage** + +Check `pkg/model/model_test.go` for a generic JSON/BSON round-trip helper that covers `Room`. If one exists, add two `Room` cases — one with `UIDs`/`Accounts` populated and one with both `nil` — to confirm `omitempty` drops them on the wire. If no Room-specific round-trip case exists, skip this step. + +Run: `go test ./pkg/model/` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add pkg/model/room.go pkg/model/room_test.go pkg/model/model_test.go +git commit -m "feat(model): add Room.UIDs/Accounts + BuildDMParticipants helper" +``` + +--- + +## Task 12: Add `UpdateDMParticipants` store method + +Implements spec §3.4. Scaffolding for Tasks 13/14 — no behavioral test on its own; the Mongo path is exercised by future integration tests, and Tasks 13/14 cover the call-site behavior via the mock. + +**Files:** +- Modify: `room-worker/store.go` (interface) +- Modify: `room-worker/store_mongo.go` (Mongo impl) +- Modify: `room-worker/mock_store_test.go` (regenerated) + +- [ ] **Step 1: Add method to the `SubscriptionStore` interface** + +In `room-worker/store.go`, locate the `SubscriptionStore` interface and append the method: + +```go +// UpdateDMParticipants $sets the room's uids/accounts pair on dm/botDM +// rooms after the counterpart user has been resolved. Idempotent under +// JetStream redelivery; safe to call multiple times with the same args. +UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error +``` + +- [ ] **Step 2: Implement on `MongoStore`** + +In `room-worker/store_mongo.go`, append: + +```go +func (s *MongoStore) UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error { + res, err := s.rooms.UpdateOne(ctx, + bson.M{"_id": roomID}, + bson.M{"$set": bson.M{"uids": uids, "accounts": accounts}}, + ) + if err != nil { + return fmt.Errorf("update dm participants (room %s): %w", roomID, err) + } + if res.MatchedCount == 0 { + return fmt.Errorf("update dm participants (room %s): room not found", roomID) + } + return nil +} +``` + +Verify imports already include `go.mongodb.org/mongo-driver/v2/bson` and `fmt` — they should be present from earlier methods in this file. + +`MatchedCount == 0` is an error: the handler creates the room before this call, so a zero-match means the doc disappeared (race delete, wrong roomID, replica lag). Surface as a wrapped error so the handler returns it and JetStream retries. + +- [ ] **Step 3: Regenerate mocks** + +Run: `make generate SERVICE=room-worker` +Expected: `room-worker/mock_store_test.go` gains a `UpdateDMParticipants` mock method and matching `UpdateDMParticipantsCall` helper. No other diffs. + +- [ ] **Step 4: Verify compilation** + +Run: `go build ./room-worker/...` +Expected: success. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/store.go room-worker/store_mongo.go room-worker/mock_store_test.go +git commit -m "feat(room-worker): add UpdateDMParticipants store method" +``` + +--- + +## Task 13: Wire `UpdateDMParticipants` into `processCreateRoomDM` + +Implements spec §3.3 (call site 1) and acceptance F1. + +**Files:** +- Modify: `room-worker/handler.go` (`processCreateRoomDM`) +- Modify: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`. Reuse the existing `newCreateRoomTestHandler`, `makeCreateRoomBody`, and `testRequestID` helpers. + +```go +// F1: async DM create persists room with UIDs/Accounts sorted by UID and +// paired by index. Pick requester/other IDs whose lex order differs from +// their accounts so the pairing invariant is observable. +func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + 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"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-fields").Return(nil) + + // UIDs sorted ascending: ["u_aaa","u_zzz"]; Accounts mirror that + // permutation: ["bob","alice"] (bob's id sorted first). + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-dm-fields", + []string{"u_aaa", "u_zzz"}, []string{"bob", "alice"}). + Return(nil) + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-dm-fields", + RequesterAccount: "alice", + Users: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -run TestProcessCreateRoom_DM_SetsParticipantFields ./room-worker/` +Expected: FAIL — the `UpdateDMParticipants` mock expectation is unmet because `processCreateRoomDM` doesn't call it yet. + +- [ ] **Step 3: Write minimal implementation** + +In `room-worker/handler.go`, modify `processCreateRoomDM` to call `UpdateDMParticipants` after `BulkCreateSubscriptions` and to mirror the persisted fields onto the in-memory `room`: + +```go +func (h *Handler) processCreateRoomDM(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error { + other, err := h.store.GetUser(ctx, req.Users[0]) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("counterpart not found") + } + return fmt.Errorf("get counterpart: %w", err) + } + + subs := buildDMSubs(requester, other, room, acceptedAt) + if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { + return fmt.Errorf("bulk create subs: %w", err) + } + + uids, accounts := model.BuildDMParticipants(requester, other) + if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { + return fmt.Errorf("update dm participants: %w", err) + } + room.UIDs = uids + room.Accounts = accounts + + return h.finishCreateRoom(ctx, req, room, requester, []*model.User{requester, other}, subs, requestID, now) +} +``` + +Order rationale: `UpdateDMParticipants` runs AFTER `BulkCreateSubscriptions` so that a worker crash between the two writes leaves the room in the legacy shape (no `uids`/`accounts`) — which existing consumers already tolerate — rather than a uids-set-but-no-subs shape that no consumer expects. JetStream redelivery converges either way. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -run TestProcessCreateRoom_DM_SetsParticipantFields ./room-worker/` +Expected: PASS. + +Run: `go test -race -run TestProcessCreateRoom_DM ./room-worker/` +Expected: All PASS. Pre-existing tests that exercise `processCreateRoom` with a DM payload (`TestProcessCreateRoom_DM_BuildsTwoSubs`, `TestProcessCreateRoom_DM_EmitsNoSysMessages`, `TestProcessCreateRoom_DM_PublishesLocalInbox`) need a new `mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)` line. Add it just above each existing `BulkCreateSubscriptions` expectation; the mock is order-agnostic by default. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): set DM participant fields on async DM create" +``` + +--- + +## Task 14: Wire `UpdateDMParticipants` into `processCreateRoomBotDM` + +Implements spec §3.3 (call site 2) and acceptance F2. Mirrors Task 13's structure; the bot user populates one slot of the pair. + +**Files:** +- Modify: `room-worker/handler.go` (`processCreateRoomBotDM`) +- Modify: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`: + +```go +// F2: async botDM create persists room with UIDs/Accounts paired by index. +func TestProcessCreateRoom_BotDM_SetsParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + requester := &model.User{ID: "u_zzz", Account: "alice", EngName: "Alice", ChineseName: "愛", SiteID: "site-A"} + bot := &model.User{ID: "u_aaa", Account: "supportbot.bot", EngName: "Support", ChineseName: "支援", SiteID: "site-A"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().GetUser(gomock.Any(), "supportbot.bot").Return(bot, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-botdm-fields").Return(nil) + + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-botdm-fields", + []string{"u_aaa", "u_zzz"}, []string{"supportbot.bot", "alice"}). + Return(nil) + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-botdm-fields", + RequesterAccount: "alice", + Users: []string{"supportbot.bot"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -run TestProcessCreateRoom_BotDM_SetsParticipantFields ./room-worker/` +Expected: FAIL — `UpdateDMParticipants` expectation unmet. + +- [ ] **Step 3: Write minimal implementation** + +In `room-worker/handler.go`, modify `processCreateRoomBotDM`: + +```go +func (h *Handler) processCreateRoomBotDM(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error { + bot, err := h.store.GetUser(ctx, req.Users[0]) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("bot user not found") + } + return fmt.Errorf("get bot user: %w", err) + } + + subs := buildBotDMSubs(requester, bot, room, acceptedAt) + if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { + return fmt.Errorf("bulk create subs: %w", err) + } + + uids, accounts := model.BuildDMParticipants(requester, bot) + if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { + return fmt.Errorf("update dm participants: %w", err) + } + room.UIDs = uids + room.Accounts = accounts + + return h.finishCreateRoom(ctx, req, room, requester, []*model.User{requester, bot}, subs, requestID, now) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -run TestProcessCreateRoom_BotDM_SetsParticipantFields ./room-worker/` +Expected: PASS. + +Run: `go test -race -run TestProcessCreateRoom_BotDM ./room-worker/` +Expected: All PASS. Update any pre-existing botDM test (e.g. `TestProcessCreateRoom_BotDM_HasIsSubscribed`) to expect `UpdateDMParticipants` the same way as Task 13. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): set DM participant fields on async botDM create" +``` + +--- + +## Task 15: Set `UIDs`/`Accounts` inline in `handleSyncCreateDM` + +Implements spec §3.3 (call site 3) and acceptance F3. The sync DM path already fetches both users before `CreateRoom`, so the fields are set on the `Room` literal directly — no `UpdateDMParticipants` call. + +**Files:** +- Modify: `room-worker/handler.go` (`handleSyncCreateDM`) +- Modify: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `room-worker/handler_test.go`: + +```go +// F3: sync DM create persists room with UIDs/Accounts set on the initial +// CreateRoom call. No UpdateDMParticipants on this path. +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"} + + mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), gomock.Any()). + Return([]model.User{requester, other}, nil) + + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().FindDMSubscription(gomock.Any(), "alice", "bob"). + Return(&model.Subscription{User: model.SubscriptionUser{ID: requester.ID, Account: requester.Account}}, nil) + mockStore.EXPECT().FindDMSubscription(gomock.Any(), "bob", "alice"). + Return(&model.Subscription{User: model.SubscriptionUser{ID: other.ID, Account: other.Account}}, nil) + // No UpdateDMParticipants expectation — sync path sets fields on the literal. + + reqBody, err := json.Marshal(model.SyncCreateDMRequest{ + RequesterAccount: "alice", + OtherAccount: "bob", + RoomType: model.RoomTypeDM, + }) + require.NoError(t, err) + + _, err = h.handleSyncCreateDM(ctx, reqBody) + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, []string{"u_aaa", "u_zzz"}, captured.UIDs) + assert.Equal(t, []string{"bob", "alice"}, captured.Accounts, "accounts paired with uid sort order") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -run TestHandleSyncCreateDM_SetsParticipantFieldsOnInitialCreate ./room-worker/` +Expected: FAIL — `captured.UIDs` is nil because the current `Room` literal doesn't set it. + +- [ ] **Step 3: Write minimal implementation** + +In `room-worker/handler.go`, in `handleSyncCreateDM`, just before the `&model.Room{...}` literal (after `acceptedAt`/`roomID` are computed and `userCount`/`appCount` are decided), compute the participants and add them to the literal: + +```go +acceptedAt := time.Now().UTC() +roomID := idgen.BuildDMRoomID(requester.ID, other.ID) + +uids, accounts := model.BuildDMParticipants(requester, other) + +// DMs/botDMs have a fixed 2-member roster — set counts at creation; no Reconcile needed. +userCount, appCount := 2, 0 +if req.RoomType == model.RoomTypeBotDM { + userCount, appCount = 1, 1 +} + +room := &model.Room{ + ID: roomID, + Name: "", + Type: req.RoomType, + CreatedBy: requester.ID, + SiteID: h.siteID, + UserCount: userCount, + AppCount: appCount, + UIDs: uids, + Accounts: accounts, + CreatedAt: acceptedAt, + UpdatedAt: acceptedAt, +} +``` + +Nothing else in `handleSyncCreateDM` changes. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -run TestHandleSyncCreateDM_SetsParticipantFieldsOnInitialCreate ./room-worker/` +Expected: PASS. + +Run: `go test -race -run TestHandleSyncCreateDM ./room-worker/` +Expected: All PASS. Pre-existing `TestHandleSyncCreateDM_*` tests don't need updates — the new fields are additive and not asserted by name elsewhere. + +- [ ] **Step 5: Commit** + +```bash +git add room-worker/handler.go room-worker/handler_test.go +git commit -m "feat(room-worker): set DM participant fields on sync DM create" +``` + +--- + +## Task 16: Pin channel-create `omitempty` guarantee + +Implements spec acceptance F4. Guards against a future regression that accidentally sets `UIDs`/`Accounts` on non-DM rooms. + +**Files:** +- Modify: `room-worker/handler_test.go` + +- [ ] **Step 1: Write the test (passes immediately — this is a guard test)** + +Append to `room-worker/handler_test.go`: + +```go +// F4: channel create does not set UIDs/Accounts; the captured Room has +// both fields nil so `omitempty` drops them from the BSON document. Guard +// test — pins the contract so a future edit can't silently leak DM-only +// fields onto channels. +func TestProcessCreateRoom_Channel_DoesNotSetParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + requester := &model.User{ID: "u_a", Account: "alice", EngName: "Alice", ChineseName: "愛", SiteID: "site-A"} + bob := model.User{ID: "u_b", Account: "bob", EngName: "Bob", ChineseName: "鮑", SiteID: "site-A"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) + mockStore.EXPECT().ListNewMembersForNewRoom(gomock.Any(), []string(nil), []string{"bob"}, "alice"). + Return([]string{"bob"}, nil) + mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{bob}, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-chan-fields").Return(nil) + // No UpdateDMParticipants — channel path must never touch the fields. + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-chan-fields", + RequesterAccount: "alice", + Name: "team-room", + ResolvedUsers: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) + require.NotNil(t, captured) + assert.Nil(t, captured.UIDs, "channels must omit UIDs (omitempty drops nil)") + assert.Nil(t, captured.Accounts, "channels must omit Accounts") +} +``` + +- [ ] **Step 2: Run test to confirm it passes** + +Run: `go test -run TestProcessCreateRoom_Channel_DoesNotSetParticipantFields ./room-worker/` +Expected: PASS — `processCreateRoomChannel` was never modified to set these fields, so the captured Room has nil for both. + +The RED-GREEN cycle is intentionally collapsed here because the asserted behavior (do not set the fields) is the default. The test pins the contract so future edits can't violate it silently — a guard test, not a behavior-driver. If this test fails, a regression has crept in. + +- [ ] **Step 3: Commit** + +```bash +git add room-worker/handler_test.go +git commit -m "test(room-worker): pin channel-create omitempty guarantee for DM fields" +``` + +--- + +## Task 17: Final verification (DM participant fields) + +- [ ] **Step 1: Run lint** + +Run: `make lint SERVICE=room-worker` +Expected: PASS, no warnings. + +- [ ] **Step 2: Run full test suite with race detector** + +Run: `make test SERVICE=room-worker && go test -race ./pkg/model/` +Expected: All PASS. + +- [ ] **Step 3: Confirm mock regeneration is clean** + +Run: `make generate SERVICE=room-worker && git diff --stat` +Expected: No diff (Task 12 regenerated already; this confirms idempotency). + +- [ ] **Step 4: Confirm coverage on the new helper** + +```bash +go test -coverprofile=/tmp/cover.out ./pkg/model/ ./room-worker/ +go tool cover -func=/tmp/cover.out | grep -E "BuildDMParticipants|UpdateDMParticipants" +``` +Expected: `BuildDMParticipants` 100% from unit tests; `UpdateDMParticipants` (Mongo impl) 0% from unit tests (covered indirectly by integration tests). Handler-side wiring is exercised via the mock in Tasks 13–15, so per-handler coverage in handler.go is unchanged or improved. + +--- + +## Self-Review (post-write) + +**Spec coverage:** + +| Spec section | Covered by | +|---|---| +| §1 bug list | Tasks 1–9 (helpers + filters + Content + sender + validation) | +| §2.1 indiv write rule | Task 3 (`processAddMembers`) + Task 4 (`processCreateRoomChannel`) | +| §2.2 backfill gate / `hadOrgsBefore` restructure | Task 2 | +| §2.3 `members_added` Content + requester fetch + name validation | Tasks 5, 6, 7 | +| §2.4 remove sender + Content + name validation + SectName fix | Tasks 8, 9 | +| §2.5 emit-vs-skip on remove | Existing demote-only return at handler.go:276 retained by Task 8; org remove always emits via Task 9 | +| §2.6 helpers | Task 1 | +| §3 acceptance criteria A1–A6 | A1, A2 → Task 3; A3 → Task 3 (no orgs in req, hadOrgsBefore=true); A4 → Task 4; A5, A6 → Task 2 | +| §3 acceptance criteria B1–B3 | B1, B2 → Task 6; B3 → Task 7 | +| §3 acceptance criteria C1–C5 | C1, C2, C4 → Task 8; C3, C5 → Task 9 | +| §3 acceptance criteria D1–D5 | D1, D2, D3 → Task 5; D4 → Task 8; D5 → Task 9 | +| §3 acceptance criteria E | Task 10 (membership fixes) + Task 17 (DM fields) | +| §3 DM Participant Fields — field shape (§3.1) | Task 11 | +| §3 DM Participant Fields — pairing invariant + `BuildDMParticipants` (§3.2) | Task 11 | +| §3 DM Participant Fields — three call sites (§3.3) | Tasks 13, 14, 15 | +| §3 DM Participant Fields — store interface (§3.4) | Task 12 | +| §3 DM Participant Fields — test plan (§3.5) | Tasks 11, 13, 14, 15, 16 | +| §4 acceptance F1 (async DM call-site) | Task 13 | +| §4 acceptance F2 (async botDM call-site) | Task 14 | +| §4 acceptance F3 (sync DM call-site, no UpdateDMParticipants) | Task 15 | +| §4 acceptance F4 (channel/discussion fields absent) | Task 16 | +| §4 acceptance F5 (pairing under non-aligned sort) | Task 11's `TestBuildDMParticipants_PreservesPairingUnderNonAlignedSort` | +| §4 acceptance F6 (idempotency on replay) | Architectural property of `$set` in Task 12; replay would call `UpdateDMParticipants` again with the same args, no test needed | +| §4 acceptance F7 (forward-only, no backfill) | No migration task; legacy rooms are untouched by design — nothing to test | + +A3 (add `Users=[u1], Orgs=[]` to a room already having an org member) is not exercised by a dedicated test in this plan — Task 2's `BackfillSkippedWhenRoomAlreadyHasOrgs` covers the same control flow with a different account name. If you want strict per-criterion coverage, add a sibling test in Task 3 mirroring that scenario; otherwise, the path is exercised end-to-end. + +**Placeholder scan:** No "TBD" / "implement later" / "similar to Task N" instances. Every code-changing step contains the actual code. Task 11's Step 5 ("optional — extend round-trip helper") is a conditional, not a placeholder: it gives the executor a clear yes/no check. + +**Type consistency:** `formatAddedSingle`, `formatAddedMulti`, `formatRemovedUserContent`, `formatRemovedOrgContent`, `formatLeftContent`, `displayName` — same names used in Tasks 1, 6, 7, 8, 9. `OrgMemberStatus`, `permanentError`, `newPermanent`, `ErrUserNotFound`, `findSysMsg` — referenced consistently. `model.UserWithMembership.User` accessor noted in Task 8 with a fallback instruction if the actual type differs. New for DM-fields work: `model.BuildDMParticipants`, `SubscriptionStore.UpdateDMParticipants`, `Room.UIDs`, `Room.Accounts` — same names used across Tasks 11–16. + +**Known soft spots an executor will need to confirm at the point of edit:** +1. The exact `permanentError` type's exportedness (tests use `errors.As`; if unexported, switch to `assert.Contains(err.Error(), ...)`). +2. The result type of `GetUserWithMembership` (`*model.UserWithMembership` wrapping `model.User`, or `*model.User` with extra fields directly). Adjust the `&user.User` accessor in Task 8 accordingly. +3. `model.MessageTypeMembersAdded` vs the string literal `"members_added"` — Task 7 uses the constant (matches existing code at handler.go:1253), Task 6 uses the literal (matches existing code at handler.go:781); keep both as-is unless lint flags it. +4. Task 11's optional Step 5: the project's `pkg/model/model_test.go` may or may not have a generic round-trip case for `Room`. If a `Room` case exists, extend it with `UIDs`/`Accounts` populated and nil variants. If not, skip — the new `pkg/model/room_test.go` already pins the helper's behavior; explicit round-trip coverage is a nice-to-have, not a correctness gate. +5. Task 13 & 14's Step 4 instructs the executor to add `UpdateDMParticipants` mock expectations to pre-existing DM/botDM `processCreateRoom` tests. The current count of such tests is six (`TestProcessCreateRoom_DM_BuildsTwoSubs`, `TestProcessCreateRoom_DM_EmitsNoSysMessages`, `TestProcessCreateRoom_BotDM_HasIsSubscribed`, `TestProcessCreateRoom_DM_PublishesLocalInbox`, plus any added during the membership-fix work). The executor should grep for `processCreateRoom_DM\|processCreateRoom_BotDM` and update each test that goes through the full handler. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. + +**2. Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batch with checkpoints. + +Which approach? From e044ad3f5e3d7222c921e46739dbbe0308b0d600 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:02:20 +0000 Subject: [PATCH 03/15] docs(client-api): align sys-msg row and document Room.uids/accounts Align the system-message type row with the new msg/sender population and document the new Room.uids and Room.accounts DM-participant fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/client-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/client-api.md b/docs/client-api.md index e6f642030..03cb9fe8a 100644 --- a/docs/client-api.md +++ b/docs/client-api.md @@ -231,6 +231,8 @@ The created `Room` object. | `createdAt` | string | RFC 3339 timestamp. | | `updatedAt` | string | RFC 3339 timestamp. | | `restricted` | boolean | Optional. | +| `uids` | string[] | Optional. `dm`/`botDM` only. Sorted ascending; paired by index with `accounts` so `uids[i]` and `accounts[i]` describe the same user. Absent on channels and on legacy DMs created before this field was introduced. | +| `accounts` | string[] | Optional. `dm`/`botDM` only. Permuted to mirror `uids` order. Absent on channels and legacy DMs. | ```json { @@ -948,7 +950,7 @@ Used by every history-service method that returns messages. Mirrors the Cassandr | `visibleTo` | string | Optional. Visibility scope. | | `reactions` | object | Optional. Map of `emoji → Participant[]`. | | `deleted` | boolean | Optional. `true` for tombstoned messages. | -| `type` | string | Optional. System-message type when set (e.g. `"member_added"`); regular messages omit it. | +| `type` | string | Optional. System-message type when set; regular messages omit it. Known values: `"room_created"`, `"members_added"`, `"member_removed"`, `"member_left"`. For all four, `msg` is populated with a server-rendered human-readable body and `sender.account` is the responsible actor (the requester for adds/removes-by-other and room-creates, the leaving user for self-leave). | | `sysMsgData` | string | Optional. Base64-encoded raw JSON payload for system messages. | | `siteId` | string | Optional. The site that owns the message. | | `editedAt` | string | Optional. RFC 3339. Set after an edit. | From 6774d0ba18e4fba72025e923e1cbb6c1f3e8b81d Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:03:03 +0000 Subject: [PATCH 04/15] feat(model): add Room.UIDs/Accounts fields and BuildDMParticipants helper DM rooms now carry both the deterministic UID list and the matching account list as siblings, so clients can render DM members without an extra round-trip. BuildDMParticipants returns the canonically sorted pair for any two users. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/model/model_test.go | 37 +++++++++++++++++++++++++++++++++++ pkg/model/room.go | 14 ++++++++++++++ pkg/model/room_test.go | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 pkg/model/room_test.go diff --git a/pkg/model/model_test.go b/pkg/model/model_test.go index 4af52e537..7bbb307ec 100644 --- a/pkg/model/model_test.go +++ b/pkg/model/model_test.go @@ -75,6 +75,43 @@ func TestRoomJSON_NilTimestampsOmitted(t *testing.T) { assert.Nil(t, dst.MinUserLastSeenAt, "absent JSON field must unmarshal to nil pointer") } +func TestRoomJSON_WithDMParticipants(t *testing.T) { + r := model.Room{ + ID: "r1", Name: "dm", Type: model.RoomTypeDM, + CreatedBy: "u1", SiteID: "site-a", UserCount: 2, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UIDs: []string{"u1", "u2"}, + Accounts: []string{"alice", "bob"}, + } + roundTrip(t, &r, &model.Room{}) +} + +func TestRoomJSON_NilDMParticipantsOmitted(t *testing.T) { + r := model.Room{ + ID: "r1", Name: "general", Type: model.RoomTypeChannel, + CreatedBy: "u1", SiteID: "site-a", UserCount: 1, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + data, err := json.Marshal(&r) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + + _, hasUIDs := raw["uids"] + assert.False(t, hasUIDs, "nil UIDs must be omitted from JSON") + + _, hasAccounts := raw["accounts"] + assert.False(t, hasAccounts, "nil Accounts must be omitted from JSON") + + var dst model.Room + require.NoError(t, json.Unmarshal(data, &dst)) + assert.Nil(t, dst.UIDs, "absent JSON field must unmarshal to nil slice") + assert.Nil(t, dst.Accounts, "absent JSON field must unmarshal to nil slice") +} + func TestThreadRoomJSON(t *testing.T) { tr := model.ThreadRoom{ ID: "tr-1", diff --git a/pkg/model/room.go b/pkg/model/room.go index 9ae9d95d2..0b417c884 100644 --- a/pkg/model/room.go +++ b/pkg/model/room.go @@ -26,6 +26,8 @@ type Room struct { CreatedAt time.Time `json:"createdAt" bson:"createdAt"` UpdatedAt time.Time `json:"updatedAt" bson:"updatedAt"` Restricted bool `json:"restricted,omitempty" bson:"restricted,omitempty"` + UIDs []string `json:"uids,omitempty" bson:"uids,omitempty"` + Accounts []string `json:"accounts,omitempty" bson:"accounts,omitempty"` } type ListRoomsResponse struct { @@ -54,3 +56,15 @@ type RoomInfo struct { type RoomsInfoBatchResponse struct { Rooms []RoomInfo `json:"rooms"` } + +// BuildDMParticipants returns sorted-by-UID, paired-by-index participant +// lists for a dm or botDM room. UIDs[i] and Accounts[i] always describe +// the same user. Callers must pass exactly two distinct *User values; +// upstream (room-service capacity check + room-worker counterpart fetch) +// already enforces this invariant. +func BuildDMParticipants(a, b *User) (uids, accounts []string) { + if a.ID < b.ID { + return []string{a.ID, b.ID}, []string{a.Account, b.Account} + } + return []string{b.ID, a.ID}, []string{b.Account, a.Account} +} diff --git a/pkg/model/room_test.go b/pkg/model/room_test.go new file mode 100644 index 000000000..ea6f8a811 --- /dev/null +++ b/pkg/model/room_test.go @@ -0,0 +1,43 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildDMParticipants_SortsByUID(t *testing.T) { + a := &User{ID: "zzz", Account: "alpha"} + b := &User{ID: "aaa", Account: "zebra"} + uids, accounts := BuildDMParticipants(a, b) + assert.Equal(t, []string{"aaa", "zzz"}, uids) + assert.Equal(t, []string{"zebra", "alpha"}, accounts, "accounts mirror uid permutation") +} + +// Spec §4 F5: non-aligned sort. Users {ID:"zzz", Account:"aaa"} and +// {ID:"aaa", Account:"zzz"}. UIDs sort ascending; Accounts permute to +// preserve the same-user pairing at each index — NOT independently sorted. +func TestBuildDMParticipants_PreservesPairingUnderNonAlignedSort(t *testing.T) { + user1 := &User{ID: "zzz", Account: "aaa"} + user2 := &User{ID: "aaa", Account: "zzz"} + uids, accounts := BuildDMParticipants(user1, user2) + assert.Equal(t, []string{"aaa", "zzz"}, uids) + assert.Equal(t, []string{"zzz", "aaa"}, accounts) +} + +func TestBuildDMParticipants_AlreadySortedInput(t *testing.T) { + a := &User{ID: "aaa", Account: "alpha"} + b := &User{ID: "bbb", Account: "beta"} + uids, accounts := BuildDMParticipants(a, b) + assert.Equal(t, []string{"aaa", "bbb"}, uids) + assert.Equal(t, []string{"alpha", "beta"}, accounts) +} + +func TestBuildDMParticipants_SwapInputOrderProducesSameResult(t *testing.T) { + a := &User{ID: "u1", Account: "alice"} + b := &User{ID: "u2", Account: "bob"} + uidsAB, accountsAB := BuildDMParticipants(a, b) + uidsBA, accountsBA := BuildDMParticipants(b, a) + assert.Equal(t, uidsAB, uidsBA, "callers passing args in either order must get the same result") + assert.Equal(t, accountsAB, accountsBA) +} From 5f020155bcea7e5f859614650ff45805ceaf4e0a Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:03:35 +0000 Subject: [PATCH 05/15] feat(room-worker): add UpdateDMParticipants store method \$set's uids/accounts on a dm/botDM room after the counterpart account has been resolved. Idempotent under JetStream redelivery; returns an error when the target room is missing (the caller treats this as a permanent failure rather than silently skipping). Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/mock_store_test.go | 14 ++++++++++++++ room-worker/store.go | 5 +++++ room-worker/store_mongo.go | 14 ++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/room-worker/mock_store_test.go b/room-worker/mock_store_test.go index 63d1a490b..d073d956d 100644 --- a/room-worker/mock_store_test.go +++ b/room-worker/mock_store_test.go @@ -446,3 +446,17 @@ func (mr *MockRoomKeyStoreMockRecorder) Set(ctx, roomID, pair any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockRoomKeyStore)(nil).Set), ctx, roomID, pair) } + +// UpdateDMParticipants mocks base method. +func (m *MockSubscriptionStore) UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDMParticipants", ctx, roomID, uids, accounts) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDMParticipants indicates an expected call of UpdateDMParticipants. +func (mr *MockSubscriptionStoreMockRecorder) UpdateDMParticipants(ctx, roomID, uids, accounts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDMParticipants", reflect.TypeOf((*MockSubscriptionStore)(nil).UpdateDMParticipants), ctx, roomID, uids, accounts) +} diff --git a/room-worker/store.go b/room-worker/store.go index 1ea9c6591..88e7283a7 100644 --- a/room-worker/store.go +++ b/room-worker/store.go @@ -78,6 +78,11 @@ type SubscriptionStore interface { // matching-existing-room as success-on-redelivery. CreateRoom(ctx context.Context, room *model.Room) error + // UpdateDMParticipants $sets the room's uids/accounts pair on dm/botDM + // rooms after the counterpart user has been resolved. Idempotent under + // JetStream redelivery; safe to call multiple times with the same args. + UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error + // ListNewMembersForNewRoom is the empty-roomID variant of // ListNewMembers — same dedup + bot filter, no "already-subscribed" // pruning since the room doesn't exist yet. excludeAccount drops one diff --git a/room-worker/store_mongo.go b/room-worker/store_mongo.go index 228d8df9f..2e123bb79 100644 --- a/room-worker/store_mongo.go +++ b/room-worker/store_mongo.go @@ -135,6 +135,20 @@ func (s *MongoStore) CreateRoom(ctx context.Context, room *model.Room) error { return nil } +func (s *MongoStore) UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error { + res, err := s.rooms.UpdateOne(ctx, + bson.M{"_id": roomID}, + bson.M{"$set": bson.M{"uids": uids, "accounts": accounts}}, + ) + if err != nil { + return fmt.Errorf("update dm participants (room %s): %w", roomID, err) + } + if res.MatchedCount == 0 { + return fmt.Errorf("update dm participants (room %s): room not found", roomID) + } + return nil +} + func (s *MongoStore) ListNewMembersForNewRoom(ctx context.Context, orgIDs, accounts []string, excludeAccount string) ([]string, error) { pipe := pipelines.GetNewMembersPipeline(orgIDs, accounts, "", excludeAccount) pipe = append(pipe, bson.M{"$group": bson.M{ From edcf2d7d51a19da44749ac9f8c302179898eb11e Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:18:46 +0000 Subject: [PATCH 06/15] fix(room-worker): filter individual room docs to actual members on create/add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MongoDB org-membership backfill was over-broad: it created individual sub-room docs for every member of the source org rooms, not just the users being added. processAddMembers now filters indiv docs to req.Users only, createRoom filters to ResolvedUsers ∪ {requester}, and the backfill gate fires only on the first-org transition so subsequent orgs don't replay it. Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/handler.go | 28 +++-- room-worker/handler_test.go | 223 ++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 8 deletions(-) diff --git a/room-worker/handler.go b/room-worker/handler.go index 44da401a3..c5d56e960 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -735,20 +735,24 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error return fmt.Errorf("bulk create subscriptions: %w", err) } - writeIndividuals := len(req.Orgs) > 0 - if !writeIndividuals { - hasOrgs, err := h.store.HasOrgRoomMembers(ctx, req.RoomID) - if err != nil { - slog.Warn("check existing org room members failed", "error", err, "roomID", req.RoomID) - } - writeIndividuals = hasOrgs + hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID) + if err != nil { + slog.Warn("check existing org room members failed", "error", err, "roomID", req.RoomID) } + writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore // Collect all room_member docs to write in a single bulk insert: // new individuals + new orgs + (optional) backfill of existing subscribers. roomMembers := make([]*model.RoomMember, 0, len(subs)+len(req.Orgs)) + allowedIndiv := make(map[string]struct{}, len(req.Users)) + for _, acc := range req.Users { + allowedIndiv[acc] = struct{}{} + } if writeIndividuals { for _, sub := range subs { + if _, ok := allowedIndiv[sub.User.Account]; !ok { + continue + } roomMembers = append(roomMembers, &model.RoomMember{ ID: idgen.GenerateUUIDv7(), RoomID: req.RoomID, @@ -775,7 +779,7 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error // Backfill existing subscribers into room_members only when orgs are // joining for the first time and we're starting to track individuals. - if writeIndividuals && len(req.Orgs) > 0 { + if len(req.Orgs) > 0 && !hadOrgsBefore { existingAccounts, err := h.store.GetSubscriptionAccounts(ctx, req.RoomID) if err != nil { slog.Warn("get subscription accounts for backfill failed", "error", err) @@ -1173,8 +1177,16 @@ func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.Creat } if len(req.ResolvedOrgs) > 0 { + allowedIndiv := make(map[string]struct{}, len(req.ResolvedUsers)+1) + allowedIndiv[requester.Account] = struct{}{} + for _, acc := range req.ResolvedUsers { + allowedIndiv[acc] = struct{}{} + } members := make([]*model.RoomMember, 0, len(subs)+len(req.ResolvedOrgs)) for _, sub := range subs { + if _, ok := allowedIndiv[sub.User.Account]; !ok { + continue + } members = append(members, &model.RoomMember{ ID: idgen.GenerateUUIDv7(), RoomID: room.ID, diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index 8cf62ffee..d9db5c30c 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -923,6 +923,9 @@ func TestHandler_ProcessAddMembers_WithOrgs(t *testing.T) { }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) + // HasOrgRoomMembers is now called unconditionally (Task 2). Return false to + // preserve this test's first-org-transition semantics so backfill fires. + store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) // With orgs: BulkCreateRoomMembers called once with individual "bob" + org "eng" + backfill "alice" store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, members []*model.RoomMember) error { @@ -3396,3 +3399,223 @@ func TestFanOutRoomKeyToSurvivors_SendsToAllSurvivorsIncludingRemoteSite(t *test "chat.user.remote-carol.event.room.key", }, pub.subjects) } + +// Task 2: Backfill must fire only on the first-org transition. The +// restructured handler calls HasOrgRoomMembers unconditionally and gates the +// backfill on `len(req.Orgs) > 0 && !hadOrgsBefore`. +func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID). + Return([]string{"u_new"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). + Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) + // GetUser expectation added in Task 5 (requester fetch). + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) // first org + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).Return(nil) + + // First-org transition MUST call GetSubscriptionAccounts (backfill kickoff). + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{"existing_user"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"existing_user"}). + Return([]model.User{{ID: "u_e", Account: "existing_user", SiteID: "site-a", EngName: "Ex", ChineseName: "存"}}, nil) + + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-backfill-first-org") + require.NoError(t, h.processAddMembers(ctx, data)) +} + +func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o_new"}, []string(nil), roomID). + Return([]string{"u_new"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). + Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) + // GetUser expectation added in Task 5 (requester fetch). + // Restructured code calls HasOrgRoomMembers unconditionally. + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(true, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).Return(nil) + // NO GetSubscriptionAccounts expectation — backfill must be skipped. + + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o_new"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-backfill-skipped") + require.NoError(t, h.processAddMembers(ctx, data)) +} + +// Task 3 (spec §2.1): a user only gets an individual room_members doc iff +// their account is in req.Users. Org-only expansions must NOT emit indiv +// docs for accounts pulled in via org expansion. + +// A1: Users=[u1], Orgs=[o1] (o1 has [u1, u2]). Expect indiv only for u1, org for o1. +func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string{"u1"}, roomID). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + // GetUser expectation added in Task 5. + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{}, nil) // no pre-existing subs + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "test-req-task3-a1") + require.NoError(t, h.processAddMembers(ctx, data)) + + var indivAccts []string + var orgIDs []string + for _, m := range captured { + switch m.Member.Type { + case model.RoomMemberIndividual: + indivAccts = append(indivAccts, m.Member.Account) + case model.RoomMemberOrg: + orgIDs = append(orgIDs, m.Member.ID) + } + } + assert.ElementsMatch(t, []string{"u1"}, indivAccts, "indiv docs limited to req.Users") + assert.ElementsMatch(t, []string{"o1"}, orgIDs) +} + +// A2: Users=[], Orgs=[o1]. Expect org only, no indivs. +func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string{"o1"}, []string(nil), roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + // GetUser expectation added in Task 5. + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{}, nil) + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"o1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "test-req-task3-a2") + require.NoError(t, h.processAddMembers(ctx, data)) + + for _, m := range captured { + assert.NotEqual(t, model.RoomMemberIndividual, m.Member.Type, "no indiv docs should be written") + } +} + +// A4: Create channel ResolvedUsers=[u1], ResolvedOrgs=[o1] (o1 has [u1, u2]), +// requester r. Expect indiv docs for r and u1, org doc for o1, no indiv for u2. +func TestHandler_ProcessCreateRoom_Channel_IndivFilter(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + requester := &model.User{ID: "r_id", Account: "r", SiteID: "site-a", EngName: "Req", ChineseName: "請"} + + store.EXPECT().ListNewMembersForNewRoom(gomock.Any(), []string{"o1"}, []string{"u1"}, "r"). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + + var captured []*model.RoomMember + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, m []*model.RoomMember) error { + captured = m + return nil + }) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + + room := &model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} + req := &model.CreateRoomRequest{ + RoomID: roomID, + ResolvedUsers: []string{"u1"}, + ResolvedOrgs: []string{"o1"}, + Timestamp: 1, + } + require.NoError(t, h.processCreateRoomChannel(context.Background(), req, room, requester, "req-1", time.UnixMilli(1).UTC(), time.UnixMilli(2).UTC())) + + var indivAccts []string + var orgIDs []string + for _, m := range captured { + switch m.Member.Type { + case model.RoomMemberIndividual: + indivAccts = append(indivAccts, m.Member.Account) + case model.RoomMemberOrg: + orgIDs = append(orgIDs, m.Member.ID) + } + } + assert.ElementsMatch(t, []string{"r", "u1"}, indivAccts, "indiv docs limited to ResolvedUsers ∪ {requester}") + assert.ElementsMatch(t, []string{"o1"}, orgIDs) +} From 60c1c51e5ee8f7c2b329f0f55dd8809ddadc3ad9 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:48:51 +0000 Subject: [PATCH 07/15] feat(room-worker): populate sender and content on member system messages Previously member_added / member_removed / member_left system messages went out with bare member lists and no sender, leaving clients nothing to render the actor or the affected users with. The room-worker now fetches the requester (validating EngName / SectName / SectID along the way), harvests SectName from the unfiltered org members so sect details survive the indiv-doc filter, and renders Content via new sysmsg formatters. Single- and multi-form Content variants cover the individual path and the org-bearing path; a 1-member org expansion correctly stays on multi form so future org members are accounted for. Adds pkg/model.MessageTypeMemberRemoved and MessageTypeMemberLeft to name the two new sys-msg variants the room-worker now emits. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/model/event.go | 4 + room-worker/handler.go | 135 ++++++--- room-worker/handler_test.go | 584 +++++++++++++++++++++++++++++++++--- room-worker/sysmsg.go | 41 +++ room-worker/sysmsg_test.go | 101 +++++++ 5 files changed, 787 insertions(+), 78 deletions(-) create mode 100644 room-worker/sysmsg.go create mode 100644 room-worker/sysmsg_test.go diff --git a/pkg/model/event.go b/pkg/model/event.go index be678c631..125f9ad3c 100644 --- a/pkg/model/event.go +++ b/pkg/model/event.go @@ -229,6 +229,10 @@ const ( MessageTypeRoomCreated = "room_created" // MessageTypeMembersAdded is the system-message type emitted when members are added. MessageTypeMembersAdded = "members_added" + // MessageTypeMemberRemoved is the system-message type emitted when a member is removed. + MessageTypeMemberRemoved = "member_removed" + // MessageTypeMemberLeft is the system-message type emitted when a member self-leaves. + MessageTypeMemberLeft = "member_left" ) const ( diff --git a/room-worker/handler.go b/room-worker/handler.go index c5d56e960..0c46ef438 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -333,6 +333,9 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove if err != nil { return fmt.Errorf("get user with membership: %w", err) } + if err := validateUserNames(&user.User, "user", req.RoomID); err != nil { + return err + } // room_members.member.id stores the user's internal ID, not the account. if err := h.store.DeleteRoomMember(ctx, req.RoomID, model.RoomMemberIndividual, user.ID); err != nil { @@ -389,9 +392,9 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove } // Member change event - evtType := "member_left" + evtType := model.MessageTypeMemberLeft if !isSelfLeave { - evtType = "member_removed" + evtType = model.MessageTypeMemberRemoved } memberEvt := model.MemberRemoveEvent{ Type: evtType, @@ -408,7 +411,7 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove // Wrapper Type collapses to member_removed even for self-leave so // search-sync-worker dispatches on one MV op; inner Type is preserved. inboxOutbox := model.OutboxEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, SiteID: h.siteID, DestSiteID: h.siteID, Payload: memberEvtData, @@ -434,12 +437,20 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove } seed := messageDedupSeed(ctx, "processRemoveIndividual", req.RoomID, fmt.Sprintf("%s:%s:%d", req.RoomID, req.Account, req.Timestamp)) + var content string + if isSelfLeave { + content = formatLeft(&user.User) + } else { + content = formatRemovedUser(&user.User) + } sysMsg := model.Message{ - ID: idgen.MessageIDFromRequestID(seed, "rmindiv"), - RoomID: req.RoomID, - Type: evtType, - SysMsgData: sysMsgData, - CreatedAt: now, + ID: idgen.MessageIDFromRequestID(seed, "rmindiv"), + RoomID: req.RoomID, + UserAccount: req.Requester, + Type: evtType, + Content: content, + SysMsgData: sysMsgData, + CreatedAt: now, } msgEvt := model.MessageEvent{ Event: model.EventCreated, @@ -455,7 +466,7 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove // Cross-site outbox for federated users if user.SiteID != h.siteID { outbox := model.OutboxEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, SiteID: h.siteID, DestSiteID: user.SiteID, Payload: memberEvtData, @@ -464,7 +475,7 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove outboxData, _ := json.Marshal(outbox) payloadSeed := fmt.Sprintf("%s:%s:%d", req.RoomID, req.Account, req.Timestamp) dedupID := natsutil.OutboxDedupID(ctx, user.SiteID, payloadSeed) - if err := h.publish(ctx, subject.Outbox(h.siteID, user.SiteID, "member_removed"), outboxData, dedupID); err != nil { + if err := h.publish(ctx, subject.Outbox(h.siteID, user.SiteID, model.OutboxMemberRemoved), outboxData, dedupID); err != nil { return fmt.Errorf("outbox publish to %s: %w", user.SiteID, err) } } @@ -486,6 +497,23 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR return fmt.Errorf("get org members with individual status: %w", err) } + // SectName is harvested from the UNFILTERED members slice (not toRemove) so + // it remains correct when every org member also has an individual sub and + // toRemove ends up empty. Pick the first non-empty SectName; an all-empty + // result is a data inconsistency upstream and must short-circuit before any + // mutating store call so the org-doc deletion is not lost to a malformed + // sys-message. + sectName := "" + for _, m := range members { + if m.SectName != "" { + sectName = m.SectName + break + } + } + if sectName == "" { + return newPermanent("org %s missing SectName on all members (room %s)", req.OrgID, req.RoomID) + } + var toRemove []OrgMemberStatus for _, m := range members { if !m.HasIndividualMembership { @@ -526,11 +554,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR now := time.Now().UTC() // Publish per-account subscription update and collect cross-site accounts - sectName := "" for _, m := range toRemove { - if m.SectName != "" { - sectName = m.SectName - } subEvt := model.SubscriptionUpdateEvent{ Subscription: model.Subscription{ RoomID: req.RoomID, @@ -549,7 +573,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR // Member change event with all removed accounts if len(accounts) > 0 { memberEvt := model.MemberRemoveEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, RoomID: req.RoomID, Accounts: accounts, SiteID: h.siteID, @@ -562,7 +586,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR } inboxOutbox := model.OutboxEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, SiteID: h.siteID, DestSiteID: h.siteID, Payload: memberEvtData, @@ -584,11 +608,13 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR seed := messageDedupSeed(ctx, "processRemoveOrg", req.RoomID, fmt.Sprintf("%s:%s:%d", req.RoomID, req.OrgID, req.Timestamp)) sysMsg := model.Message{ - ID: idgen.MessageIDFromRequestID(seed, "rmorg"), - RoomID: req.RoomID, - Type: "member_removed", - SysMsgData: sysMsgPayload, - CreatedAt: now, + ID: idgen.MessageIDFromRequestID(seed, "rmorg"), + RoomID: req.RoomID, + UserAccount: req.Requester, + Type: model.MessageTypeMemberRemoved, + Content: formatRemovedOrg(sectName), + SysMsgData: sysMsgPayload, + CreatedAt: now, } msgEvt := model.MessageEvent{ Event: model.EventCreated, @@ -610,7 +636,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR } for destSiteID, accounts := range siteAccounts { evt := model.MemberRemoveEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, RoomID: req.RoomID, Accounts: accounts, SiteID: h.siteID, @@ -618,7 +644,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR Timestamp: now.UnixMilli(), } outbox := model.OutboxEvent{ - Type: "member_removed", + Type: model.OutboxMemberRemoved, SiteID: h.siteID, DestSiteID: destSiteID, Payload: mustMarshal(evt), @@ -627,7 +653,7 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR outboxData, _ := json.Marshal(outbox) payloadSeed := fmt.Sprintf("%s:%s:%d", req.RoomID, req.OrgID, req.Timestamp) dedupID := natsutil.OutboxDedupID(ctx, destSiteID, payloadSeed) - if err := h.publish(ctx, subject.Outbox(h.siteID, destSiteID, "member_removed"), outboxData, dedupID); err != nil { + if err := h.publish(ctx, subject.Outbox(h.siteID, destSiteID, model.OutboxMemberRemoved), outboxData, dedupID); err != nil { return fmt.Errorf("outbox publish to %s: %w", destSiteID, err) } } @@ -642,7 +668,7 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error } requestID := natsutil.RequestIDFromContext(ctx) if requestID == "" { - return fmt.Errorf("missing X-Request-ID: %w", errPermanent) + return newPermanent("missing X-Request-ID") } if req.Timestamp <= 0 { req.Timestamp = time.Now().UTC().UnixMilli() @@ -689,6 +715,26 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error } } + // Validate added users' name fields before fetching the requester so that + // a cheap in-memory check on the already-fetched data short-circuits the + // extra GetUser round trip when the input is bad. + for i := range users { + if err := validateUserNames(&users[i], "user", req.RoomID); err != nil { + return err + } + } + + requester, err := h.store.GetUser(ctx, req.RequesterAccount) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("requester %s not found (room %s)", req.RequesterAccount, req.RoomID) + } + return fmt.Errorf("get requester: %w", err) + } + if err := validateUserNames(requester, "requester", req.RoomID); err != nil { + return err + } + // acceptedAt is the stable request-acceptance time (set by room-service). // It's used for every domain-level timestamp that must survive event replay // (subscription.JoinedAt, historySharedSince, room_members.Ts, system @@ -744,13 +790,13 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error // Collect all room_member docs to write in a single bulk insert: // new individuals + new orgs + (optional) backfill of existing subscribers. roomMembers := make([]*model.RoomMember, 0, len(subs)+len(req.Orgs)) - allowedIndiv := make(map[string]struct{}, len(req.Users)) + allowedIndividual := make(map[string]struct{}, len(req.Users)) for _, acc := range req.Users { - allowedIndiv[acc] = struct{}{} + allowedIndividual[acc] = struct{}{} } if writeIndividuals { for _, sub := range subs { - if _, ok := allowedIndiv[sub.User.Account]; !ok { + if _, ok := allowedIndividual[sub.User.Account]; !ok { continue } roomMembers = append(roomMembers, &model.RoomMember{ @@ -886,12 +932,23 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error sysMsgData, _ := json.Marshal(membersAdded) seed := messageDedupSeed(ctx, "processAddMembers", req.RoomID, fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp)) + // Single-form Content only fires when the requester added one user + // directly. Org-expanded adds always use the multi form — even if the + // org happens to expand to one user — because the requester's intent + // was "add the org", not "add Bob individually", and future org members + // would otherwise appear silently with no matching sys-message. + content := formatAddedMulti(requester) + if len(subs) == 1 && len(req.Orgs) == 0 { + onlyUser := userMap[subs[0].User.Account] + content = formatAddedSingle(requester, &onlyUser) + } sysMsg := model.Message{ ID: idgen.MessageIDFromRequestID(seed, "addmembers"), RoomID: req.RoomID, - UserID: req.RequesterID, - UserAccount: req.RequesterAccount, - Type: "members_added", + UserID: requester.ID, + UserAccount: requester.Account, + Type: model.MessageTypeMembersAdded, + Content: content, SysMsgData: sysMsgData, CreatedAt: acceptedAt, } @@ -1031,6 +1088,9 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error } return fmt.Errorf("get requester: %w", err) } + if err := validateUserNames(requester, "requester", req.RoomID); err != nil { + return err + } roomType := determineRoomTypeFromPayload(&req) acceptedAt := time.UnixMilli(req.Timestamp).UTC() @@ -1148,8 +1208,8 @@ func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.Creat userSet := make(map[string]struct{}, len(users)) for i := range users { userSet[users[i].Account] = struct{}{} - if users[i].EngName == "" || users[i].ChineseName == "" { - return newPermanent("user %s missing required name fields", users[i].Account) + if err := validateUserNames(&users[i], "user", room.ID); err != nil { + return err } } for _, account := range accounts { @@ -1177,14 +1237,14 @@ func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.Creat } if len(req.ResolvedOrgs) > 0 { - allowedIndiv := make(map[string]struct{}, len(req.ResolvedUsers)+1) - allowedIndiv[requester.Account] = struct{}{} + allowedIndividual := make(map[string]struct{}, len(req.ResolvedUsers)+1) + allowedIndividual[requester.Account] = struct{}{} for _, acc := range req.ResolvedUsers { - allowedIndiv[acc] = struct{}{} + allowedIndividual[acc] = struct{}{} } members := make([]*model.RoomMember, 0, len(subs)+len(req.ResolvedOrgs)) for _, sub := range subs { - if _, ok := allowedIndiv[sub.User.Account]; !ok { + if _, ok := allowedIndividual[sub.User.Account]; !ok { continue } members = append(members, &model.RoomMember{ @@ -1364,6 +1424,7 @@ func (h *Handler) publishChannelSysMessages(ctx context.Context, req *model.Crea UserID: requester.ID, UserAccount: requester.Account, Type: model.MessageTypeMembersAdded, + Content: formatAddedMulti(requester), SysMsgData: sysData2, CreatedAt: acceptedAt.Add(time.Millisecond), } diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index d9db5c30c..c57589d89 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -457,9 +457,11 @@ func TestHandler_ProcessRemoveMember_SelfLeave_DualMembership(t *testing.T) { userResult := &UserWithMembership{ User: model.User{ - ID: "u1", - Account: account, - SiteID: siteID, + ID: "u1", + Account: account, + SiteID: siteID, + EngName: "Alice", + ChineseName: "愛", }, HasOrgMembership: true, Roles: []model.Role{model.RoleMember}, @@ -511,7 +513,7 @@ func TestHandler_ProcessRemoveMember_DualMembership_OwnerDemoted(t *testing.T) { ) userResult := &UserWithMembership{ - User: model.User{ID: "u1", Account: account, SiteID: siteID}, + User: model.User{ID: "u1", Account: account, SiteID: siteID, EngName: "Alice", ChineseName: "愛"}, HasOrgMembership: true, Roles: []model.Role{model.RoleOwner, model.RoleMember}, } @@ -649,8 +651,11 @@ func TestHandler_ProcessAddMembers(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1"). Return([]string{"bob", "charlie"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, - {ID: "u3", Account: "charlie", SiteID: "site-b"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + {ID: "u3", Account: "charlie", SiteID: "site-b", EngName: "Charlie", ChineseName: "查"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, subs []*model.Subscription) error { @@ -669,8 +674,9 @@ func TestHandler_ProcessAddMembers(t *testing.T) { req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob", "charlie"}, - History: model.HistoryConfig{Mode: model.HistoryModeNone}, - Timestamp: 1, + RequesterAccount: "alice", + History: model.HistoryConfig{Mode: model.HistoryModeNone}, + Timestamp: 1, } reqData, _ := json.Marshal(req) @@ -764,7 +770,10 @@ func TestHandler_ProcessAddMembers_HistoryAll(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1"). Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).DoAndReturn( func(_ context.Context, subs []*model.Subscription) error { @@ -777,8 +786,9 @@ func TestHandler_ProcessAddMembers_HistoryAll(t *testing.T) { req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob"}, - History: model.HistoryConfig{Mode: model.HistoryModeAll}, - Timestamp: 1, + RequesterAccount: "alice", + History: model.HistoryConfig{Mode: model.HistoryModeAll}, + Timestamp: 1, } reqData, _ := json.Marshal(req) @@ -824,8 +834,11 @@ func TestHandler_ProcessAddMembers_RestrictedPropagatesPointer(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1"). Return([]string{"bob", "charlie"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, - {ID: "u3", Account: "charlie", SiteID: "site-b"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + {ID: "u3", Account: "charlie", SiteID: "site-b", EngName: "Charlie", ChineseName: "查"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -834,8 +847,9 @@ func TestHandler_ProcessAddMembers_RestrictedPropagatesPointer(t *testing.T) { const reqTS int64 = 1744300000000 req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob", "charlie"}, - History: model.HistoryConfig{Mode: model.HistoryModeNone}, - Timestamp: reqTS, + RequesterAccount: "alice", + History: model.HistoryConfig{Mode: model.HistoryModeNone}, + Timestamp: reqTS, } reqData, _ := json.Marshal(req) ctxR := natsutil.WithRequestID(context.Background(), "req-restricted-propagates") @@ -884,7 +898,10 @@ func TestHandler_ProcessAddMembers_UnrestrictedOmitsFieldFromWire(t *testing.T) store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1"). Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -892,8 +909,9 @@ func TestHandler_ProcessAddMembers_UnrestrictedOmitsFieldFromWire(t *testing.T) req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob"}, - History: model.HistoryConfig{Mode: model.HistoryModeAll}, - Timestamp: 1, + RequesterAccount: "alice", + History: model.HistoryConfig{Mode: model.HistoryModeAll}, + Timestamp: 1, } reqData, _ := json.Marshal(req) ctxU := natsutil.WithRequestID(context.Background(), "req-unrestricted-wire") @@ -919,7 +937,10 @@ func TestHandler_ProcessAddMembers_WithOrgs(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), []string{"eng"}, []string{"bob"}, "r1"). Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -1007,9 +1028,12 @@ func TestHandler_ProcessAddMembers_MultipleSiteOutbox(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"alice", "bob", "charlie"}, "r1"). Return([]string{"alice", "bob", "charlie"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"alice", "bob", "charlie"}).Return([]model.User{ - {ID: "u1", Account: "alice", SiteID: "site-b"}, - {ID: "u2", Account: "bob", SiteID: "site-b"}, - {ID: "u3", Account: "charlie", SiteID: "site-c"}, + {ID: "u1", Account: "alice", SiteID: "site-b", EngName: "Alice", ChineseName: "愛"}, + {ID: "u2", Account: "bob", SiteID: "site-b", EngName: "Bob", ChineseName: "鮑"}, + {ID: "u3", Account: "charlie", SiteID: "site-c", EngName: "Charlie", ChineseName: "查"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-b", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -1106,6 +1130,15 @@ func TestHandler_ProcessRemoveMember_OwnerRemovesOrg(t *testing.T) { assert.True(t, subjSet[subject.SubscriptionUpdate("dave")], "expected subscription update for dave") assert.False(t, subjSet[subject.SubscriptionUpdate("eve")], "eve has individual membership, should not be removed") assert.True(t, subjSet[subject.MemberEvent(roomID)], "expected member event published") + + // Sys-message must carry sender (UserAccount = requester) and Content + // rendered from the org's SectName (spec §2.4). The previous version of + // this test only verified counts, leaving Content/UserAccount regressions + // undetected. + sysMsg := findSysMsg(t, published, siteID, model.MessageTypeMemberRemoved) + assert.Equal(t, requester, sysMsg.UserAccount, "sender envelope must be set to requester") + assert.Empty(t, sysMsg.UserID, "UserID must stay empty per spec §2.4") + assert.Equal(t, "Engineering has been removed from the channel", sysMsg.Content) } func TestHandler_ProcessRemoveMember_CrossSiteOutbox(t *testing.T) { @@ -1121,9 +1154,11 @@ func TestHandler_ProcessRemoveMember_CrossSiteOutbox(t *testing.T) { userResult := &UserWithMembership{ User: model.User{ - ID: "u1", - Account: account, - SiteID: userSite, // different from local site + ID: "u1", + Account: account, + SiteID: userSite, // different from local site + EngName: "Alice", + ChineseName: "愛", }, HasOrgMembership: false, } @@ -1197,7 +1232,7 @@ func TestHandler_ProcessRemoveIndividual_DeleteRoomMemberError(t *testing.T) { store.EXPECT(). GetUserWithMembership(gomock.Any(), "r1", "alice"). Return(&UserWithMembership{ - User: model.User{ID: "u1", Account: "alice"}, + User: model.User{ID: "u1", Account: "alice", EngName: "Alice", ChineseName: "愛"}, Roles: []model.Role{model.RoleMember}, }, nil) store.EXPECT(). @@ -1219,7 +1254,7 @@ func TestHandler_ProcessRemoveIndividual_DualDemoteError(t *testing.T) { store.EXPECT(). GetUserWithMembership(gomock.Any(), "r1", "alice"). Return(&UserWithMembership{ - User: model.User{ID: "u1", Account: "alice"}, + User: model.User{ID: "u1", Account: "alice", EngName: "Alice", ChineseName: "愛"}, HasOrgMembership: true, Roles: []model.Role{model.RoleOwner, model.RoleMember}, }, nil) @@ -1245,7 +1280,7 @@ func TestHandler_ProcessRemoveIndividual_DeleteSubscriptionError(t *testing.T) { store.EXPECT(). GetUserWithMembership(gomock.Any(), "r1", "alice"). Return(&UserWithMembership{ - User: model.User{ID: "u1", Account: "alice"}, + User: model.User{ID: "u1", Account: "alice", EngName: "Alice", ChineseName: "愛"}, Roles: []model.Role{model.RoleMember}, }, nil) store.EXPECT(). @@ -1270,7 +1305,7 @@ func TestHandler_ProcessRemoveIndividual_ReconcileMemberCountsError(t *testing.T store.EXPECT(). GetUserWithMembership(gomock.Any(), "r1", "alice"). Return(&UserWithMembership{ - User: model.User{ID: "u1", Account: "alice"}, + User: model.User{ID: "u1", Account: "alice", EngName: "Alice", ChineseName: "愛"}, Roles: []model.Role{model.RoleMember}, }, nil) store.EXPECT(). @@ -1303,7 +1338,10 @@ func TestHandler_ProcessAddMembers_ExistingOrgsWritesIndividuals(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob"}, "r1"). Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -1345,7 +1383,7 @@ func TestHandler_ProcessRemoveIndividual_OutboxFailurePropagates(t *testing.T) { store.EXPECT(). GetUserWithMembership(gomock.Any(), roomID, account). Return(&UserWithMembership{ - User: model.User{ID: "u1", Account: account, SiteID: userSite}, + User: model.User{ID: "u1", Account: account, SiteID: userSite, EngName: "Alice", ChineseName: "愛"}, HasOrgMembership: false, }, nil) store.EXPECT(). @@ -1433,7 +1471,10 @@ func TestHandler_processAddMembers_PublishesSuccessEventToRequesterSubject(t *te store.EXPECT().GetRoom(gomock.Any(), "r1").Return(&model.Room{ID: "r1", Type: model.RoomTypeChannel, SiteID: "site1"}, nil) store.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site1"}, + {ID: "u2", Account: "bob", SiteID: "site1", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site1", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -1594,6 +1635,9 @@ func setupAddMembersHappyPath(t *testing.T, mockStore *MockSubscriptionStore, ac users[i] = model.User{ID: "u_" + a, Account: a, SiteID: "site-A", EngName: "X", ChineseName: "X"} } mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), accounts).Return(users, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-A", EngName: "Alice", ChineseName: "愛麗絲", + }, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -1631,6 +1675,9 @@ func TestProcessAddMembers_PopulatesSubName(t *testing.T) { mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ {ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"}, }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-A", EngName: "Alice", ChineseName: "愛麗絲", + }, nil) var capturedSubs []*model.Subscription mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, subs []*model.Subscription) error { @@ -1668,6 +1715,9 @@ func TestProcessAddMembers_HistoryNone_NoTimestamp(t *testing.T) { mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ {ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"}, }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-A", EngName: "Alice", ChineseName: "愛麗絲", + }, nil) var capturedSubs []*model.Subscription mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, subs []*model.Subscription) error { @@ -1705,6 +1755,9 @@ func TestProcessAddMembers_NoHistoryConfig_LeavesNil(t *testing.T) { mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ {ID: "u_bob", Account: "bob", SiteID: "site-A", EngName: "X", ChineseName: "X"}, }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-A", EngName: "Alice", ChineseName: "愛麗絲", + }, nil) var capturedSubs []*model.Subscription mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, subs []*model.Subscription) error { @@ -1742,6 +1795,9 @@ func TestProcessAddMembers_OutboxCarriesRoomName(t *testing.T) { mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{ {ID: "u_bob", Account: "bob", SiteID: "site-B", EngName: "Bob", ChineseName: "鲍勃"}, }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-A", EngName: "Alice", ChineseName: "愛麗絲", + }, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -3414,7 +3470,8 @@ func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T Return([]string{"u_new"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) - // GetUser expectation added in Task 5 (requester fetch). + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) // first org store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) @@ -3449,7 +3506,8 @@ func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *test Return([]string{"u_new"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u_new"}). Return([]model.User{{ID: "u_new", Account: "u_new", SiteID: "site-a", EngName: "New", ChineseName: "新"}}, nil) - // GetUser expectation added in Task 5 (requester fetch). + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) // Restructured code calls HasOrgRoomMembers unconditionally. store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(true, nil) @@ -3475,7 +3533,7 @@ func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *test // docs for accounts pulled in via org expansion. // A1: Users=[u1], Orgs=[o1] (o1 has [u1, u2]). Expect indiv only for u1, org for o1. -func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) { +func TestHandler_ProcessAddMembers_IndividualFilter_DirectAndOrgOverlap(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockSubscriptionStore(ctrl) @@ -3489,7 +3547,8 @@ func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, }, nil) - // GetUser expectation added in Task 5. + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) @@ -3510,7 +3569,7 @@ func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) Users: []string{"u1"}, Orgs: []string{"o1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "test-req-task3-a1") + ctx := natsutil.WithRequestID(context.Background(), "req-indiv-filter-direct-and-org-overlap") require.NoError(t, h.processAddMembers(ctx, data)) var indivAccts []string @@ -3528,7 +3587,7 @@ func TestHandler_ProcessAddMembers_IndivFilter_DirectAndOrgOverlap(t *testing.T) } // A2: Users=[], Orgs=[o1]. Expect org only, no indivs. -func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { +func TestHandler_ProcessAddMembers_IndividualFilter_OrgOnly(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockSubscriptionStore(ctrl) @@ -3539,7 +3598,8 @@ func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { Return([]string{"u1"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) - // GetUser expectation added in Task 5. + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) @@ -3560,7 +3620,7 @@ func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { Orgs: []string{"o1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "test-req-task3-a2") + ctx := natsutil.WithRequestID(context.Background(), "req-indiv-filter-org-only") require.NoError(t, h.processAddMembers(ctx, data)) for _, m := range captured { @@ -3570,7 +3630,7 @@ func TestHandler_ProcessAddMembers_IndivFilter_OrgOnly(t *testing.T) { // A4: Create channel ResolvedUsers=[u1], ResolvedOrgs=[o1] (o1 has [u1, u2]), // requester r. Expect indiv docs for r and u1, org doc for o1, no indiv for u2. -func TestHandler_ProcessCreateRoom_Channel_IndivFilter(t *testing.T) { +func TestHandler_ProcessCreateRoom_Channel_IndividualFilter(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockSubscriptionStore(ctrl) @@ -3619,3 +3679,445 @@ func TestHandler_ProcessCreateRoom_Channel_IndivFilter(t *testing.T) { assert.ElementsMatch(t, []string{"r", "u1"}, indivAccts, "indiv docs limited to ResolvedUsers ∪ {requester}") assert.ElementsMatch(t, []string{"o1"}, orgIDs) } + +// D1: requester not found → permanent error. +func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "missing-requester").Return(nil, ErrUserNotFound) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "missing-id", RequesterAccount: "missing-requester", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-requester-not-found") + + err := h.processAddMembers(ctx, data) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + assert.Contains(t, err.Error(), "missing-requester") +} + +// D2: requester has empty EngName → permanent error. +func TestHandler_ProcessAddMembers_RequesterEmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "", ChineseName: "愛"}, nil) + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-requester-empty-name") + + err := h.processAddMembers(ctx, data) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) +} + +// D3: added user has empty ChineseName → permanent error. +func TestHandler_ProcessAddMembers_AddedUserEmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: ""}}, nil) + // Validation for added users should fire before requester fetch — do not mock GetUser here. + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-added-user-empty-name") + + err := h.processAddMembers(ctx, data) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) +} + +// findSysMsg locates the system message published on MsgCanonicalCreated for +// the given site with the requested Type. Fails the test if no such publish +// occurred. +func findSysMsg(t *testing.T, published []publishedMsg, siteID, msgType string) model.Message { + t.Helper() + want := subject.MsgCanonicalCreated(siteID) + for _, p := range published { + if p.subj != want { + continue + } + var evt model.MessageEvent + if err := json.Unmarshal(p.data, &evt); err != nil { + t.Fatalf("unmarshal MessageEvent: %v", err) + } + if evt.Message.Type == msgType { + return evt.Message + } + } + t.Fatalf("no %s sys-message published on %s", msgType, siteID) + return model.Message{} +} + +// B1: len(subs)==1 → single form. +func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + // No BulkCreateRoomMembers expected (no orgs, no pre-existing orgs → lite-mode add). + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-content-single") + require.NoError(t, h.processAddMembers(ctx, data)) + + sysMsg := findSysMsg(t, published, "site-a", "members_added") + assert.Equal(t, "Alice 愛 added U1 一 to the channel", sysMsg.Content) +} + +// B2: len(subs)>=2 → multi form. +func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1", "u2"}, roomID). + Return([]string{"u1", "u2"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1", "u2"}). + Return([]model.User{ + {ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}, + {ID: "u2_id", Account: "u2", SiteID: "site-a", EngName: "U2", ChineseName: "二"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1", "u2"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-content-multi") + require.NoError(t, h.processAddMembers(ctx, data)) + + sysMsg := findSysMsg(t, published, "site-a", "members_added") + assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) +} + +// B3: create-room channel publishes members_added with always-multi form. +func TestHandler_PublishChannelSysMessages_MembersAddedContent(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + room := &model.Room{ID: "r1", Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} + requester := &model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"} + req := &model.CreateRoomRequest{RoomID: "r1", Users: []string{"u1", "u2"}} + + require.NoError(t, h.publishChannelSysMessages(context.Background(), req, room, requester, 2, "req-1", time.UnixMilli(1).UTC())) + + sysMsg := findSysMsg(t, published, "site-a", model.MessageTypeMembersAdded) + assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) +} + +// C1: self-leave full removal → member_left with sender + Content. +func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetUserWithMembership(gomock.Any(), roomID, "bob"). + Return(&UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + HasOrgMembership: false, + Roles: []model.Role{model.RoleMember}, + }, nil) + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil) + store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "bob", Account: "bob", Timestamp: 1} + require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_left") + assert.Equal(t, "bob", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID, "UserID must stay empty per spec §2.4") + assert.Equal(t, "Bob 鮑 left the channel", sysMsg.Content) +} + +// C2: removed-by-other full removal → member_removed with sender + Content. +func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetUserWithMembership(gomock.Any(), roomID, "bob"). + Return(&UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + }, nil) + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil) + store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", Account: "bob", Timestamp: 1} + require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_removed") + assert.Equal(t, "alice", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID) + assert.Equal(t, "Bob 鮑 has been removed from the channel", sysMsg.Content) +} + +// D4: target user has empty ChineseName → permanent error. Deferred +// async-job result must surface a sanitized error to the requester. +func TestHandler_ProcessRemoveIndividual_EmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + store.EXPECT().GetUserWithMembership(gomock.Any(), "r1", "bob"). + Return(&UserWithMembership{ + User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob"}, + }, nil) + // No other mocks expected — empty-name validation must return BEFORE delete/publish. + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", Timestamp: 1} + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + err := h.processRemoveIndividual(ctx, &req) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + + responses := userResponseFor(published, "alice") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, "missing required name fields") + assert.NotContains(t, result.Error, ": permanent") +} + +// C3: org remove with every member also having individual subs (toRemove empty) +// — SectName still populated from unfiltered members; sys-message still published. +func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), roomID, "o1"). + Return([]OrgMemberStatus{ + {Account: "u1", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true}, + {Account: "u2", SiteID: "site-a", SectName: "Engineering", HasIndividualMembership: true}, + }, nil) + // toRemove is empty → no DeleteSubscriptionsByAccounts call expected. + store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1} + require.NoError(t, h.processRemoveOrg(context.Background(), &req)) + + sysMsg := findSysMsg(t, published, "site-a", "member_removed") + assert.Equal(t, "alice", sysMsg.UserAccount) + assert.Empty(t, sysMsg.UserID) + assert.Equal(t, "Engineering has been removed from the channel", sysMsg.Content) +} + +// D5: every member SectName empty → permanent error. The deferred +// publishAsyncJobResult must also surface a sanitized error to the requester +// so the client doesn't hang waiting for a reply. +func TestHandler_ProcessRemoveOrg_AllSectNamesEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + store.EXPECT().GetOrgMembersWithIndividualStatus(gomock.Any(), "r1", "o1"). + Return([]OrgMemberStatus{ + {Account: "u1", SiteID: "site-a", SectName: "", HasIndividualMembership: false}, + }, nil) + // No other mocks — permanent error must short-circuit before deletes/publishes. + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", OrgID: "o1", Timestamp: 1} + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + err := h.processRemoveOrg(ctx, &req) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + + responses := userResponseFor(published, "alice") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, "missing SectName") + assert.NotContains(t, result.Error, ": permanent") +} + +// Requester-name validation in processCreateRoom: §2.3 promises Content is +// well-formed on every channel sys-message, but publishChannelSysMessages +// calls formatAddedMulti(requester) which renders a malformed body when the +// requester has empty EngName/ChineseName. Validate immediately after fetch +// so the path bails before CreateRoom even runs. +func TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent(t *testing.T) { + cases := []struct { + name string + eng string + chinese string + wantText string + }{ + {name: "empty EngName", eng: "", chinese: "愛", wantText: "requester alice missing required name fields"}, + {name: "empty ChineseName", eng: "Alice", chinese: "", wantText: "requester alice missing required name fields"}, + {name: "both empty", eng: "", chinese: "", wantText: "requester alice missing required name fields"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, mockStore, getPublished := newCreateRoomTestHandler(t) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + mockStore.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-A", EngName: tc.eng, ChineseName: tc.chinese}, nil) + // CreateRoom MUST NOT be called — validation short-circuits first. + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "r-empty-name", + RequesterAccount: "alice", + Users: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + + err := h.processCreateRoom(ctx, body) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + assert.Contains(t, err.Error(), tc.wantText) + + // Async-job error event must be published via the defer. + responses := userResponseFor(getPublished(), "alice") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, tc.wantText) + }) + } +} + +// F4 (post-review): single form must NOT fire when the join came via an +// org expansion — even if the org happens to have exactly one member. The +// user's intent was "add the org", not "add Bob individually", so the +// rendered Content uses the multi form. +func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + // req.Users is empty; org "eng" expands to one user "u1". + store.EXPECT().ListNewMembers(gomock.Any(), []string{"eng"}, []string(nil), roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID).Return(false, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().BulkCreateRoomMembers(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().GetSubscriptionAccounts(gomock.Any(), roomID).Return([]string{}, nil) + store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} + + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Orgs: []string{"eng"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "req-add-members-org-single-member") + require.NoError(t, h.processAddMembers(ctx, data)) + + sysMsg := findSysMsg(t, published, "site-a", "members_added") + assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content, + "org-add must use multi form even when org expands to a single user") +} diff --git a/room-worker/sysmsg.go b/room-worker/sysmsg.go new file mode 100644 index 000000000..8891b3d85 --- /dev/null +++ b/room-worker/sysmsg.go @@ -0,0 +1,41 @@ +package main + +import ( + "strings" + + "github.com/hmchangw/chat/pkg/model" +) + +func displayName(u *model.User) string { + return strings.TrimSpace(u.EngName + " " + u.ChineseName) +} + +// validateUserNames returns a permanent error when u lacks the EngName/ +// ChineseName fields required to render system-message Content. role labels +// the caller's relationship to u ("user", "requester") in the error message. +func validateUserNames(u *model.User, role, roomID string) error { + if u.EngName == "" || u.ChineseName == "" { + return newPermanent("%s %s missing required name fields (room %s)", role, u.Account, roomID) + } + return nil +} + +func formatAddedSingle(requester, added *model.User) string { + return displayName(requester) + " added " + displayName(added) + " to the channel" +} + +func formatAddedMulti(requester *model.User) string { + return displayName(requester) + " added members to the channel" +} + +func formatRemovedUser(user *model.User) string { + return displayName(user) + " has been removed from the channel" +} + +func formatRemovedOrg(sectName string) string { + return sectName + " has been removed from the channel" +} + +func formatLeft(user *model.User) string { + return displayName(user) + " left the channel" +} diff --git a/room-worker/sysmsg_test.go b/room-worker/sysmsg_test.go new file mode 100644 index 000000000..ed42c95be --- /dev/null +++ b/room-worker/sysmsg_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hmchangw/chat/pkg/model" +) + +func TestFormatAddedSingle(t *testing.T) { + got := formatAddedSingle( + &model.User{EngName: "Alice", ChineseName: "愛麗絲"}, + &model.User{EngName: "Bob", ChineseName: "鮑勃"}, + ) + assert.Equal(t, "Alice 愛麗絲 added Bob 鮑勃 to the channel", got) +} + +func TestFormatAddedMulti(t *testing.T) { + got := formatAddedMulti(&model.User{EngName: "Alice", ChineseName: "愛麗絲"}) + assert.Equal(t, "Alice 愛麗絲 added members to the channel", got) +} + +func TestFormatRemovedUser(t *testing.T) { + got := formatRemovedUser(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) + assert.Equal(t, "Bob 鮑勃 has been removed from the channel", got) +} + +func TestFormatRemovedOrg(t *testing.T) { + got := formatRemovedOrg("Engineering") + assert.Equal(t, "Engineering has been removed from the channel", got) +} + +func TestFormatLeft(t *testing.T) { + got := formatLeft(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) + assert.Equal(t, "Bob 鮑勃 left the channel", got) +} + +func TestFormatLeft_TrimsEmptyNameSide(t *testing.T) { + // Spec §2.6: TrimSpace(EngName + " " + ChineseName) — when one side is empty, + // the result still has no leading/trailing whitespace. Callers must reject + // fully-empty inputs upstream; this test pins the trim behavior only. + assert.Equal(t, "Bob left the channel", formatLeft(&model.User{EngName: "Bob"})) + assert.Equal(t, "鮑勃 left the channel", formatLeft(&model.User{ChineseName: "鮑勃"})) +} + +func TestValidateUserNames(t *testing.T) { + cases := []struct { + name string + user model.User + role string + roomID string + wantErr bool + wantMsg string + }{ + { + name: "both names set", + user: model.User{Account: "alice", EngName: "Alice", ChineseName: "愛"}, + role: "user", roomID: "r1", + wantErr: false, + }, + { + name: "empty EngName", + user: model.User{Account: "bob", EngName: "", ChineseName: "鮑"}, + role: "user", roomID: "r1", + wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + }, + { + name: "empty ChineseName", + user: model.User{Account: "bob", EngName: "Bob", ChineseName: ""}, + role: "user", roomID: "r1", + wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + }, + { + name: "both empty", + user: model.User{Account: "bob", EngName: "", ChineseName: ""}, + role: "user", roomID: "r1", + wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + }, + { + name: "requester role label propagated", + user: model.User{Account: "alice", EngName: ""}, + role: "requester", roomID: "r2", + wantErr: true, wantMsg: "requester alice missing required name fields (room r2)", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateUserNames(&tc.user, tc.role, tc.roomID) + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.True(t, errors.Is(err, errPermanent), "validation failure must be permanent") + assert.Equal(t, tc.wantMsg, err.Error()) + }) + } +} From b4160f1c95ef0975952210a3d8c705a21c923b33 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 10:59:48 +0000 Subject: [PATCH 08/15] =?UTF-8?q?fix(room-worker):=20validation=20hardenin?= =?UTF-8?q?g=20=E2=80=94=20UUID=20request=20IDs=20and=20fail-closed=20org?= =?UTF-8?q?=20backfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defensive fixes in the same path: - processAddMembers and processCreateRoom now validate that the X-Request-ID header carries a well-formed UUID (v4 or v7) via idgen.IsValidUUID; non-UUIDs return a permanent error so log correlation stays usable. - HasOrgRoomMembers failures no longer fall through silently with the zero-value bool — defaulting hadOrgsBefore=false on error would spuriously trigger first-org backfill on a room that already has org docs, and the duplicate inserts would only be masked by the Mongo unique index. Surface the error so JetStream redelivery retries with fresh state. Comments on these branches trimmed to single-line. Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/handler.go | 15 +-- room-worker/handler_test.go | 181 ++++++++++++++++++++++++++++-------- 2 files changed, 152 insertions(+), 44 deletions(-) diff --git a/room-worker/handler.go b/room-worker/handler.go index 0c46ef438..136023b6a 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -670,6 +670,9 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error if requestID == "" { return newPermanent("missing X-Request-ID") } + if !idgen.IsValidUUID(requestID) { + return newPermanent("invalid X-Request-ID: must be a hyphenated UUID") + } if req.Timestamp <= 0 { req.Timestamp = time.Now().UTC().UnixMilli() } @@ -781,9 +784,10 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error return fmt.Errorf("bulk create subscriptions: %w", err) } + // Fail closed: defaulting hadOrgsBefore=false on error would trigger spurious first-org backfill. hadOrgsBefore, err := h.store.HasOrgRoomMembers(ctx, req.RoomID) if err != nil { - slog.Warn("check existing org room members failed", "error", err, "roomID", req.RoomID) + return fmt.Errorf("check existing org room members: %w", err) } writeIndividuals := len(req.Orgs) > 0 || hadOrgsBefore @@ -932,11 +936,7 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error sysMsgData, _ := json.Marshal(membersAdded) seed := messageDedupSeed(ctx, "processAddMembers", req.RoomID, fmt.Sprintf("%s:%s:%d", req.RoomID, req.RequesterAccount, req.Timestamp)) - // Single-form Content only fires when the requester added one user - // directly. Org-expanded adds always use the multi form — even if the - // org happens to expand to one user — because the requester's intent - // was "add the org", not "add Bob individually", and future org members - // would otherwise appear silently with no matching sys-message. + // Single form only for direct 1-user adds; org-bearing adds always use multi. content := formatAddedMulti(requester) if len(subs) == 1 && len(req.Orgs) == 0 { onlyUser := userMap[subs[0].User.Account] @@ -1062,6 +1062,9 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error if requestID == "" { return newPermanent("missing X-Request-ID") } + if !idgen.IsValidUUID(requestID) { + return newPermanent("invalid X-Request-ID: must be a hyphenated UUID") + } var req model.CreateRoomRequest if err := json.Unmarshal(data, &req); err != nil { diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index c57589d89..f3f0b94c7 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -326,13 +326,13 @@ func TestHandler_ProcessRoleUpdate_PropagatesRequestID(t *testing.T) { } h := NewHandler(store, "site1", publish, testKeyStore, testKeySender) - ctx := natsutil.WithRequestID(context.Background(), "req-rw-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleOwner, Timestamp: 1} reqData, _ := json.Marshal(req) err := h.processRoleUpdate(ctx, reqData) require.NoError(t, err) require.NotNil(t, capturedCtx, "publish wrapper must receive a non-nil ctx") - assert.Equal(t, "req-rw-test", natsutil.RequestIDFromContext(capturedCtx), + assert.Equal(t, testRequestID, natsutil.RequestIDFromContext(capturedCtx), "publish wrapper must receive ctx that still carries the request ID") } @@ -630,7 +630,7 @@ func TestHandler_ProcessAddMembers_FallsBackToNowOnInvalidTimestamp(t *testing.T Timestamp: 0, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-fallback-ts-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, data) require.Error(t, err) assert.NotContains(t, err.Error(), "timestamp must be > 0") @@ -680,7 +680,7 @@ func TestHandler_ProcessAddMembers(t *testing.T) { } reqData, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-basic") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, reqData) require.NoError(t, err) @@ -792,7 +792,7 @@ func TestHandler_ProcessAddMembers_HistoryAll(t *testing.T) { } reqData, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-history-all-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, reqData) require.NoError(t, err) } @@ -852,7 +852,7 @@ func TestHandler_ProcessAddMembers_RestrictedPropagatesPointer(t *testing.T) { Timestamp: reqTS, } reqData, _ := json.Marshal(req) - ctxR := natsutil.WithRequestID(context.Background(), "req-restricted-propagates") + ctxR := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctxR, reqData)) // Local RoomMemberEvent: HSS must be a non-nil pointer equal to request ts. @@ -914,7 +914,7 @@ func TestHandler_ProcessAddMembers_UnrestrictedOmitsFieldFromWire(t *testing.T) Timestamp: 1, } reqData, _ := json.Marshal(req) - ctxU := natsutil.WithRequestID(context.Background(), "req-unrestricted-wire") + ctxU := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctxU, reqData)) evt, raw := findMemberAddEvent(t, published, "r1") @@ -970,7 +970,7 @@ func TestHandler_ProcessAddMembers_WithOrgs(t *testing.T) { } reqData, _ := json.Marshal(req) - ctxOrgs := natsutil.WithRequestID(context.Background(), "req-with-orgs-test") + ctxOrgs := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctxOrgs, reqData) require.NoError(t, err) } @@ -1005,7 +1005,7 @@ func TestHandler_ProcessAddMembers_UserNotFound(t *testing.T) { } reqData, _ := json.Marshal(req) - ctxUNF := natsutil.WithRequestID(context.Background(), "req-user-not-found-test") + ctxUNF := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctxUNF, reqData) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) @@ -1048,7 +1048,7 @@ func TestHandler_ProcessAddMembers_MultipleSiteOutbox(t *testing.T) { } reqData, _ := json.Marshal(req) - ctxMS := natsutil.WithRequestID(context.Background(), "req-multi-site-outbox-test") + ctxMS := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctxMS, reqData) require.NoError(t, err) @@ -1363,7 +1363,7 @@ func TestHandler_ProcessAddMembers_ExistingOrgsWritesIndividuals(t *testing.T) { } reqData, _ := json.Marshal(req) - ctxEO := natsutil.WithRequestID(context.Background(), "req-existing-orgs-test") + ctxEO := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctxEO, reqData) require.NoError(t, err) } @@ -1480,7 +1480,7 @@ func TestHandler_processAddMembers_PublishesSuccessEventToRequesterSubject(t *te store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) - ctx := natsutil.WithRequestID(context.Background(), "req-async-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob"}, @@ -1491,10 +1491,10 @@ func TestHandler_processAddMembers_PublishesSuccessEventToRequesterSubject(t *te err := h.processAddMembers(ctx, reqData) require.NoError(t, err) - assert.Equal(t, subject.UserResponse("alice", "req-async-test"), capturedSubject) + assert.Equal(t, subject.UserResponse("alice", testRequestID), capturedSubject) var result model.AsyncJobResult require.NoError(t, json.Unmarshal(capturedData, &result)) - assert.Equal(t, "req-async-test", result.RequestID) + assert.Equal(t, testRequestID, result.RequestID) assert.Equal(t, model.AsyncJobOpRoomMemberAdd, result.Operation) assert.Equal(t, "ok", result.Status) assert.Equal(t, "", result.Error) @@ -1521,7 +1521,7 @@ func TestHandler_processAddMembers_PublishesFailureEventOnError(t *testing.T) { store.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), []string{"bob"}, "r1").Return([]string{"bob"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return(nil, fmt.Errorf("database connection failed")) - ctx := natsutil.WithRequestID(context.Background(), "req-error-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) req := model.AddMembersRequest{ RoomID: "r1", Users: []string{"bob"}, @@ -1534,10 +1534,10 @@ func TestHandler_processAddMembers_PublishesFailureEventOnError(t *testing.T) { assert.Contains(t, err.Error(), "find users by accounts") // Verify failure event was published to requester - assert.Equal(t, subject.UserResponse("alice", "req-error-test"), capturedSubject) + assert.Equal(t, subject.UserResponse("alice", testRequestID), capturedSubject) var result model.AsyncJobResult require.NoError(t, json.Unmarshal(capturedData, &result)) - assert.Equal(t, "req-error-test", result.RequestID) + assert.Equal(t, testRequestID, result.RequestID) assert.Equal(t, model.AsyncJobOpRoomMemberAdd, result.Operation) assert.Equal(t, "error", result.Status, "failure event must have Status=error") assert.Equal(t, "operation failed", result.Error, "failure event must carry sanitized error message") @@ -1556,14 +1556,14 @@ func TestHandler_publishAsyncJobResult_PopulatesErrorOnFailure(t *testing.T) { } h := NewHandler(nil, "site1", publish, testKeyStore, testKeySender) - ctx := natsutil.WithRequestID(context.Background(), "req-err-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) jobErr := errors.New("oops") h.publishAsyncJobResult(ctx, "alice", model.AsyncJobOpRoomMemberAdd, "r1", jobErr) - assert.Equal(t, subject.UserResponse("alice", "req-err-test"), capturedSubject) + assert.Equal(t, subject.UserResponse("alice", testRequestID), capturedSubject) var result model.AsyncJobResult require.NoError(t, json.Unmarshal(capturedData, &result)) - assert.Equal(t, "req-err-test", result.RequestID) + assert.Equal(t, testRequestID, result.RequestID) assert.Equal(t, model.AsyncJobOpRoomMemberAdd, result.Operation) assert.Equal(t, "error", result.Status) assert.Equal(t, "operation failed", result.Error) @@ -1591,7 +1591,7 @@ func TestHandler_publishAsyncJobResult_NoOpOnEmptyRequester(t *testing.T) { } h := NewHandler(nil, "site1", publish, testKeyStore, testKeySender) - ctx := natsutil.WithRequestID(context.Background(), "req-test") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) h.publishAsyncJobResult(ctx, "", model.AsyncJobOpRoomMemberAdd, "r1", nil) assert.False(t, called, "publish must be skipped when requester account is empty") } @@ -3491,7 +3491,7 @@ func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T Orgs: []string{"o1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-backfill-first-org") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) } @@ -3524,7 +3524,7 @@ func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *test Orgs: []string{"o_new"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-backfill-skipped") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) } @@ -3569,7 +3569,7 @@ func TestHandler_ProcessAddMembers_IndividualFilter_DirectAndOrgOverlap(t *testi Users: []string{"u1"}, Orgs: []string{"o1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-indiv-filter-direct-and-org-overlap") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) var indivAccts []string @@ -3620,7 +3620,7 @@ func TestHandler_ProcessAddMembers_IndividualFilter_OrgOnly(t *testing.T) { Orgs: []string{"o1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-indiv-filter-org-only") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) for _, m := range captured { @@ -3694,15 +3694,27 @@ func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) { Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) store.EXPECT().GetUser(gomock.Any(), "missing-requester").Return(nil, ErrUserNotFound) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "missing-id", RequesterAccount: "missing-requester", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-requester-not-found") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, data) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) assert.Contains(t, err.Error(), "missing-requester") + + responses := userResponseFor(published, "missing-requester") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, "missing-requester") + assert.NotContains(t, result.Error, ": permanent") } // D2: requester has empty EngName → permanent error. @@ -3720,14 +3732,26 @@ func TestHandler_ProcessAddMembers_RequesterEmptyName(t *testing.T) { store.EXPECT().GetUser(gomock.Any(), "alice"). Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "", ChineseName: "愛"}, nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-requester-empty-name") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, data) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) + + responses := userResponseFor(published, "alice") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, "missing required name fields") + assert.NotContains(t, result.Error, ": permanent") } // D3: added user has empty ChineseName → permanent error. @@ -3744,14 +3768,26 @@ func TestHandler_ProcessAddMembers_AddedUserEmptyName(t *testing.T) { Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: ""}}, nil) // Validation for added users should fire before requester fetch — do not mock GetUser here. - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + var published []publishedMsg + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { + published = append(published, publishedMsg{subj: subj, data: data}) + return nil + }} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-added-user-empty-name") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) err := h.processAddMembers(ctx, data) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) + + responses := userResponseFor(published, "alice") + require.NotEmpty(t, responses, "permanent error must publish async-job error event") + var result model.AsyncJobResult + require.NoError(t, json.Unmarshal(responses[0].data, &result)) + assert.Equal(t, model.AsyncJobStatusError, result.Status) + assert.Contains(t, result.Error, "missing required name fields") + assert.NotContains(t, result.Error, ": permanent") } // findSysMsg locates the system message published on MsgCanonicalCreated for @@ -3806,7 +3842,7 @@ func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) { Users: []string{"u1"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-content-single") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") @@ -3845,7 +3881,7 @@ func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) { Users: []string{"u1", "u2"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-content-multi") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") @@ -4079,10 +4115,8 @@ func TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent(t *testing.T) { } } -// F4 (post-review): single form must NOT fire when the join came via an -// org expansion — even if the org happens to have exactly one member. The -// user's intent was "add the org", not "add Bob individually", so the -// rendered Content uses the multi form. +// F4: 1-member org expansion must still render multi-form Content. + func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockSubscriptionStore(ctrl) @@ -4114,10 +4148,81 @@ func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *test Orgs: []string{"eng"}, Timestamp: 1, } data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-org-single-member") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content, "org-add must use multi form even when org expands to a single user") } + +// HasOrgRoomMembers error must surface as non-permanent so JetStream retries. +func TestHandler_ProcessAddMembers_HasOrgRoomMembersError_FailsClosed(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + + roomID := "r1" + store.EXPECT().GetRoom(gomock.Any(), roomID). + Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) + store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). + Return([]string{"u1"}, nil) + store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). + Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) + store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + store.EXPECT().HasOrgRoomMembers(gomock.Any(), roomID). + Return(false, fmt.Errorf("transient mongo error")) + // No BulkCreateRoomMembers / ReconcileMemberCounts — must short-circuit. + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{ + RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + err := h.processAddMembers(ctx, data) + require.Error(t, err) + assert.NotErrorIs(t, err, errPermanent, "Mongo errors must NOT be permanent — JetStream should retry") + assert.Contains(t, err.Error(), "check existing org room members") +} + +// X-Request-ID must be a hyphenated UUID; non-UUIDs leak into reply subjects. +func TestHandler_ProcessAddMembers_InvalidRequestID_ReturnsPermanent(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockSubscriptionStore(ctrl) + // No store mocks — validation must short-circuit before any store call. + + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + req := model.AddMembersRequest{ + RoomID: "r1", RequesterID: "u_a", RequesterAccount: "alice", + Users: []string{"u1"}, Timestamp: 1, + } + data, _ := json.Marshal(req) + ctx := natsutil.WithRequestID(context.Background(), "not-a-uuid") + + err := h.processAddMembers(ctx, data) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + assert.Contains(t, err.Error(), "invalid X-Request-ID") +} + +func TestProcessCreateRoom_InvalidRequestID_ReturnsPermanent(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + _ = mockStore // store mocks intentionally unset — must short-circuit before any call + ctx := natsutil.WithRequestID(context.Background(), "not-a-uuid") + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-1", + RequesterAccount: "alice", + Users: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + + err := h.processCreateRoom(ctx, body) + require.Error(t, err) + assert.ErrorIs(t, err, errPermanent) + assert.Contains(t, err.Error(), "invalid X-Request-ID") +} From 22c3628e06efe87487be966630f7489ff7bdce69 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Fri, 15 May 2026 11:03:06 +0000 Subject: [PATCH 09/15] feat(room-worker): set DM participant fields on DM/botDM rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DM and botDM rooms now persist matching uids/accounts pairs at room creation across all three create paths: - Async DM create (processCreateRoom, dm type) — calls UpdateDMParticipants after counterpart resolution. - Async botDM create (processCreateRoom, botDM type) — same path, bot account resolved like a regular user. - Sync DM create (handleSyncCreateDM) — sets UIDs/Accounts on the CreateRoom literal so the participants land in the same write as the room itself; on a dup-key (the DM existed already), the path is forward-only and does not backfill — the participants are immutable for a DM room, so no recovery work is needed. UpdateDMParticipants returns an error when no room matches the ID (room-not-found is permanent, not silently skipped). The channel create path is left untouched; a guard test pins that channels must omit UIDs/Accounts so omitempty drops them from BSON. Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/handler.go | 21 ++++++ room-worker/handler_test.go | 136 +++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/room-worker/handler.go b/room-worker/handler.go index 136023b6a..deb0d218f 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -1172,6 +1172,14 @@ func (h *Handler) processCreateRoomDM(ctx context.Context, req *model.CreateRoom if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { return fmt.Errorf("bulk create subs: %w", err) } + + uids, accounts := model.BuildDMParticipants(requester, other) + if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { + return fmt.Errorf("update dm participants: %w", err) + } + room.UIDs = uids + room.Accounts = accounts + return h.finishCreateRoom(ctx, req, room, requester, []model.User{*requester, *other}, subs, requestID, now) } @@ -1188,6 +1196,14 @@ func (h *Handler) processCreateRoomBotDM(ctx context.Context, req *model.CreateR if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { return fmt.Errorf("bulk create subs: %w", err) } + + uids, accounts := model.BuildDMParticipants(requester, bot) + if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { + return fmt.Errorf("update dm participants: %w", err) + } + room.UIDs = uids + room.Accounts = accounts + return h.finishCreateRoom(ctx, req, room, requester, []model.User{*requester, *bot}, subs, requestID, now) } @@ -1520,6 +1536,8 @@ func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.S acceptedAt := time.Now().UTC() roomID := idgen.BuildDMRoomID(requester.ID, other.ID) + uids, accounts := model.BuildDMParticipants(requester, other) + // DMs/botDMs have a fixed 2-member roster — set counts at creation; no Reconcile needed. userCount, appCount := 2, 0 if req.RoomType == model.RoomTypeBotDM { @@ -1534,6 +1552,8 @@ func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.S SiteID: h.siteID, UserCount: userCount, AppCount: appCount, + UIDs: uids, + Accounts: accounts, CreatedAt: acceptedAt, UpdatedAt: acceptedAt, } @@ -1557,6 +1577,7 @@ func (h *Handler) handleSyncCreateDM(ctx context.Context, data []byte) (*model.S "requestID", requestID) return nil, errRoomIDCollision } + // Sync-path duplicate-key: forward-only — no UIDs/Accounts backfill on the existing room. room = existing acceptedAt = existing.CreatedAt } diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index f3f0b94c7..a9500597f 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -2004,6 +2004,7 @@ func TestProcessCreateRoom_DM_BuildsTwoSubs(t *testing.T) { capturedSubs = subs return nil }) + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -2046,6 +2047,7 @@ func TestProcessCreateRoom_DM_EmitsNoSysMessages(t *testing.T) { mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -2076,6 +2078,7 @@ func TestProcessCreateRoom_BotDM_HasIsSubscribed(t *testing.T) { capturedSubs = subs return nil }) + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-bot-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -3074,6 +3077,7 @@ func TestProcessCreateRoom_DM_PublishesLocalInbox(t *testing.T) { mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-inbox").Return(nil) ts := time.Now().UnixMilli() @@ -4115,8 +4119,138 @@ func TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent(t *testing.T) { } } -// F4: 1-member org expansion must still render multi-form Content. +// F1: async DM create sets UIDs/Accounts sorted by UID, paired by index. +func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + 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"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-fields").Return(nil) + + // UIDs sorted: ["u_aaa","u_zzz"]; Accounts mirror that permutation. + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-dm-fields", + []string{"u_aaa", "u_zzz"}, []string{"bob", "alice"}). + Return(nil) + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-dm-fields", + RequesterAccount: "alice", + Users: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) +} + +// F2: async botDM create persists room with UIDs/Accounts paired by index. +func TestProcessCreateRoom_BotDM_SetsParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + requester := &model.User{ID: "u_zzz", Account: "alice", EngName: "Alice", ChineseName: "愛", SiteID: "site-A"} + bot := &model.User{ID: "u_aaa", Account: "supportbot.bot", EngName: "Support", ChineseName: "支援", SiteID: "site-A"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().GetUser(gomock.Any(), "supportbot.bot").Return(bot, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-botdm-fields").Return(nil) + + mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-botdm-fields", + []string{"u_aaa", "u_zzz"}, []string{"supportbot.bot", "alice"}). + Return(nil) + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-botdm-fields", + RequesterAccount: "alice", + Users: []string{"supportbot.bot"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) +} + +// F3: sync DM create sets UIDs/Accounts on the initial CreateRoom literal. +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"} + + mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), gomock.Any()). + Return([]model.User{requester, other}, nil) + + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().FindDMSubscription(gomock.Any(), "alice", "bob"). + Return(&model.Subscription{User: model.SubscriptionUser{ID: requester.ID, Account: requester.Account}}, nil) + mockStore.EXPECT().FindDMSubscription(gomock.Any(), "bob", "alice"). + Return(&model.Subscription{User: model.SubscriptionUser{ID: other.ID, Account: other.Account}}, nil) + // No UpdateDMParticipants expectation — sync path sets fields on the literal. + + reqBody, err := json.Marshal(model.SyncCreateDMRequest{ + RequesterAccount: "alice", + OtherAccount: "bob", + RoomType: model.RoomTypeDM, + }) + require.NoError(t, err) + + _, err = h.handleSyncCreateDM(ctx, reqBody) + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, []string{"u_aaa", "u_zzz"}, captured.UIDs) + assert.Equal(t, []string{"bob", "alice"}, captured.Accounts, "accounts paired with uid sort order") +} + +// F4: channels must omit UIDs/Accounts; guard test pins the contract. +func TestProcessCreateRoom_Channel_DoesNotSetParticipantFields(t *testing.T) { + h, mockStore, _ := newCreateRoomTestHandler(t) + ctx := natsutil.WithRequestID(context.Background(), testRequestID) + + requester := &model.User{ID: "u_a", Account: "alice", EngName: "Alice", ChineseName: "愛", SiteID: "site-A"} + bob := model.User{ID: "u_b", Account: "bob", EngName: "Bob", ChineseName: "鮑", SiteID: "site-A"} + + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) + mockStore.EXPECT().ListNewMembersForNewRoom(gomock.Any(), []string(nil), []string{"bob"}, "alice"). + Return([]string{"bob"}, nil) + mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{bob}, nil) + mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) + mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-chan-fields").Return(nil) + // No UpdateDMParticipants — channel path must never touch the fields. + + body := makeCreateRoomBody(t, &model.CreateRoomRequest{ + RoomID: "room-chan-fields", + RequesterAccount: "alice", + Name: "team-room", + ResolvedUsers: []string{"bob"}, + Timestamp: time.Now().UnixMilli(), + }) + require.NoError(t, h.processCreateRoom(ctx, body)) + require.NotNil(t, captured) + assert.Nil(t, captured.UIDs, "channels must omit UIDs (omitempty drops nil)") + assert.Nil(t, captured.Accounts, "channels must omit Accounts") +} + +// F4: 1-member org expansion must still render multi-form Content. func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockSubscriptionStore(ctrl) From e59fbb759ba202cb867b0b73f53b339e8eab5acc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 06:32:06 +0000 Subject: [PATCH 10/15] test(room-worker): adapt tests for room-encryption-keys merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebasing onto main (which brought in #171 room encryption keys), two classes of test adaptations were needed: 1. #171 tests of processAddMembers / processRemoveIndividual now hit the PR's stricter name-field validation. Added EngName/ChineseName to user fixtures and added the GetUser expectation for the requester account. 2. PR tests of processAddMembers that build the Handler via struct literal now reach the unconditional buildAndFanOutRoomKey call added in #171. Added keyStore: testKeyStore, keySender: testKeySender to all such Handler inits so the no-op stubs satisfy the fan-out call. Also retired one non-UUID request ID literal ("req-add-members-ordering") that the PR's UUID-format validation now rejects. No production-code changes — purely test-side reconciliation between the two feature branches. --- room-worker/handler_test.go | 92 +++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index a9500597f..fd0038b7b 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -45,7 +45,7 @@ func TestHandler_ProcessRoleUpdate_Promote(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleOwner, Timestamp: 1} data, _ := json.Marshal(req) @@ -96,7 +96,7 @@ func TestHandler_ProcessRoleUpdate_Demote(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleMember, Timestamp: 1} data, _ := json.Marshal(req) @@ -138,7 +138,7 @@ func TestHandler_ProcessRoleUpdate_CrossSite(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleOwner, Timestamp: 1} data, _ := json.Marshal(req) @@ -210,7 +210,7 @@ func TestHandler_ProcessRoleUpdate_InvalidJSON(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { t.Fatal("publish should not be called") return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} err := h.processRoleUpdate(context.Background(), []byte("not json")) if err == nil { @@ -226,7 +226,7 @@ func TestHandler_ProcessRoleUpdate_AddRoleError(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { t.Fatal("publish should not be called") return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleOwner, Timestamp: 1} data, _ := json.Marshal(req) @@ -245,7 +245,7 @@ func TestHandler_ProcessRoleUpdate_RemoveRoleError(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { t.Fatal("publish should not be called") return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleMember, Timestamp: 1} data, _ := json.Marshal(req) @@ -264,7 +264,7 @@ func TestHandler_ProcessRoleUpdate_GetSubscriptionError(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { t.Fatal("publish should not be called") return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: model.RoleOwner, Timestamp: 1} data, _ := json.Marshal(req) @@ -299,7 +299,7 @@ func TestHandler_ProcessRoleUpdate_UnsupportedRole(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { t.Fatal("publish should not be called") return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.UpdateRoleRequest{RoomID: "r1", Account: "bob", NewRole: "admin", Timestamp: 1} data, _ := json.Marshal(req) @@ -722,21 +722,24 @@ func TestHandler_ProcessAddMembers_PublishesSubscriptionUpdateBeforeRoomKey(t *t store.EXPECT().ListNewMembers(gomock.Any(), nil, []string{"bob", "charlie"}, "r1"). Return([]string{"bob", "charlie"}, nil) store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob", "charlie"}).Return([]model.User{ - {ID: "u2", Account: "bob", SiteID: "site-a"}, - {ID: "u3", Account: "charlie", SiteID: "site-a"}, + {ID: "u2", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}, + {ID: "u3", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"}, + }, nil) + store.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u1", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) store.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) store.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) req := model.AddMembersRequest{ - RoomID: "r1", Users: []string{"bob", "charlie"}, + RoomID: "r1", RequesterAccount: "alice", Users: []string{"bob", "charlie"}, History: model.HistoryConfig{Mode: model.HistoryModeNone}, Timestamp: 1, } reqData, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), "req-add-members-ordering") + ctx := natsutil.WithRequestID(context.Background(), testRequestID) require.NoError(t, h.processAddMembers(ctx, reqData)) for _, account := range []string{"bob", "charlie"} { @@ -3287,7 +3290,10 @@ func TestProcessAddMembers_FansOutKeyToNewAccountsOnly(t *testing.T) { mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1"). Return([]string{"charlie"}, nil) mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{ - {ID: "u_charlie", Account: "charlie", SiteID: "site-a"}, + {ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"}, + }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) @@ -3327,7 +3333,10 @@ func TestProcessAddMembers_PermanentErrorWhenKeyMissing(t *testing.T) { mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1"). Return([]string{"charlie"}, nil) mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{ - {ID: "u_charlie", Account: "charlie", SiteID: "site-a"}, + {ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"}, + }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) @@ -3360,7 +3369,10 @@ func TestProcessAddMembers_TransientErrorWhenValkeyFails(t *testing.T) { mockStore.EXPECT().ListNewMembers(gomock.Any(), gomock.Any(), gomock.Any(), "r1"). Return([]string{"charlie"}, nil) mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"charlie"}).Return([]model.User{ - {ID: "u_charlie", Account: "charlie", SiteID: "site-a"}, + {ID: "u_charlie", Account: "charlie", SiteID: "site-a", EngName: "Charlie", ChineseName: "查"}, + }, nil) + mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(&model.User{ + ID: "u_alice", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛", }, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().HasOrgRoomMembers(gomock.Any(), "r1").Return(false, nil) @@ -3409,7 +3421,7 @@ func TestProcessRemoveMember_SkipsRotationWhenValkeyAlreadyAhead(t *testing.T) { // Mongo work still happens (idempotent). No Rotate/Set should be called. store.EXPECT().GetUserWithMembership(gomock.Any(), "r1", "bob"). - Return(&UserWithMembership{User: model.User{ID: "u-bob", Account: "bob", SiteID: "site-a"}}, nil) + Return(&UserWithMembership{User: model.User{ID: "u-bob", Account: "bob", SiteID: "site-a", EngName: "Bob", ChineseName: "鮑"}}, nil) store.EXPECT().DeleteRoomMember(gomock.Any(), "r1", model.RoomMemberIndividual, "u-bob").Return(nil) store.EXPECT().DeleteSubscription(gomock.Any(), "r1", "bob").Return(int64(1), nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) @@ -3488,7 +3500,7 @@ func TestHandler_ProcessAddMembers_BackfillRunsOnFirstOrgTransition(t *testing.T store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3521,7 +3533,7 @@ func TestHandler_ProcessAddMembers_BackfillSkippedWhenRoomAlreadyHasOrgs(t *test store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3566,7 +3578,7 @@ func TestHandler_ProcessAddMembers_IndividualFilter_DirectAndOrgOverlap(t *testi }) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3617,7 +3629,7 @@ func TestHandler_ProcessAddMembers_IndividualFilter_OrgOnly(t *testing.T) { }) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3659,7 +3671,7 @@ func TestHandler_ProcessCreateRoom_Channel_IndividualFilter(t *testing.T) { }) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} room := &model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} req := &model.CreateRoomRequest{ @@ -3702,7 +3714,7 @@ func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "missing-id", RequesterAccount: "missing-requester", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) ctx := natsutil.WithRequestID(context.Background(), testRequestID) @@ -3740,7 +3752,7 @@ func TestHandler_ProcessAddMembers_RequesterEmptyName(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) ctx := natsutil.WithRequestID(context.Background(), testRequestID) @@ -3776,7 +3788,7 @@ func TestHandler_ProcessAddMembers_AddedUserEmptyName(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} data, _ := json.Marshal(req) ctx := natsutil.WithRequestID(context.Background(), testRequestID) @@ -3839,7 +3851,7 @@ func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3878,7 +3890,7 @@ func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -3901,7 +3913,7 @@ func TestHandler_PublishChannelSysMessages_MembersAddedContent(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} room := &model.Room{ID: "r1", Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel} requester := &model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"} @@ -3933,10 +3945,10 @@ func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.RemoveMemberRequest{RoomID: roomID, Requester: "bob", Account: "bob", Timestamp: 1} - require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil, false)) sysMsg := findSysMsg(t, published, "site-a", "member_left") assert.Equal(t, "bob", sysMsg.UserAccount) @@ -3962,10 +3974,10 @@ func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", Account: "bob", Timestamp: 1} - require.NoError(t, h.processRemoveIndividual(context.Background(), &req)) + require.NoError(t, h.processRemoveIndividual(context.Background(), &req, nil, false)) sysMsg := findSysMsg(t, published, "site-a", "member_removed") assert.Equal(t, "alice", sysMsg.UserAccount) @@ -3989,11 +4001,11 @@ func TestHandler_ProcessRemoveIndividual_EmptyName(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", Timestamp: 1} ctx := natsutil.WithRequestID(context.Background(), testRequestID) - err := h.processRemoveIndividual(ctx, &req) + err := h.processRemoveIndividual(ctx, &req, nil, false) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) @@ -4026,10 +4038,10 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.RemoveMemberRequest{RoomID: roomID, Requester: "alice", OrgID: "o1", Timestamp: 1} - require.NoError(t, h.processRemoveOrg(context.Background(), &req)) + require.NoError(t, h.processRemoveOrg(context.Background(), &req, nil, false)) sysMsg := findSysMsg(t, published, "site-a", "member_removed") assert.Equal(t, "alice", sysMsg.UserAccount) @@ -4054,11 +4066,11 @@ func TestHandler_ProcessRemoveOrg_AllSectNamesEmpty(t *testing.T) { h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", OrgID: "o1", Timestamp: 1} ctx := natsutil.WithRequestID(context.Background(), testRequestID) - err := h.processRemoveOrg(ctx, &req) + err := h.processRemoveOrg(ctx, &req, nil, false) require.Error(t, err) assert.ErrorIs(t, err, errPermanent) @@ -4275,7 +4287,7 @@ func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *test h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { published = append(published, publishedMsg{subj: subj, data: data}) return nil - }} + }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", @@ -4309,7 +4321,7 @@ func TestHandler_ProcessAddMembers_HasOrgRoomMembersError_FailsClosed(t *testing Return(false, fmt.Errorf("transient mongo error")) // No BulkCreateRoomMembers / ReconcileMemberCounts — must short-circuit. - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1, @@ -4329,7 +4341,7 @@ func TestHandler_ProcessAddMembers_InvalidRequestID_ReturnsPermanent(t *testing. store := NewMockSubscriptionStore(ctrl) // No store mocks — validation must short-circuit before any store call. - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }} + h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore: testKeyStore, keySender: testKeySender} req := model.AddMembersRequest{ RoomID: "r1", RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1, From 453905d829758a97e75ed48794f95dafb7faa926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 06:55:46 +0000 Subject: [PATCH 11/15] docs(plan,spec) + test(room-worker): align scope statements and extend integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation: the plan's architecture summary and file-map preamble, and the spec's §2.6 closing line, all claimed there were no model or store interface changes — contradicting Tasks 11-12 (Room.UIDs/ Accounts + BuildDMParticipants, UpdateDMParticipants on SubscriptionStore). Updated the three lines so the scope statements match what the plan and spec actually deliver. Tests: the existing room-worker feature integration tests exercised the code paths for the four bug fixes but did not assert the user-facing artifacts those fixes produce. Extended: - TestSyncCreateDM_DM_PersistsRoomAndSubs + TestProcessCreateRoomDM- PersistsTwoSubsAndZeroMembers: assert room.UIDs and room.Accounts are persisted (sorted by uid, paired by index) — covers DM participant fields persistence. - TestProcessAddMembers_PublishesLocalInbox_Integration: assert the canonical members_added sys-message carries UserAccount=requester and formatter-rendered Content — covers empty-Content + missing- sender bugs on add. - TestProcessRemoveIndividual_PublishesLocalInbox_Integration: same assertion for the member_removed sys-message — covers empty-Content + missing-sender bugs on forced removal. Org-member duplication remains covered by handler_test.go unit tests; the existing AddMembers integration test uses only direct users so it doesn't exercise the duplication scenario. --- ...2026-05-14-room-worker-membership-fixes.md | 4 ++-- ...-13-room-worker-membership-fixes-design.md | 2 +- room-worker/integration_test.go | 24 +++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md b/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md index 93ae61f0c..ea173504f 100644 --- a/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md +++ b/docs/superpowers/plans/2026-05-14-room-worker-membership-fixes.md @@ -4,7 +4,7 @@ **Goal:** Fix three room-worker bugs — org-member duplication in `room_members`, empty `Content` on `members_added` sys-messages, and missing sender + empty `Content` on `member_removed` / `member_left` — without changing wire schemas or migrating existing data. -**Architecture:** Introduce a `room-worker/sysmsg.go` helper file with five formatter functions. Apply localized changes inside `processAddMembers`, `processCreateRoomChannel`, `processRemoveIndividual`, `processRemoveOrg`, and `publishChannelSysMessages` in `room-worker/handler.go`. All changes are internal to `room-worker`; no store interface, model, or wire-protocol changes. +**Architecture:** Introduce `room-worker/sysmsg.go` formatter helpers and update `room-worker/handler.go` membership flows. Also add DM participant persistence spanning `pkg/model` (`Room.UIDs`/`Room.Accounts` fields, `BuildDMParticipants` helper) and the `room-worker` store contract (`UpdateDMParticipants` on `SubscriptionStore`). No wire-protocol changes. **Tech Stack:** Go 1.25, NATS JetStream, MongoDB driver v2, `go.uber.org/mock` (mockgen), `stretchr/testify`. @@ -21,7 +21,7 @@ | `room-worker/handler.go` | Modify | `processAddMembers`, `processCreateRoomChannel`, `processRemoveIndividual`, `processRemoveOrg`, `publishChannelSysMessages` | | `room-worker/handler_test.go` | Modify | New table-driven tests for filter rule, backfill gate, Content, sender, name validation | -No `store.go` changes — every method the plan needs is already on the `SubscriptionStore` interface. +`store.go` is extended in Task 12 to add `UpdateDMParticipants` to the `SubscriptionStore` interface for DM participant persistence; the remaining tasks reuse methods already on the interface. --- diff --git a/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md b/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md index c8fa1e0c6..fb38bcbff 100644 --- a/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md +++ b/docs/superpowers/specs/2026-05-13-room-worker-membership-fixes-design.md @@ -93,7 +93,7 @@ New `room-worker/sysmsg.go`: Display-name composition: `strings.TrimSpace(u.EngName + " " + u.ChineseName)`. Empty-name inputs are *not* handled by the formatters — callers validate `EngName`/`ChineseName` non-empty before invocation (§2.3, §2.4). The formatters trust their inputs. -Unit tests in `room-worker/sysmsg_test.go`. No store interface changes. +Unit tests in `room-worker/sysmsg_test.go`. The membership-correctness work above does not change the store interface; §3.4 adds `UpdateDMParticipants` to `SubscriptionStore` separately for DM participant persistence. ## 3. DM Participant Fields diff --git a/room-worker/integration_test.go b/room-worker/integration_test.go index 0680888ba..4a0c7e63f 100644 --- a/room-worker/integration_test.go +++ b/room-worker/integration_test.go @@ -605,6 +605,8 @@ func TestProcessCreateRoomDMPersistsTwoSubsAndZeroMembers(t *testing.T) { // CreatedBy is the requester's User.ID for every room type, including // DM/botDM (post-v2 cleanup; previously empty for DM/botDM). assert.Equal(t, "u_alice", room.CreatedBy) + assert.Equal(t, []string{"u_alice", "u_bob"}, room.UIDs, "DM participant uids persisted, sorted") + assert.Equal(t, []string{"alice", "bob"}, room.Accounts, "DM participant accounts persisted, paired by index with uids") } func TestProcessCreateRoomChannel_OutboxPerRemoteSite(t *testing.T) { @@ -925,6 +927,16 @@ func TestProcessAddMembers_PublishesLocalInbox_Integration(t *testing.T) { "local INBOX must carry full add set — same-site (charlie) + remote (bob)") assert.Equal(t, reqID+":site-A", pubs[0].msgID, "Nats-Msg-Id must be natsutil.OutboxDedupID(ctx, originSite, payloadSeed) so JetStream dedups self-loop replays") + + // members_added sys-message: requester is the sender, Content is server-rendered. + sysPubs := cap.outboxOnPrefix(subject.MsgCanonicalCreated("site-A")) + require.Len(t, sysPubs, 1, "exactly one members_added sys-message per add-members call") + var sysEvt model.MessageEvent + require.NoError(t, json.Unmarshal(sysPubs[0].data, &sysEvt)) + assert.Equal(t, model.MessageTypeMembersAdded, sysEvt.Message.Type) + assert.Equal(t, "alice", sysEvt.Message.UserAccount, "sender is the requester") + assert.Equal(t, "Alice 爱丽丝 added members to the channel", sysEvt.Message.Content, + "multi-add Content uses formatAddedMulti(requester)") } func TestProcessRemoveIndividual_PublishesLocalInbox_Integration(t *testing.T) { @@ -981,6 +993,16 @@ func TestProcessRemoveIndividual_PublishesLocalInbox_Integration(t *testing.T) { assert.Equal(t, roomID, inner.RoomID) assert.Equal(t, []string{"bob"}, inner.Accounts) assert.Equal(t, reqID+":site-A", pubs[0].msgID) + + // member_removed sys-message: requester is the sender, Content is server-rendered. + sysPubs := cap.outboxOnPrefix(subject.MsgCanonicalCreated("site-A")) + require.Len(t, sysPubs, 1, "exactly one member_removed sys-message per remove-member call") + var sysEvt model.MessageEvent + require.NoError(t, json.Unmarshal(sysPubs[0].data, &sysEvt)) + assert.Equal(t, model.MessageTypeMemberRemoved, sysEvt.Message.Type) + assert.Equal(t, "alice", sysEvt.Message.UserAccount, "sender is the requester, not the removed user") + assert.Equal(t, "Bob 鲍勃 has been removed from the channel", sysEvt.Message.Content, + "forced-remove Content uses formatRemovedUser(user)") } // --- Sync DM endpoint integration tests --- @@ -1018,6 +1040,8 @@ func TestSyncCreateDM_DM_PersistsRoomAndSubs(t *testing.T) { assert.Equal(t, siteID, room.SiteID) assert.Equal(t, 2, room.UserCount, "DM room.UserCount set at creation; no Reconcile pass") assert.Equal(t, 0, room.AppCount) + assert.Equal(t, []string{"u-alice", "u-bob"}, room.UIDs, "DM participant uids persisted, sorted") + assert.Equal(t, []string{"alice", "bob"}, room.Accounts, "DM participant accounts persisted, paired by index with uids") subCount, err := db.Collection("subscriptions").CountDocuments(ctx, bson.M{"roomId": roomID}) require.NoError(t, err) From 9e17af429e202446af5c1ef5fadf4fac4fb1e03b Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Mon, 18 May 2026 04:15:37 +0000 Subject: [PATCH 12/15] feat(broadcast-worker): fan out DM/botDM mutation events via room.Accounts; skip bots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fanOutMutationEvent previously called ListSubscriptions on every DM edit/delete to recover recipient accounts — but Room.Accounts (denormalized onto the room doc by room-worker for DM/botDM rooms) already carries the same data. Drop the Mongo round-trip and iterate room.Accounts directly. Merge the DM and BotDM cases (BotDM previously fell into the unknown-room-type default branch and logged a misleading warning) and skip bot accounts so the bot in a botDM does not receive its own edit/delete echo on a subject nothing listens on. Add a local isBot helper mirroring the predicate already present in message-gatekeeper and room-service; promotion to a shared pkg/botid is the right cleanup once a fourth duplicate is no longer acceptable. Tests: - existing DM edit/delete fan-out tests switched fixtures from a ListSubscriptions mock to Accounts on the Room struct (same coverage) - new TestHandleUpdated_BotDMRoom_SkipsBotAccount asserts the bot is filtered in a botDM room Co-Authored-By: Claude Opus 4.7 (1M context) --- broadcast-worker/handler.go | 12 +++---- broadcast-worker/handler_test.go | 62 ++++++++++++++++++++++++++------ broadcast-worker/helper.go | 12 +++++++ 3 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 broadcast-worker/helper.go diff --git a/broadcast-worker/handler.go b/broadcast-worker/handler.go index da79d94d8..9eb476cb2 100644 --- a/broadcast-worker/handler.go +++ b/broadcast-worker/handler.go @@ -171,17 +171,15 @@ func (h *Handler) fanOutMutationEvent( } return h.pub.Publish(ctx, subject.RoomEvent(room.ID), payload) - case model.RoomTypeDM: - subs, err := h.store.ListSubscriptions(ctx, room.ID) - if err != nil { - return fmt.Errorf("list subscriptions for DM room %s: %w", room.ID, err) - } + case model.RoomTypeDM, model.RoomTypeBotDM: payload, err := json.Marshal(&roomEvt) if err != nil { return fmt.Errorf("marshal %s DM event: %w", roomEvtType, err) } - for i := range subs { - account := subs[i].User.Account + for _, account := range room.Accounts { + if isBot(account) { + continue + } if err := h.pub.Publish(ctx, subject.UserRoomEvent(account), payload); err != nil { slog.Error("publish DM mutation event failed", "error", err, diff --git a/broadcast-worker/handler_test.go b/broadcast-worker/handler_test.go index 92613a95e..a5f067e01 100644 --- a/broadcast-worker/handler_test.go +++ b/broadcast-worker/handler_test.go @@ -888,13 +888,13 @@ func TestHandleUpdated_DMRoom_FansOutToBothMembers(t *testing.T) { keyStore := NewMockRoomKeyProvider(ctrl) roomID := "dm-alice-bob" - room := &model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a"} - subs := []model.Subscription{ - {User: model.SubscriptionUser{ID: "u-alice", Account: "alice"}, RoomID: roomID}, - {User: model.SubscriptionUser{ID: "u-bob", Account: "bob"}, RoomID: roomID}, + room := &model.Room{ + ID: roomID, + Type: model.RoomTypeDM, + SiteID: "site-a", + Accounts: []string{"alice", "bob"}, } store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil) - store.EXPECT().ListSubscriptions(gomock.Any(), roomID).Return(subs, nil) edited := time.Date(2026, 5, 14, 12, 5, 0, 0, time.UTC) evt := model.MessageEvent{ @@ -946,13 +946,13 @@ func TestHandleDeleted_DMRoom_FansOutToBothMembers(t *testing.T) { keyStore := NewMockRoomKeyProvider(ctrl) roomID := "dm-alice-bob" - room := &model.Room{ID: roomID, Type: model.RoomTypeDM, SiteID: "site-a"} - subs := []model.Subscription{ - {User: model.SubscriptionUser{ID: "u-alice", Account: "alice"}, RoomID: roomID}, - {User: model.SubscriptionUser{ID: "u-bob", Account: "bob"}, RoomID: roomID}, + room := &model.Room{ + ID: roomID, + Type: model.RoomTypeDM, + SiteID: "site-a", + Accounts: []string{"alice", "bob"}, } store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil) - store.EXPECT().ListSubscriptions(gomock.Any(), roomID).Return(subs, nil) deletedAt := time.Date(2026, 5, 14, 12, 10, 0, 0, time.UTC) evt := model.MessageEvent{ @@ -993,6 +993,48 @@ func TestHandleDeleted_DMRoom_FansOutToBothMembers(t *testing.T) { assert.True(t, subjects[subject.UserRoomEvent("bob")]) } +func TestHandleUpdated_BotDMRoom_SkipsBotAccount(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + us := NewMockUserStore(ctrl) + pub := &mockPublisher{} + keyStore := NewMockRoomKeyProvider(ctrl) + + roomID := "botdm-alice-helper.bot" + room := &model.Room{ + ID: roomID, + Type: model.RoomTypeBotDM, + SiteID: "site-a", + Accounts: []string{"alice", "helper.bot"}, + } + store.EXPECT().GetRoom(gomock.Any(), roomID).Return(room, nil) + + edited := time.Date(2026, 5, 14, 12, 5, 0, 0, time.UTC) + evt := model.MessageEvent{ + Event: model.EventUpdated, + SiteID: "site-a", + Timestamp: edited.UnixMilli(), + Message: model.Message{ + ID: "msg-1", + RoomID: roomID, + UserID: "u-alice", + UserAccount: "alice", + Content: "updated content", + CreatedAt: time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC), + EditedAt: &edited, + UpdatedAt: &edited, + }, + } + data, err := json.Marshal(&evt) + require.NoError(t, err) + + h := NewHandler(store, us, pub, keyStore, true) + require.NoError(t, h.HandleMessage(context.Background(), data)) + + require.Len(t, pub.records, 1, "botDM: only the human recipient gets the live event") + assert.Equal(t, subject.UserRoomEvent("alice"), pub.records[0].subject) +} + func TestHandler_HandleMessage_ChannelEncryptionDisabled(t *testing.T) { msgTime := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) senderUser := model.User{ID: "u-sender", Account: "sender", EngName: "Sender Lin", ChineseName: "寄件者", SiteID: "site-a"} diff --git a/broadcast-worker/helper.go b/broadcast-worker/helper.go new file mode 100644 index 000000000..d51e0c05e --- /dev/null +++ b/broadcast-worker/helper.go @@ -0,0 +1,12 @@ +package main + +import "strings" + +// isBot returns true if account follows the bot naming convention used across +// the codebase (suffix `.bot` or prefix `p_`). Mirrors the predicate in +// message-gatekeeper/helper.go and room-service/helper.go — promoting to a +// shared pkg/botid is a future cleanup; keep these copies in sync if the +// convention changes. +func isBot(account string) bool { + return strings.HasSuffix(account, ".bot") || strings.HasPrefix(account, "p_") +} From 3c6f817e1ed6ac33fdfd8a39f3e4b1a0dcba7ea2 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Mon, 18 May 2026 04:43:30 +0000 Subject: [PATCH 13/15] refactor(room-worker): single-write DM/botDM create; dedupe displayName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two PR #185 review comments from mliu: 1. processCreateRoom previously inserted the room and then ran a follow-up UpdateDMParticipants UpdateOne to backfill UIDs/Accounts after the counterpart user was resolved. That second Mongo round-trip is avoidable for new rooms: fetch the counterpart upfront, populate Room.UIDs/Accounts on the literal, and let CreateRoom persist the complete document in one write — matching the sync DM path's pattern. UpdateDMParticipants is removed from SubscriptionStore (interface + Mongo impl + mock); no callers remain. The duplicate-key replay branch keeps the existing identity-equality semantics (Type/SiteID/Name/CreatedBy must match); it no longer rewrites UIDs/Accounts onto the existing doc, since rooms created by this code path already have those fields and legacy rooms are out of scope for the hot path. The newly-similar processCreateRoomDM/BotDM helpers collapsed into the switch, mirroring the inline if-RoomTypeBotDM pattern handleSyncCreateDM already uses. 2. displayName(u) used to return TrimSpace(EngName + " " + ChineseName). For account-only users where EngName == ChineseName, that rendered as "Bob Bob". Short-circuit to a single rendering when the two fields are equal. Channels remain the only consumer of system-message content; DM/botDM paths don't render via this helper. Tests: - F1/F2 (DM/BotDM SetsParticipantFields) now capture the Room passed to CreateRoom and assert UIDs/Accounts on it, replacing the previous UpdateDMParticipants arg-pin - CollisionMismatchType test threads the upfront counterpart GetUser - TestFormatLeft_EngEqualsChineseRendersOnce pins the new dedupe contract - mocks regenerated; integration test compile-clean Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/handler.go | 81 ++++++++++++---------------------- room-worker/handler_test.go | 57 ++++++++++++++---------- room-worker/mock_store_test.go | 14 ------ room-worker/store.go | 5 --- room-worker/store_mongo.go | 14 ------ room-worker/sysmsg.go | 3 ++ room-worker/sysmsg_test.go | 13 ++++++ 7 files changed, 79 insertions(+), 108 deletions(-) diff --git a/room-worker/handler.go b/room-worker/handler.go index deb0d218f..c633c7f88 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -1108,6 +1108,24 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error CreatedAt: acceptedAt, UpdatedAt: acceptedAt, } + + // Fetch the DM/botDM counterpart upfront so the room can be inserted in a + // single write with UIDs/Accounts populated, matching the sync DM path. + var counterpart *model.User + if roomType == model.RoomTypeDM || roomType == model.RoomTypeBotDM { + counterpart, err = h.store.GetUser(ctx, req.Users[0]) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + if roomType == model.RoomTypeBotDM { + return newPermanent("bot user not found") + } + return newPermanent("counterpart not found") + } + return fmt.Errorf("get counterpart: %w", err) + } + room.UIDs, room.Accounts = model.BuildDMParticipants(requester, counterpart) + } + if err := h.store.CreateRoom(ctx, room); err != nil { if mongo.IsDuplicateKeyError(err) { existing, fetchErr := h.store.GetRoom(ctx, room.ID) @@ -1134,10 +1152,17 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error } switch roomType { - case model.RoomTypeDM: - return h.processCreateRoomDM(ctx, &req, room, requester, requestID, acceptedAt, now) - case model.RoomTypeBotDM: - return h.processCreateRoomBotDM(ctx, &req, room, requester, requestID, acceptedAt, now) + case model.RoomTypeDM, model.RoomTypeBotDM: + var subs []*model.Subscription + if roomType == model.RoomTypeBotDM { + subs = buildBotDMSubs(requester, counterpart, room, acceptedAt) + } else { + subs = buildDMSubs(requester, counterpart, room, acceptedAt) + } + if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { + return fmt.Errorf("bulk create subs: %w", err) + } + return h.finishCreateRoom(ctx, &req, room, requester, []model.User{*requester, *counterpart}, subs, requestID, now) case model.RoomTypeChannel: return h.processCreateRoomChannel(ctx, &req, room, requester, requestID, acceptedAt, now) default: @@ -1159,54 +1184,6 @@ func determineRoomTypeFromPayload(req *model.CreateRoomRequest) model.RoomType { return model.RoomTypeChannel } -func (h *Handler) processCreateRoomDM(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error { - other, err := h.store.GetUser(ctx, req.Users[0]) - if err != nil { - if errors.Is(err, ErrUserNotFound) { - return newPermanent("counterpart not found") - } - return fmt.Errorf("get counterpart: %w", err) - } - - subs := buildDMSubs(requester, other, room, acceptedAt) - if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { - return fmt.Errorf("bulk create subs: %w", err) - } - - uids, accounts := model.BuildDMParticipants(requester, other) - if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { - return fmt.Errorf("update dm participants: %w", err) - } - room.UIDs = uids - room.Accounts = accounts - - return h.finishCreateRoom(ctx, req, room, requester, []model.User{*requester, *other}, subs, requestID, now) -} - -func (h *Handler) processCreateRoomBotDM(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error { - bot, err := h.store.GetUser(ctx, req.Users[0]) - if err != nil { - if errors.Is(err, ErrUserNotFound) { - return newPermanent("bot user not found") - } - return fmt.Errorf("get bot user: %w", err) - } - - subs := buildBotDMSubs(requester, bot, room, acceptedAt) - if err := h.store.BulkCreateSubscriptions(ctx, subs); err != nil { - return fmt.Errorf("bulk create subs: %w", err) - } - - uids, accounts := model.BuildDMParticipants(requester, bot) - if err := h.store.UpdateDMParticipants(ctx, room.ID, uids, accounts); err != nil { - return fmt.Errorf("update dm participants: %w", err) - } - room.UIDs = uids - room.Accounts = accounts - - return h.finishCreateRoom(ctx, req, room, requester, []model.User{*requester, *bot}, subs, requestID, now) -} - func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.CreateRoomRequest, room *model.Room, requester *model.User, requestID string, acceptedAt, now time.Time) error { // Pass requester.Account as excludeAccount so org-expansion can't re- // introduce the requester (who joins separately as owner). Mirrors the diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index fd0038b7b..23a01dacf 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -1998,8 +1998,8 @@ func TestProcessCreateRoom_DM_BuildsTwoSubs(t *testing.T) { other := &model.User{ID: "u_bob", Account: "bob", EngName: "Bob B", ChineseName: "鮑伯", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) var capturedSubs []*model.Subscription mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()). @@ -2007,7 +2007,6 @@ func TestProcessCreateRoom_DM_BuildsTwoSubs(t *testing.T) { capturedSubs = subs return nil }) - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -2047,10 +2046,9 @@ func TestProcessCreateRoom_DM_EmitsNoSysMessages(t *testing.T) { other := &model.User{ID: "u_bob", Account: "bob", EngName: "Bob B", ChineseName: "鮑伯", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -2072,8 +2070,8 @@ func TestProcessCreateRoom_BotDM_HasIsSubscribed(t *testing.T) { bot := &model.User{ID: "u_bot", Account: "helper.bot", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "helper.bot").Return(bot, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) var capturedSubs []*model.Subscription mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()). @@ -2081,7 +2079,6 @@ func TestProcessCreateRoom_BotDM_HasIsSubscribed(t *testing.T) { capturedSubs = subs return nil }) - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-bot-1").Return(nil) body := makeCreateRoomBody(t, &model.CreateRoomRequest{ @@ -2445,8 +2442,11 @@ func TestProcessCreateRoom_RoomIDCollisionMismatchType_ReturnsPermanent(t *testi ctx := natsutil.WithRequestID(context.Background(), testRequestID) requester := &model.User{ID: "u_alice", Account: "alice", EngName: "Alice A", ChineseName: "艾麗斯", SiteID: "site-A"} + other := &model.User{ID: "u_bob", Account: "bob", EngName: "Bob B", ChineseName: "鮑伯", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) + // Counterpart resolved upfront so CreateRoom can set UIDs/Accounts in one write. + mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) // Insert collides on _id. mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(mongo.WriteException{ WriteErrors: []mongo.WriteError{{Code: 11000, Message: "duplicate key"}}, @@ -3077,10 +3077,9 @@ func TestProcessCreateRoom_DM_PublishesLocalInbox(t *testing.T) { other := &model.User{ID: "u_bob", Account: "bob", EngName: "Bob", ChineseName: "鮑", SiteID: "site-B"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-inbox").Return(nil) ts := time.Now().UnixMilli() @@ -4131,7 +4130,8 @@ func TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent(t *testing.T) { } } -// F1: async DM create sets UIDs/Accounts sorted by UID, paired by index. +// F1: async DM create sets UIDs/Accounts sorted by UID, paired by index, on +// the initial CreateRoom insert (single Mongo write, no follow-up update). func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { h, mockStore, _ := newCreateRoomTestHandler(t) ctx := natsutil.WithRequestID(context.Background(), testRequestID) @@ -4140,16 +4140,17 @@ func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { other := &model.User{ID: "u_aaa", Account: "bob", EngName: "Bob", ChineseName: "鮑", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "bob").Return(other, nil) + + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-dm-fields").Return(nil) - // UIDs sorted: ["u_aaa","u_zzz"]; Accounts mirror that permutation. - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-dm-fields", - []string{"u_aaa", "u_zzz"}, []string{"bob", "alice"}). - Return(nil) - body := makeCreateRoomBody(t, &model.CreateRoomRequest{ RoomID: "room-dm-fields", RequesterAccount: "alice", @@ -4157,9 +4158,15 @@ func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { Timestamp: time.Now().UnixMilli(), }) require.NoError(t, h.processCreateRoom(ctx, body)) + + require.NotNil(t, captured) + // UIDs sorted: ["u_aaa","u_zzz"]; Accounts mirror that permutation. + assert.Equal(t, []string{"u_aaa", "u_zzz"}, captured.UIDs) + assert.Equal(t, []string{"bob", "alice"}, captured.Accounts, "accounts paired with uid sort order") } -// F2: async botDM create persists room with UIDs/Accounts paired by index. +// F2: async botDM create persists room with UIDs/Accounts paired by index on +// the initial CreateRoom insert (single Mongo write, no follow-up update). func TestProcessCreateRoom_BotDM_SetsParticipantFields(t *testing.T) { h, mockStore, _ := newCreateRoomTestHandler(t) ctx := natsutil.WithRequestID(context.Background(), testRequestID) @@ -4168,15 +4175,17 @@ func TestProcessCreateRoom_BotDM_SetsParticipantFields(t *testing.T) { bot := &model.User{ID: "u_aaa", Account: "supportbot.bot", EngName: "Support", ChineseName: "支援", SiteID: "site-A"} mockStore.EXPECT().GetUser(gomock.Any(), "alice").Return(requester, nil) - mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().GetUser(gomock.Any(), "supportbot.bot").Return(bot, nil) + + var captured *model.Room + mockStore.EXPECT().CreateRoom(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, r *model.Room) error { + captured = r + return nil + }) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-botdm-fields").Return(nil) - mockStore.EXPECT().UpdateDMParticipants(gomock.Any(), "room-botdm-fields", - []string{"u_aaa", "u_zzz"}, []string{"supportbot.bot", "alice"}). - Return(nil) - body := makeCreateRoomBody(t, &model.CreateRoomRequest{ RoomID: "room-botdm-fields", RequesterAccount: "alice", @@ -4184,6 +4193,10 @@ func TestProcessCreateRoom_BotDM_SetsParticipantFields(t *testing.T) { Timestamp: time.Now().UnixMilli(), }) require.NoError(t, h.processCreateRoom(ctx, body)) + + require.NotNil(t, captured) + assert.Equal(t, []string{"u_aaa", "u_zzz"}, captured.UIDs) + assert.Equal(t, []string{"supportbot.bot", "alice"}, captured.Accounts) } // F3: sync DM create sets UIDs/Accounts on the initial CreateRoom literal. @@ -4210,7 +4223,6 @@ func TestHandleSyncCreateDM_SetsParticipantFieldsOnInitialCreate(t *testing.T) { Return(&model.Subscription{User: model.SubscriptionUser{ID: requester.ID, Account: requester.Account}}, nil) mockStore.EXPECT().FindDMSubscription(gomock.Any(), "bob", "alice"). Return(&model.Subscription{User: model.SubscriptionUser{ID: other.ID, Account: other.Account}}, nil) - // No UpdateDMParticipants expectation — sync path sets fields on the literal. reqBody, err := json.Marshal(model.SyncCreateDMRequest{ RequesterAccount: "alice", @@ -4247,7 +4259,6 @@ func TestProcessCreateRoom_Channel_DoesNotSetParticipantFields(t *testing.T) { mockStore.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"bob"}).Return([]model.User{bob}, nil) mockStore.EXPECT().BulkCreateSubscriptions(gomock.Any(), gomock.Any()).Return(nil) mockStore.EXPECT().ReconcileMemberCounts(gomock.Any(), "room-chan-fields").Return(nil) - // No UpdateDMParticipants — channel path must never touch the fields. body := makeCreateRoomBody(t, &model.CreateRoomRequest{ RoomID: "room-chan-fields", diff --git a/room-worker/mock_store_test.go b/room-worker/mock_store_test.go index d073d956d..63d1a490b 100644 --- a/room-worker/mock_store_test.go +++ b/room-worker/mock_store_test.go @@ -446,17 +446,3 @@ func (mr *MockRoomKeyStoreMockRecorder) Set(ctx, roomID, pair any) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Set", reflect.TypeOf((*MockRoomKeyStore)(nil).Set), ctx, roomID, pair) } - -// UpdateDMParticipants mocks base method. -func (m *MockSubscriptionStore) UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateDMParticipants", ctx, roomID, uids, accounts) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateDMParticipants indicates an expected call of UpdateDMParticipants. -func (mr *MockSubscriptionStoreMockRecorder) UpdateDMParticipants(ctx, roomID, uids, accounts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDMParticipants", reflect.TypeOf((*MockSubscriptionStore)(nil).UpdateDMParticipants), ctx, roomID, uids, accounts) -} diff --git a/room-worker/store.go b/room-worker/store.go index 88e7283a7..1ea9c6591 100644 --- a/room-worker/store.go +++ b/room-worker/store.go @@ -78,11 +78,6 @@ type SubscriptionStore interface { // matching-existing-room as success-on-redelivery. CreateRoom(ctx context.Context, room *model.Room) error - // UpdateDMParticipants $sets the room's uids/accounts pair on dm/botDM - // rooms after the counterpart user has been resolved. Idempotent under - // JetStream redelivery; safe to call multiple times with the same args. - UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error - // ListNewMembersForNewRoom is the empty-roomID variant of // ListNewMembers — same dedup + bot filter, no "already-subscribed" // pruning since the room doesn't exist yet. excludeAccount drops one diff --git a/room-worker/store_mongo.go b/room-worker/store_mongo.go index 2e123bb79..228d8df9f 100644 --- a/room-worker/store_mongo.go +++ b/room-worker/store_mongo.go @@ -135,20 +135,6 @@ func (s *MongoStore) CreateRoom(ctx context.Context, room *model.Room) error { return nil } -func (s *MongoStore) UpdateDMParticipants(ctx context.Context, roomID string, uids, accounts []string) error { - res, err := s.rooms.UpdateOne(ctx, - bson.M{"_id": roomID}, - bson.M{"$set": bson.M{"uids": uids, "accounts": accounts}}, - ) - if err != nil { - return fmt.Errorf("update dm participants (room %s): %w", roomID, err) - } - if res.MatchedCount == 0 { - return fmt.Errorf("update dm participants (room %s): room not found", roomID) - } - return nil -} - func (s *MongoStore) ListNewMembersForNewRoom(ctx context.Context, orgIDs, accounts []string, excludeAccount string) ([]string, error) { pipe := pipelines.GetNewMembersPipeline(orgIDs, accounts, "", excludeAccount) pipe = append(pipe, bson.M{"$group": bson.M{ diff --git a/room-worker/sysmsg.go b/room-worker/sysmsg.go index 8891b3d85..c65c97156 100644 --- a/room-worker/sysmsg.go +++ b/room-worker/sysmsg.go @@ -7,6 +7,9 @@ import ( ) func displayName(u *model.User) string { + if u.EngName == u.ChineseName { + return strings.TrimSpace(u.EngName) + } return strings.TrimSpace(u.EngName + " " + u.ChineseName) } diff --git a/room-worker/sysmsg_test.go b/room-worker/sysmsg_test.go index ed42c95be..1f709cef1 100644 --- a/room-worker/sysmsg_test.go +++ b/room-worker/sysmsg_test.go @@ -46,6 +46,19 @@ func TestFormatLeft_TrimsEmptyNameSide(t *testing.T) { assert.Equal(t, "鮑勃 left the channel", formatLeft(&model.User{ChineseName: "鮑勃"})) } +func TestFormatLeft_EngEqualsChineseRendersOnce(t *testing.T) { + // When EngName and ChineseName are identical (e.g. account-only users), + // render the name a single time — repeating it as "Bob Bob" looks wrong. + assert.Equal(t, "Bob left the channel", formatLeft(&model.User{EngName: "Bob", ChineseName: "Bob"})) + assert.Equal(t, + "Alice 愛麗絲 added Bob to the channel", + formatAddedSingle( + &model.User{EngName: "Alice", ChineseName: "愛麗絲"}, + &model.User{EngName: "Bob", ChineseName: "Bob"}, + ), + ) +} + func TestValidateUserNames(t *testing.T) { cases := []struct { name string From c789f8517d8e9d8df270dba8d46facfb23e6eafc Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Mon, 18 May 2026 07:15:17 +0000 Subject: [PATCH 14/15] refactor(room-worker): sys-message format follow-ups for PR #185 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups from PR #185 review: 1. Drop validateUserNames + its five call sites (mliu PR comment). EngName and ChineseName are now treated as optional. displayName falls through to a single non-empty side, and finally to the Account string when both are blank — the rendered body never collapses to a bare quote pair. 2. Quote display names in every channel sys-message: `"Alice Anderson 王愛麗" added "Frank Fischer 費法蘭" to the channel` (and analogous shapes for added-multi / removed-user / removed-org / left). formatRemovedOrg quotes the SectName too. 3. Populate the sender envelope (Message.UserID + UserAccount) on the member_left / member_removed sys-messages emitted by processRemoveIndividual and processRemoveOrg. message-worker looks up the user by UserID to fill the Cassandra `sender` Participant column; previously these rows landed with sender = null. Self-leave reuses the already-fetched leaving user; forced-removal and org-removal fetch the requester via the existing store.GetUser (no new store method needed). Older Cassandra rows are intentionally not backfilled — only newly emitted messages get the populated sender. Also capitalise "A new room has been created" on the room_created sys-message body. Tests: - sysmsg_test: drop TestValidateUserNames; add TestDisplayName covering every fallback branch and TestFormatLeft_FallsBackToAccount / TestFormatAddedSingle_SingleNameSide; update existing formatter assertions for the new quoted shape. - handler_test: drop the four empty-name permanent-error tests (TestHandler_ProcessAddMembers_RequesterEmptyName, _AddedUserEmptyName, TestHandler_ProcessRemoveIndividual_EmptyName, TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent); update existing content assertions to the quoted format; mock GetUser on the requester for forced-remove / org-remove paths and assert sysMsg.UserID is set to the requester ID. Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/handler.go | 50 +++++----- room-worker/handler_test.go | 192 +++++------------------------------- room-worker/sysmsg.go | 37 ++++--- room-worker/sysmsg_test.go | 111 +++++++++------------ 4 files changed, 119 insertions(+), 271 deletions(-) diff --git a/room-worker/handler.go b/room-worker/handler.go index c633c7f88..3eea7f97a 100644 --- a/room-worker/handler.go +++ b/room-worker/handler.go @@ -333,9 +333,6 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove if err != nil { return fmt.Errorf("get user with membership: %w", err) } - if err := validateUserNames(&user.User, "user", req.RoomID); err != nil { - return err - } // room_members.member.id stores the user's internal ID, not the account. if err := h.store.DeleteRoomMember(ctx, req.RoomID, model.RoomMemberIndividual, user.ID); err != nil { @@ -423,7 +420,17 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove slog.Error("local inbox member_removed publish failed", "error", err, "roomID", req.RoomID) } - // System message + // Sys-msg sender: leaving user for self-leave, requester for forced removal. + requester := &user.User + if !isSelfLeave { + requester, err = h.store.GetUser(ctx, req.Requester) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("requester %s not found (room %s)", req.Requester, req.RoomID) + } + return fmt.Errorf("get requester: %w", err) + } + } sysMsgUser := model.SysMsgUser{ Account: user.Account, EngName: user.EngName, @@ -446,7 +453,8 @@ func (h *Handler) processRemoveIndividual(ctx context.Context, req *model.Remove sysMsg := model.Message{ ID: idgen.MessageIDFromRequestID(seed, "rmindiv"), RoomID: req.RoomID, - UserAccount: req.Requester, + UserID: requester.ID, + UserAccount: requester.Account, Type: evtType, Content: content, SysMsgData: sysMsgData, @@ -599,7 +607,14 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR } } - // System message + // Sys-msg sender is the requester. + requester, err := h.store.GetUser(ctx, req.Requester) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return newPermanent("requester %s not found (room %s)", req.Requester, req.RoomID) + } + return fmt.Errorf("get requester: %w", err) + } sysMsgPayload, _ := json.Marshal(model.MemberRemoved{ OrgID: req.OrgID, SectName: sectName, @@ -610,7 +625,8 @@ func (h *Handler) processRemoveOrg(ctx context.Context, req *model.RemoveMemberR sysMsg := model.Message{ ID: idgen.MessageIDFromRequestID(seed, "rmorg"), RoomID: req.RoomID, - UserAccount: req.Requester, + UserID: requester.ID, + UserAccount: requester.Account, Type: model.MessageTypeMemberRemoved, Content: formatRemovedOrg(sectName), SysMsgData: sysMsgPayload, @@ -718,15 +734,6 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error } } - // Validate added users' name fields before fetching the requester so that - // a cheap in-memory check on the already-fetched data short-circuits the - // extra GetUser round trip when the input is bad. - for i := range users { - if err := validateUserNames(&users[i], "user", req.RoomID); err != nil { - return err - } - } - requester, err := h.store.GetUser(ctx, req.RequesterAccount) if err != nil { if errors.Is(err, ErrUserNotFound) { @@ -734,9 +741,6 @@ func (h *Handler) processAddMembers(ctx context.Context, data []byte) (err error } return fmt.Errorf("get requester: %w", err) } - if err := validateUserNames(requester, "requester", req.RoomID); err != nil { - return err - } // acceptedAt is the stable request-acceptance time (set by room-service). // It's used for every domain-level timestamp that must survive event replay @@ -1091,9 +1095,6 @@ func (h *Handler) processCreateRoom(ctx context.Context, data []byte) (err error } return fmt.Errorf("get requester: %w", err) } - if err := validateUserNames(requester, "requester", req.RoomID); err != nil { - return err - } roomType := determineRoomTypeFromPayload(&req) acceptedAt := time.UnixMilli(req.Timestamp).UTC() @@ -1204,9 +1205,6 @@ func (h *Handler) processCreateRoomChannel(ctx context.Context, req *model.Creat userSet := make(map[string]struct{}, len(users)) for i := range users { userSet[users[i].Account] = struct{}{} - if err := validateUserNames(&users[i], "user", room.ID); err != nil { - return err - } } for _, account := range accounts { if _, ok := userSet[account]; !ok { @@ -1397,7 +1395,7 @@ func (h *Handler) publishChannelSysMessages(ctx context.Context, req *model.Crea UserID: requester.ID, UserAccount: requester.Account, Type: model.MessageTypeRoomCreated, - Content: "a new room has been created", + Content: "A new room has been created", SysMsgData: sysData1, CreatedAt: acceptedAt, } diff --git a/room-worker/handler_test.go b/room-worker/handler_test.go index 23a01dacf..097434d16 100644 --- a/room-worker/handler_test.go +++ b/room-worker/handler_test.go @@ -583,6 +583,9 @@ func TestHandler_ProcessRemoveMember_OwnerRemovesIndividual(t *testing.T) { ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) store.EXPECT(). ListByRoom(gomock.Any(), roomID).Return(nil, nil) + store.EXPECT(). + GetUser(gomock.Any(), requester). + Return(&model.User{ID: "u_alice", Account: requester, SiteID: siteID, EngName: "Alice", ChineseName: "愛"}, nil) var published []publishedMsg h := NewHandler(store, siteID, func(_ context.Context, subj string, data []byte, _ string) error { @@ -1108,6 +1111,9 @@ func TestHandler_ProcessRemoveMember_OwnerRemovesOrg(t *testing.T) { ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) // recount after removal store.EXPECT(). ListByRoom(gomock.Any(), roomID).Return(nil, nil) + store.EXPECT(). + GetUser(gomock.Any(), requester). + Return(&model.User{ID: "u_alice", Account: requester, SiteID: siteID, EngName: "Alice", ChineseName: "愛"}, nil) var published []publishedMsg h := NewHandler(store, siteID, func(_ context.Context, subj string, data []byte, _ string) error { @@ -1140,8 +1146,8 @@ func TestHandler_ProcessRemoveMember_OwnerRemovesOrg(t *testing.T) { // undetected. sysMsg := findSysMsg(t, published, siteID, model.MessageTypeMemberRemoved) assert.Equal(t, requester, sysMsg.UserAccount, "sender envelope must be set to requester") - assert.Empty(t, sysMsg.UserID, "UserID must stay empty per spec §2.4") - assert.Equal(t, "Engineering has been removed from the channel", sysMsg.Content) + assert.Equal(t, "u_alice", sysMsg.UserID, "UserID set to requester so message-worker can populate Cassandra sender column") + assert.Equal(t, `"Engineering" has been removed from the channel`, sysMsg.Content) } func TestHandler_ProcessRemoveMember_CrossSiteOutbox(t *testing.T) { @@ -1438,6 +1444,8 @@ func TestHandler_ProcessRemoveOrg_OutboxFailurePropagates(t *testing.T) { store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, orgID).Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) store.EXPECT().ListByRoom(gomock.Any(), roomID).Return(nil, nil) + store.EXPECT().GetUser(gomock.Any(), requester). + Return(&model.User{ID: "u_alice", Account: requester, SiteID: localSite, EngName: "Alice", ChineseName: "愛"}, nil) outboxSubj := subject.Outbox(localSite, remoteSite, "member_removed") publish := func(_ context.Context, subj string, _ []byte, _ string) error { @@ -3424,6 +3432,8 @@ func TestProcessRemoveMember_SkipsRotationWhenValkeyAlreadyAhead(t *testing.T) { store.EXPECT().DeleteRoomMember(gomock.Any(), "r1", model.RoomMemberIndividual, "u-bob").Return(nil) store.EXPECT().DeleteSubscription(gomock.Any(), "r1", "bob").Return(int64(1), nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), "r1").Return(nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u-alice", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) h := NewHandler(store, "site-a", func(_ context.Context, _ string, _ []byte, _ string) error { return nil }, keyStore, testKeySender) req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", BaseKeyVersion: 5, RoomType: model.RoomTypeChannel} @@ -3732,79 +3742,6 @@ func TestHandler_ProcessAddMembers_RequesterNotFound(t *testing.T) { assert.NotContains(t, result.Error, ": permanent") } -// D2: requester has empty EngName → permanent error. -func TestHandler_ProcessAddMembers_RequesterEmptyName(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockSubscriptionStore(ctrl) - - roomID := "r1" - store.EXPECT().GetRoom(gomock.Any(), roomID). - Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) - store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). - Return([]string{"u1"}, nil) - store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). - Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: "一"}}, nil) - store.EXPECT().GetUser(gomock.Any(), "alice"). - Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "", ChineseName: "愛"}, nil) - - var published []publishedMsg - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { - published = append(published, publishedMsg{subj: subj, data: data}) - return nil - }, keyStore: testKeyStore, keySender: testKeySender} - req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} - data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), testRequestID) - - err := h.processAddMembers(ctx, data) - require.Error(t, err) - assert.ErrorIs(t, err, errPermanent) - - responses := userResponseFor(published, "alice") - require.NotEmpty(t, responses, "permanent error must publish async-job error event") - var result model.AsyncJobResult - require.NoError(t, json.Unmarshal(responses[0].data, &result)) - assert.Equal(t, model.AsyncJobStatusError, result.Status) - assert.Contains(t, result.Error, "missing required name fields") - assert.NotContains(t, result.Error, ": permanent") -} - -// D3: added user has empty ChineseName → permanent error. -func TestHandler_ProcessAddMembers_AddedUserEmptyName(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockSubscriptionStore(ctrl) - - roomID := "r1" - store.EXPECT().GetRoom(gomock.Any(), roomID). - Return(&model.Room{ID: roomID, Name: "Chan", SiteID: "site-a", Type: model.RoomTypeChannel}, nil) - store.EXPECT().ListNewMembers(gomock.Any(), []string(nil), []string{"u1"}, roomID). - Return([]string{"u1"}, nil) - store.EXPECT().FindUsersByAccounts(gomock.Any(), []string{"u1"}). - Return([]model.User{{ID: "u1_id", Account: "u1", SiteID: "site-a", EngName: "U1", ChineseName: ""}}, nil) - // Validation for added users should fire before requester fetch — do not mock GetUser here. - - var published []publishedMsg - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { - published = append(published, publishedMsg{subj: subj, data: data}) - return nil - }, keyStore: testKeyStore, keySender: testKeySender} - req := model.AddMembersRequest{RoomID: roomID, RequesterID: "u_a", RequesterAccount: "alice", Users: []string{"u1"}, Timestamp: 1} - data, _ := json.Marshal(req) - ctx := natsutil.WithRequestID(context.Background(), testRequestID) - - err := h.processAddMembers(ctx, data) - require.Error(t, err) - assert.ErrorIs(t, err, errPermanent) - - responses := userResponseFor(published, "alice") - require.NotEmpty(t, responses, "permanent error must publish async-job error event") - var result model.AsyncJobResult - require.NoError(t, json.Unmarshal(responses[0].data, &result)) - assert.Equal(t, model.AsyncJobStatusError, result.Status) - assert.Contains(t, result.Error, "missing required name fields") - assert.NotContains(t, result.Error, ": permanent") -} - // findSysMsg locates the system message published on MsgCanonicalCreated for // the given site with the requested Type. Fails the test if no such publish // occurred. @@ -3861,7 +3798,7 @@ func TestHandler_ProcessAddMembers_Content_Single(t *testing.T) { require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") - assert.Equal(t, "Alice 愛 added U1 一 to the channel", sysMsg.Content) + assert.Equal(t, `"Alice 愛" added "U1 一" to the channel`, sysMsg.Content) } // B2: len(subs)>=2 → multi form. @@ -3900,7 +3837,7 @@ func TestHandler_ProcessAddMembers_Content_Multi(t *testing.T) { require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") - assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) + assert.Equal(t, `"Alice 愛" added members to the channel`, sysMsg.Content) } // B3: create-room channel publishes members_added with always-multi form. @@ -3921,7 +3858,7 @@ func TestHandler_PublishChannelSysMessages_MembersAddedContent(t *testing.T) { require.NoError(t, h.publishChannelSysMessages(context.Background(), req, room, requester, 2, "req-1", time.UnixMilli(1).UTC())) sysMsg := findSysMsg(t, published, "site-a", model.MessageTypeMembersAdded) - assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content) + assert.Equal(t, `"Alice 愛" added members to the channel`, sysMsg.Content) } // C1: self-leave full removal → member_left with sender + Content. @@ -3951,8 +3888,8 @@ func TestHandler_ProcessRemoveIndividual_SelfLeave_Content(t *testing.T) { sysMsg := findSysMsg(t, published, "site-a", "member_left") assert.Equal(t, "bob", sysMsg.UserAccount) - assert.Empty(t, sysMsg.UserID, "UserID must stay empty per spec §2.4") - assert.Equal(t, "Bob 鮑 left the channel", sysMsg.Content) + assert.Equal(t, "u_b", sysMsg.UserID, "self-leave reuses leaving-user's ID as sender") + assert.Equal(t, `"Bob 鮑" left the channel`, sysMsg.Content) } // C2: removed-by-other full removal → member_removed with sender + Content. @@ -3968,6 +3905,8 @@ func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) { store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberIndividual, "u_b").Return(nil) store.EXPECT().DeleteSubscription(gomock.Any(), roomID, "bob").Return(int64(1), nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) var published []publishedMsg h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { @@ -3980,41 +3919,8 @@ func TestHandler_ProcessRemoveIndividual_RemovedByOther_Content(t *testing.T) { sysMsg := findSysMsg(t, published, "site-a", "member_removed") assert.Equal(t, "alice", sysMsg.UserAccount) - assert.Empty(t, sysMsg.UserID) - assert.Equal(t, "Bob 鮑 has been removed from the channel", sysMsg.Content) -} - -// D4: target user has empty ChineseName → permanent error. Deferred -// async-job result must surface a sanitized error to the requester. -func TestHandler_ProcessRemoveIndividual_EmptyName(t *testing.T) { - ctrl := gomock.NewController(t) - store := NewMockSubscriptionStore(ctrl) - - store.EXPECT().GetUserWithMembership(gomock.Any(), "r1", "bob"). - Return(&UserWithMembership{ - User: model.User{ID: "u_b", Account: "bob", SiteID: "site-a", EngName: "Bob"}, - }, nil) - // No other mocks expected — empty-name validation must return BEFORE delete/publish. - - var published []publishedMsg - h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { - published = append(published, publishedMsg{subj: subj, data: data}) - return nil - }, keyStore: testKeyStore, keySender: testKeySender} - req := model.RemoveMemberRequest{RoomID: "r1", Requester: "alice", Account: "bob", Timestamp: 1} - ctx := natsutil.WithRequestID(context.Background(), testRequestID) - - err := h.processRemoveIndividual(ctx, &req, nil, false) - require.Error(t, err) - assert.ErrorIs(t, err, errPermanent) - - responses := userResponseFor(published, "alice") - require.NotEmpty(t, responses, "permanent error must publish async-job error event") - var result model.AsyncJobResult - require.NoError(t, json.Unmarshal(responses[0].data, &result)) - assert.Equal(t, model.AsyncJobStatusError, result.Status) - assert.Contains(t, result.Error, "missing required name fields") - assert.NotContains(t, result.Error, ": permanent") + assert.Equal(t, "u_a", sysMsg.UserID, "forced removal sets sender to requester") + assert.Equal(t, `"Bob 鮑" has been removed from the channel`, sysMsg.Content) } // C3: org remove with every member also having individual subs (toRemove empty) @@ -4032,6 +3938,8 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T // toRemove is empty → no DeleteSubscriptionsByAccounts call expected. store.EXPECT().DeleteRoomMember(gomock.Any(), roomID, model.RoomMemberOrg, "o1").Return(nil) store.EXPECT().ReconcileMemberCounts(gomock.Any(), roomID).Return(nil) + store.EXPECT().GetUser(gomock.Any(), "alice"). + Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-a", EngName: "Alice", ChineseName: "愛"}, nil) var published []publishedMsg h := &Handler{store: store, siteID: "site-a", publish: func(_ context.Context, subj string, data []byte, _ string) error { @@ -4044,8 +3952,8 @@ func TestHandler_ProcessRemoveOrg_AllOverlap_SectNameFromUnfiltered(t *testing.T sysMsg := findSysMsg(t, published, "site-a", "member_removed") assert.Equal(t, "alice", sysMsg.UserAccount) - assert.Empty(t, sysMsg.UserID) - assert.Equal(t, "Engineering has been removed from the channel", sysMsg.Content) + assert.Equal(t, "u_a", sysMsg.UserID, "org removal sets sender to requester") + assert.Equal(t, `"Engineering" has been removed from the channel`, sysMsg.Content) } // D5: every member SectName empty → permanent error. The deferred @@ -4082,54 +3990,6 @@ func TestHandler_ProcessRemoveOrg_AllSectNamesEmpty(t *testing.T) { assert.NotContains(t, result.Error, ": permanent") } -// Requester-name validation in processCreateRoom: §2.3 promises Content is -// well-formed on every channel sys-message, but publishChannelSysMessages -// calls formatAddedMulti(requester) which renders a malformed body when the -// requester has empty EngName/ChineseName. Validate immediately after fetch -// so the path bails before CreateRoom even runs. -func TestProcessCreateRoom_RequesterEmptyName_ReturnsPermanent(t *testing.T) { - cases := []struct { - name string - eng string - chinese string - wantText string - }{ - {name: "empty EngName", eng: "", chinese: "愛", wantText: "requester alice missing required name fields"}, - {name: "empty ChineseName", eng: "Alice", chinese: "", wantText: "requester alice missing required name fields"}, - {name: "both empty", eng: "", chinese: "", wantText: "requester alice missing required name fields"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - h, mockStore, getPublished := newCreateRoomTestHandler(t) - ctx := natsutil.WithRequestID(context.Background(), testRequestID) - - mockStore.EXPECT().GetUser(gomock.Any(), "alice"). - Return(&model.User{ID: "u_a", Account: "alice", SiteID: "site-A", EngName: tc.eng, ChineseName: tc.chinese}, nil) - // CreateRoom MUST NOT be called — validation short-circuits first. - - body := makeCreateRoomBody(t, &model.CreateRoomRequest{ - RoomID: "r-empty-name", - RequesterAccount: "alice", - Users: []string{"bob"}, - Timestamp: time.Now().UnixMilli(), - }) - - err := h.processCreateRoom(ctx, body) - require.Error(t, err) - assert.ErrorIs(t, err, errPermanent) - assert.Contains(t, err.Error(), tc.wantText) - - // Async-job error event must be published via the defer. - responses := userResponseFor(getPublished(), "alice") - require.NotEmpty(t, responses, "permanent error must publish async-job error event") - var result model.AsyncJobResult - require.NoError(t, json.Unmarshal(responses[0].data, &result)) - assert.Equal(t, model.AsyncJobStatusError, result.Status) - assert.Contains(t, result.Error, tc.wantText) - }) - } -} - // F1: async DM create sets UIDs/Accounts sorted by UID, paired by index, on // the initial CreateRoom insert (single Mongo write, no follow-up update). func TestProcessCreateRoom_DM_SetsParticipantFields(t *testing.T) { @@ -4309,7 +4169,7 @@ func TestHandler_ProcessAddMembers_Content_OrgAddWithOneMember_UsesMulti(t *test require.NoError(t, h.processAddMembers(ctx, data)) sysMsg := findSysMsg(t, published, "site-a", "members_added") - assert.Equal(t, "Alice 愛 added members to the channel", sysMsg.Content, + assert.Equal(t, `"Alice 愛" added members to the channel`, sysMsg.Content, "org-add must use multi form even when org expands to a single user") } diff --git a/room-worker/sysmsg.go b/room-worker/sysmsg.go index c65c97156..576322acd 100644 --- a/room-worker/sysmsg.go +++ b/room-worker/sysmsg.go @@ -6,39 +6,44 @@ import ( "github.com/hmchangw/chat/pkg/model" ) +// displayName falls back to Account when both name fields are empty. func displayName(u *model.User) string { - if u.EngName == u.ChineseName { - return strings.TrimSpace(u.EngName) + eng := strings.TrimSpace(u.EngName) + chinese := strings.TrimSpace(u.ChineseName) + switch { + case eng == "" && chinese == "": + return u.Account + case eng == "": + return chinese + case chinese == "": + return eng + case eng == chinese: + return eng + default: + return eng + " " + chinese } - return strings.TrimSpace(u.EngName + " " + u.ChineseName) } -// validateUserNames returns a permanent error when u lacks the EngName/ -// ChineseName fields required to render system-message Content. role labels -// the caller's relationship to u ("user", "requester") in the error message. -func validateUserNames(u *model.User, role, roomID string) error { - if u.EngName == "" || u.ChineseName == "" { - return newPermanent("%s %s missing required name fields (room %s)", role, u.Account, roomID) - } - return nil +func quoted(name string) string { + return "\"" + name + "\"" } func formatAddedSingle(requester, added *model.User) string { - return displayName(requester) + " added " + displayName(added) + " to the channel" + return quoted(displayName(requester)) + " added " + quoted(displayName(added)) + " to the channel" } func formatAddedMulti(requester *model.User) string { - return displayName(requester) + " added members to the channel" + return quoted(displayName(requester)) + " added members to the channel" } func formatRemovedUser(user *model.User) string { - return displayName(user) + " has been removed from the channel" + return quoted(displayName(user)) + " has been removed from the channel" } func formatRemovedOrg(sectName string) string { - return sectName + " has been removed from the channel" + return quoted(sectName) + " has been removed from the channel" } func formatLeft(user *model.User) string { - return displayName(user) + " left the channel" + return quoted(displayName(user)) + " left the channel" } diff --git a/room-worker/sysmsg_test.go b/room-worker/sysmsg_test.go index 1f709cef1..3efc34b40 100644 --- a/room-worker/sysmsg_test.go +++ b/room-worker/sysmsg_test.go @@ -1,11 +1,9 @@ package main import ( - "errors" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/hmchangw/chat/pkg/model" ) @@ -15,100 +13,87 @@ func TestFormatAddedSingle(t *testing.T) { &model.User{EngName: "Alice", ChineseName: "愛麗絲"}, &model.User{EngName: "Bob", ChineseName: "鮑勃"}, ) - assert.Equal(t, "Alice 愛麗絲 added Bob 鮑勃 to the channel", got) + assert.Equal(t, `"Alice 愛麗絲" added "Bob 鮑勃" to the channel`, got) } func TestFormatAddedMulti(t *testing.T) { got := formatAddedMulti(&model.User{EngName: "Alice", ChineseName: "愛麗絲"}) - assert.Equal(t, "Alice 愛麗絲 added members to the channel", got) + assert.Equal(t, `"Alice 愛麗絲" added members to the channel`, got) } func TestFormatRemovedUser(t *testing.T) { got := formatRemovedUser(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) - assert.Equal(t, "Bob 鮑勃 has been removed from the channel", got) + assert.Equal(t, `"Bob 鮑勃" has been removed from the channel`, got) } func TestFormatRemovedOrg(t *testing.T) { got := formatRemovedOrg("Engineering") - assert.Equal(t, "Engineering has been removed from the channel", got) + assert.Equal(t, `"Engineering" has been removed from the channel`, got) } func TestFormatLeft(t *testing.T) { got := formatLeft(&model.User{EngName: "Bob", ChineseName: "鮑勃"}) - assert.Equal(t, "Bob 鮑勃 left the channel", got) + assert.Equal(t, `"Bob 鮑勃" left the channel`, got) } -func TestFormatLeft_TrimsEmptyNameSide(t *testing.T) { - // Spec §2.6: TrimSpace(EngName + " " + ChineseName) — when one side is empty, - // the result still has no leading/trailing whitespace. Callers must reject - // fully-empty inputs upstream; this test pins the trim behavior only. - assert.Equal(t, "Bob left the channel", formatLeft(&model.User{EngName: "Bob"})) - assert.Equal(t, "鮑勃 left the channel", formatLeft(&model.User{ChineseName: "鮑勃"})) -} - -func TestFormatLeft_EngEqualsChineseRendersOnce(t *testing.T) { - // When EngName and ChineseName are identical (e.g. account-only users), - // render the name a single time — repeating it as "Bob Bob" looks wrong. - assert.Equal(t, "Bob left the channel", formatLeft(&model.User{EngName: "Bob", ChineseName: "Bob"})) - assert.Equal(t, - "Alice 愛麗絲 added Bob to the channel", - formatAddedSingle( - &model.User{EngName: "Alice", ChineseName: "愛麗絲"}, - &model.User{EngName: "Bob", ChineseName: "Bob"}, - ), - ) -} - -func TestValidateUserNames(t *testing.T) { +func TestDisplayName(t *testing.T) { cases := []struct { - name string - user model.User - role string - roomID string - wantErr bool - wantMsg string + name string + user model.User + want string }{ { - name: "both names set", - user: model.User{Account: "alice", EngName: "Alice", ChineseName: "愛"}, - role: "user", roomID: "r1", - wantErr: false, + name: "both names set — concatenated", + user: model.User{Account: "alice", EngName: "Alice", ChineseName: "愛麗絲"}, + want: "Alice 愛麗絲", + }, + { + name: "only EngName — use it", + user: model.User{Account: "alice", EngName: "Alice"}, + want: "Alice", }, { - name: "empty EngName", - user: model.User{Account: "bob", EngName: "", ChineseName: "鮑"}, - role: "user", roomID: "r1", - wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + name: "only ChineseName — use it", + user: model.User{Account: "alice", ChineseName: "愛麗絲"}, + want: "愛麗絲", }, { - name: "empty ChineseName", - user: model.User{Account: "bob", EngName: "Bob", ChineseName: ""}, - role: "user", roomID: "r1", - wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + name: "EngName equals ChineseName — render once", + user: model.User{Account: "alice", EngName: "Bob", ChineseName: "Bob"}, + want: "Bob", }, { - name: "both empty", - user: model.User{Account: "bob", EngName: "", ChineseName: ""}, - role: "user", roomID: "r1", - wantErr: true, wantMsg: "user bob missing required name fields (room r1)", + name: "both empty — fall back to Account", + user: model.User{Account: "alice"}, + want: "alice", }, { - name: "requester role label propagated", - user: model.User{Account: "alice", EngName: ""}, - role: "requester", roomID: "r2", - wantErr: true, wantMsg: "requester alice missing required name fields (room r2)", + name: "whitespace-only names — fall back to Account", + user: model.User{Account: "alice", EngName: " ", ChineseName: "\t"}, + want: "alice", + }, + { + name: "leading/trailing whitespace trimmed on each side", + user: model.User{Account: "alice", EngName: " Alice ", ChineseName: " 愛 "}, + want: "Alice 愛", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - err := validateUserNames(&tc.user, tc.role, tc.roomID) - if !tc.wantErr { - require.NoError(t, err) - return - } - require.Error(t, err) - assert.True(t, errors.Is(err, errPermanent), "validation failure must be permanent") - assert.Equal(t, tc.wantMsg, err.Error()) + assert.Equal(t, tc.want, displayName(&tc.user)) }) } } + +func TestFormatLeft_FallsBackToAccount(t *testing.T) { + got := formatLeft(&model.User{Account: "alice"}) + assert.Equal(t, `"alice" left the channel`, got) +} + +func TestFormatAddedSingle_SingleNameSide(t *testing.T) { + got := formatAddedSingle( + &model.User{EngName: "Alice"}, + &model.User{ChineseName: "鮑勃"}, + ) + assert.Equal(t, `"Alice" added "鮑勃" to the channel`, got) +} From af22b6303fbe9a5377e13e115989f00715dd9783 Mon Sep 17 00:00:00 2001 From: Vinayak Jauhari Date: Mon, 18 May 2026 07:21:31 +0000 Subject: [PATCH 15/15] test(room-worker): update integration test assertions for quoted sys-msg shape Two integration assertions still expected the unquoted sys-message bodies; mirror the change from the prior commit so the live MongoDB+Cassandra container test agrees with the in-memory handler tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- room-worker/integration_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/room-worker/integration_test.go b/room-worker/integration_test.go index 4a0c7e63f..18488bc31 100644 --- a/room-worker/integration_test.go +++ b/room-worker/integration_test.go @@ -935,7 +935,7 @@ func TestProcessAddMembers_PublishesLocalInbox_Integration(t *testing.T) { require.NoError(t, json.Unmarshal(sysPubs[0].data, &sysEvt)) assert.Equal(t, model.MessageTypeMembersAdded, sysEvt.Message.Type) assert.Equal(t, "alice", sysEvt.Message.UserAccount, "sender is the requester") - assert.Equal(t, "Alice 爱丽丝 added members to the channel", sysEvt.Message.Content, + assert.Equal(t, `"Alice 爱丽丝" added members to the channel`, sysEvt.Message.Content, "multi-add Content uses formatAddedMulti(requester)") } @@ -1001,7 +1001,7 @@ func TestProcessRemoveIndividual_PublishesLocalInbox_Integration(t *testing.T) { require.NoError(t, json.Unmarshal(sysPubs[0].data, &sysEvt)) assert.Equal(t, model.MessageTypeMemberRemoved, sysEvt.Message.Type) assert.Equal(t, "alice", sysEvt.Message.UserAccount, "sender is the requester, not the removed user") - assert.Equal(t, "Bob 鲍勃 has been removed from the channel", sysEvt.Message.Content, + assert.Equal(t, `"Bob 鲍勃" has been removed from the channel`, sysEvt.Message.Content, "forced-remove Content uses formatRemovedUser(user)") }