agi.sharedspace_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. package agi
  2. import (
  3. "path/filepath"
  4. "reflect"
  5. "sort"
  6. "testing"
  7. "github.com/robertkrimen/otto"
  8. "imuslab.com/arozos/mod/agi/static"
  9. notification "imuslab.com/arozos/mod/notification"
  10. "imuslab.com/arozos/mod/sharedspace"
  11. user "imuslab.com/arozos/mod/user"
  12. )
  13. func TestResolveNotifyRecipients(t *testing.T) {
  14. members := map[string]string{
  15. "alice": "owner", "bob": "member", "carol": "member", "dave": "member",
  16. }
  17. tests := []struct {
  18. name string
  19. present map[string]bool
  20. sender string
  21. requested []string
  22. want []string
  23. }{
  24. {
  25. name: "all members except the sender",
  26. sender: "alice",
  27. want: []string{"bob", "carol", "dave"},
  28. },
  29. {
  30. name: "members present in the space are skipped",
  31. sender: "alice",
  32. present: map[string]bool{"bob": true},
  33. want: []string{"carol", "dave"},
  34. },
  35. {
  36. name: "an explicit list narrows the recipients",
  37. sender: "alice",
  38. requested: []string{"bob", "dave"},
  39. want: []string{"bob", "dave"},
  40. },
  41. {
  42. name: "explicit targets are intersected with members",
  43. sender: "alice",
  44. requested: []string{"bob", "stranger"},
  45. want: []string{"bob"},
  46. },
  47. {
  48. name: "sender and present users are dropped from an explicit list",
  49. sender: "alice",
  50. present: map[string]bool{"dave": true},
  51. requested: []string{"alice", "bob", "dave"},
  52. want: []string{"bob"},
  53. },
  54. {
  55. name: "an empty explicit list notifies nobody",
  56. sender: "alice",
  57. requested: []string{},
  58. want: []string{},
  59. },
  60. }
  61. for _, tt := range tests {
  62. t.Run(tt.name, func(t *testing.T) {
  63. got := resolveNotifyRecipients(members, tt.present, tt.sender, tt.requested)
  64. if !reflect.DeepEqual(got, tt.want) {
  65. t.Errorf("resolveNotifyRecipients() = %v, want %v", got, tt.want)
  66. }
  67. })
  68. }
  69. }
  70. // TestNotifyMembersIntegration drives the injected sharedspace.notifyMembers
  71. // JS function through a real Otto VM against a live space, exercising the full
  72. // path (argument parsing, membership check, presence skip, delivery).
  73. func TestNotifyMembersIntegration(t *testing.T) {
  74. sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
  75. space, err := sm.CreateSpaceWithOptions("alice", "team", sharedspace.SpaceOptions{
  76. Access: sharedspace.AccessPrivate,
  77. })
  78. if err != nil {
  79. t.Fatalf("CreateSpaceWithOptions() error = %v", err)
  80. }
  81. space.AddMember("alice", "bob", sharedspace.RoleMember)
  82. space.AddMember("alice", "carol", sharedspace.RoleMember)
  83. var captured []*notification.NotificationPayload
  84. g := &Gateway{Option: &AgiSysInfo{
  85. SharedSpaceManager: sm,
  86. NotificationSender: func(p *notification.NotificationPayload) error {
  87. captured = append(captured, p)
  88. return nil
  89. },
  90. }}
  91. run := func(username, script string) otto.Value {
  92. vm := otto.New()
  93. g.injectSharedSpaceFunctions(&static.AgiLibInjectionPayload{
  94. VM: vm,
  95. User: &user.User{Username: username},
  96. ScriptPath: "./web/Chatspace/backend/notify.js",
  97. })
  98. v, runErr := vm.Run(script)
  99. if runErr != nil {
  100. t.Fatalf("vm.Run(%q) error = %v", script, runErr)
  101. }
  102. return v
  103. }
  104. // A member notifies every other member; the sender label is the module root.
  105. captured = nil
  106. v := run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi team")`)
  107. if n, _ := v.ToInteger(); n != 2 {
  108. t.Fatalf("notified = %d, want 2", n)
  109. }
  110. if len(captured) != 1 {
  111. t.Fatalf("sender called %d times, want 1", len(captured))
  112. }
  113. got := append([]string{}, captured[0].Receiver...)
  114. sort.Strings(got)
  115. if !reflect.DeepEqual(got, []string{"bob", "carol"}) {
  116. t.Errorf("receivers = %v, want [bob carol]", got)
  117. }
  118. if captured[0].Sender != "Chatspace" {
  119. t.Errorf("sender label = %q, want Chatspace", captured[0].Sender)
  120. }
  121. if captured[0].Message != "hi team" {
  122. t.Errorf("message = %q, want 'hi team'", captured[0].Message)
  123. }
  124. // Members currently connected to the space are skipped (they get it live).
  125. captured = nil
  126. space.Channel().Join("carol")
  127. v = run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi")`)
  128. if n, _ := v.ToInteger(); n != 1 {
  129. t.Fatalf("notified = %d, want 1 (carol is present)", n)
  130. }
  131. if len(captured) != 1 || len(captured[0].Receiver) != 1 || captured[0].Receiver[0] != "bob" {
  132. t.Errorf("receivers = %v, want [bob]", captured[0].Receiver)
  133. }
  134. // An explicit target list is intersected with the members.
  135. captured = nil
  136. v = run("alice", `sharedspace.notifyMembers("`+space.ID+`", "alice", "hi", "high", ["bob","stranger"])`)
  137. if n, _ := v.ToInteger(); n != 1 {
  138. t.Fatalf("notified = %d, want 1 (only bob is a member)", n)
  139. }
  140. if captured[0].Priority != notification.PriorityHigh {
  141. t.Errorf("priority = %d, want high", captured[0].Priority)
  142. }
  143. // A non-member cannot raise notifications for the space.
  144. captured = nil
  145. v = run("dave", `sharedspace.notifyMembers("`+space.ID+`", "dave", "hi")`)
  146. if n, _ := v.ToInteger(); n != -1 {
  147. t.Errorf("non-member notified = %d, want -1", n)
  148. }
  149. if len(captured) != 0 {
  150. t.Errorf("non-member must not send; captured %d notifications", len(captured))
  151. }
  152. }
  153. func TestAgiDescribeSpaceAdvancedFields(t *testing.T) {
  154. sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
  155. space, err := sm.CreateSpaceWithOptions("alice", "Project room", sharedspace.SpaceOptions{
  156. Access: sharedspace.AccessPublic,
  157. Metadata: map[string]string{"purpose": "planning"},
  158. })
  159. if err != nil {
  160. t.Fatalf("CreateSpaceWithOptions() error = %v", err)
  161. }
  162. space.AddMember("alice", "bob", sharedspace.RoleMember)
  163. space.CreateDoc("alice", "spec")
  164. desc := agiDescribeSpace(space)
  165. if desc["access"] != sharedspace.AccessPublic {
  166. t.Errorf("access = %v, want public", desc["access"])
  167. }
  168. if desc["persistent"] != false {
  169. t.Errorf("persistent = %v, want false", desc["persistent"])
  170. }
  171. if desc["members"] != 2 {
  172. t.Errorf("members = %v, want 2", desc["members"])
  173. }
  174. if desc["docs"] != 1 {
  175. t.Errorf("docs = %v, want 1", desc["docs"])
  176. }
  177. metadata, ok := desc["metadata"].(map[string]string)
  178. if !ok || metadata["purpose"] != "planning" {
  179. t.Errorf("metadata = %v", desc["metadata"])
  180. }
  181. }
  182. func TestAgiDescribeDoc(t *testing.T) {
  183. sm := sharedspace.NewManager(filepath.Join(t.TempDir(), "spaces"), 0)
  184. space := sm.CreateSpace("alice", "")
  185. doc, err := space.CreateDoc("alice", "notes")
  186. if err != nil {
  187. t.Fatalf("CreateDoc() error = %v", err)
  188. }
  189. space.UpdateDoc("bob", doc.ID, 1, "content body")
  190. snapshot, _ := space.GetDoc(doc.ID)
  191. withContent := agiDescribeDoc(snapshot, true)
  192. if withContent["docid"] != doc.ID || withContent["revision"] != int64(2) {
  193. t.Errorf("doc description = %+v", withContent)
  194. }
  195. if withContent["content"] != "content body" || withContent["updatedby"] != "bob" {
  196. t.Errorf("doc content fields = %+v", withContent)
  197. }
  198. withoutContent := agiDescribeDoc(snapshot, false)
  199. if _, leaked := withoutContent["content"]; leaked {
  200. t.Errorf("content leaked into the no-content description")
  201. }
  202. }