@quenty/grouputils 10.22.0 → 10.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [10.23.1](https://github.com/Quenty/NevermoreEngine/compare/@quenty/grouputils@10.23.0...@quenty/grouputils@10.23.1) (2026-07-23)
7
+
8
+ **Note:** Version bump only for package @quenty/grouputils
9
+
10
+ # [10.23.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/grouputils@10.22.0...@quenty/grouputils@10.23.0) (2026-07-23)
11
+
12
+ ### Features
13
+
14
+ - Add baseline player-mock and support across Nevermore for mocked players. ([567d121](https://github.com/Quenty/NevermoreEngine/commit/567d121ffc014b42391554088189a1a6296dda83))
15
+
6
16
  # [10.22.0](https://github.com/Quenty/NevermoreEngine/compare/@quenty/grouputils@10.21.0...@quenty/grouputils@10.22.0) (2026-07-18)
7
17
 
8
18
  **Note:** Version bump only for package @quenty/grouputils
@@ -0,0 +1,10 @@
1
+ {
2
+ "targets": {
3
+ "test": {
4
+ "universeId": 9716264427,
5
+ "placeId": 116768249745894,
6
+ "project": "test/default.project.json",
7
+ "scriptTemplate": "test/scripts/Server/ServerMain.server.lua"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/grouputils",
3
- "version": "10.22.0",
3
+ "version": "10.23.1",
4
4
  "description": "Group utility functions for Roblox",
5
5
  "keywords": [
6
6
  "Roblox",
@@ -30,10 +30,13 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@quenty/loader": "10.11.0",
33
- "@quenty/promise": "10.20.0"
33
+ "@quenty/nevermore-test-runner": "1.5.0",
34
+ "@quenty/playermock": "1.1.1",
35
+ "@quenty/promise": "10.21.0",
36
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
34
37
  },
35
38
  "publishConfig": {
36
39
  "access": "public"
37
40
  },
38
- "gitHead": "647acfad93dca41c38f59cda247d2bfc0fff36d4"
41
+ "gitHead": "5ea0583dedebba6492f9ac6d719d32b45c5543fa"
39
42
  }
@@ -0,0 +1,66 @@
1
+ --!strict
2
+ --[=[
3
+ Helpers for injecting group membership onto a [PlayerMock] in tests.
4
+
5
+ [GroupUtils] answers rank and role from two engine calls -- `GetRolesInGroupAsync`
6
+ (primary) and `GetGroupsAsync` (fallback) -- and only those calls are intercepted for
7
+ mocks, so its parsing and fallback logic runs for real in tests. [GroupTestUtils.assignGroupInfo]
8
+ writes both injected results from one rank/role pair so the answer is coherent whichever
9
+ path production code takes.
10
+
11
+ @class GroupTestUtils
12
+ ]=]
13
+
14
+ local require = require(script.Parent.loader).load(script)
15
+
16
+ local PlayerMock = require("PlayerMock")
17
+
18
+ local GroupTestUtils = {}
19
+
20
+ export type AssignedGroupInfo = {
21
+ rank: number,
22
+ role: string,
23
+ }
24
+
25
+ --[=[
26
+ Injects membership in a group for a mock at both engine calls [GroupUtils] reads, replacing
27
+ any earlier assignment for the same group. Assign multiple groups by calling once per group.
28
+
29
+ ```lua
30
+ GroupTestUtils.assignGroupInfo(playerMock, 372, { rank = 230, role = "Admin" })
31
+ -- GroupUtils.promiseRankInGroup(playerMock, 372) resolves 230,
32
+ -- promiseRoleInGroup(playerMock, 372) resolves "Admin"
33
+ ```
34
+
35
+ @param player Player -- must be a PlayerMock
36
+ @param groupId number
37
+ @param groupInfo { rank: number, role: string }
38
+ ]=]
39
+ function GroupTestUtils.assignGroupInfo(player: Player, groupId: number, groupInfo: AssignedGroupInfo)
40
+ assert(PlayerMock.isMock(player), "Not a PlayerMock")
41
+ assert(type(groupId) == "number", "Bad groupId")
42
+ assert(type(groupInfo) == "table", "Bad groupInfo")
43
+ assert(type(groupInfo.rank) == "number", "Bad groupInfo.rank")
44
+ assert(type(groupInfo.role) == "string", "Bad groupInfo.role")
45
+
46
+ PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", groupId, {
47
+ IsMember = true,
48
+ Roles = { { Name = groupInfo.role, Rank = groupInfo.rank } },
49
+ })
50
+
51
+ local groups = PlayerMock.readLookup(player, "GroupService.GetGroupsAsync", 0)
52
+ local updated = {}
53
+ for _, existing in groups do
54
+ if existing.Id ~= groupId then
55
+ table.insert(updated, existing)
56
+ end
57
+ end
58
+ table.insert(updated, {
59
+ Id = groupId,
60
+ Rank = groupInfo.rank,
61
+ Role = groupInfo.role,
62
+ })
63
+ PlayerMock.writeLookup(player, "GroupService.GetGroupsAsync", 0, updated)
64
+ end
65
+
66
+ return GroupTestUtils
@@ -9,6 +9,7 @@ local require = require(script.Parent.loader).load(script)
9
9
 
10
10
  local GroupService = game:GetService("GroupService")
11
11
 
12
+ local PlayerMock = require("PlayerMock")
12
13
  local Promise = require("Promise")
13
14
 
14
15
  local GroupUtils = {}
@@ -48,17 +49,34 @@ type GetGroupDictionary = {
48
49
  }
49
50
  type GetGroupsAsyncResult = { GetGroupDictionary }
50
51
 
51
- local function _getRankAndRoleFallback(userId: number, groupId: number): (number, string?)
52
+ -- The mock interception point is the engine call itself (see [PlayerMock.writeLookup]), not the
53
+ -- promise wrappers below -- so the highest-role scan, fallback ordering, and reject paths all
54
+ -- run for mocks too. A PlayerMock is in no real Roblox group, so answering from the injected
55
+ -- result avoids the (flaky, fake-UserId) group lookup.
56
+ local function _getRolesInGroupAsync(player: Player, groupId: number): GetRolesInGroupAsyncResult
57
+ if PlayerMock.isMock(player) then
58
+ return PlayerMock.readLookup(player, "GroupService.GetRolesInGroupAsync", groupId)
59
+ end
60
+
61
+ return GroupService:GetRolesInGroupAsync(player.UserId, groupId) :: GetRolesInGroupAsyncResult
62
+ end
63
+
64
+ local function _getRankAndRoleFallbackAsync(player: Player, groupId: number): (number, string?)
52
65
  -- euvin: i yoinked this from
53
66
  -- https://devforum.roblox.com/t/groupservicegetrolesingroupasync-is-not-enabled-yet-wiki-tells-me-to-use-it/4660969
54
67
 
55
68
  --? The GetRankInGroup method is deprecated and unstable now.
56
69
  --? https://devforum.roblox.com/t/excessive-rate-limits-when-checking-gamepasses-group-ranks/3549665
57
- local groups = GroupService:GetGroupsAsync(userId) :: GetGroupsAsyncResult
70
+ local groups: GetGroupsAsyncResult
71
+ if PlayerMock.isMock(player) then
72
+ groups = PlayerMock.readLookup(player, "GroupService.GetGroupsAsync", 0)
73
+ else
74
+ groups = GroupService:GetGroupsAsync(player.UserId) :: GetGroupsAsyncResult
75
+ end
58
76
 
59
- for _, GroupInfo: GetGroupDictionary in groups do
60
- if GroupInfo.Id == groupId then
61
- return GroupInfo.Rank, GroupInfo.Role
77
+ for _, groupInfo: GetGroupDictionary in groups do
78
+ if groupInfo.Id == groupId then
79
+ return groupInfo.Rank, groupInfo.Role
62
80
  end
63
81
  end
64
82
 
@@ -73,7 +91,7 @@ end
73
91
  @return Promise<number> -- Generally from 0 to 255
74
92
  ]=]
75
93
  function GroupUtils.promiseRankInGroup(player: Player, groupId: number): Promise.Promise<number>
76
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
94
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
77
95
  assert(type(groupId) == "number", "Bad groupId")
78
96
 
79
97
  return Promise.spawn(function(resolve, reject)
@@ -82,7 +100,7 @@ function GroupUtils.promiseRankInGroup(player: Player, groupId: number): Promise
82
100
  -- GetRankInGroupAsync is deprecated, changed from GetRankInGroupAsync to GetRolesInGroupAsync
83
101
  -- ... but GetRolesInGroupAsync fails for some reason and hasn't been fixed (June 2, 2026)
84
102
  -- so we will fall back to a deprecated method anyway
85
- local result = GroupService:GetRolesInGroupAsync(player.UserId, groupId) :: GetRolesInGroupAsyncResult
103
+ local result = _getRolesInGroupAsync(player, groupId)
86
104
  if result.IsMember then
87
105
  local highestRoleTable = _getHighestRoleTable(result.Roles)
88
106
  if highestRoleTable then
@@ -92,7 +110,7 @@ function GroupUtils.promiseRankInGroup(player: Player, groupId: number): Promise
92
110
  end)
93
111
  if not rank then
94
112
  ok, err = pcall(function()
95
- local gotRank, _ = _getRankAndRoleFallback(player.UserId, groupId)
113
+ local gotRank, _ = _getRankAndRoleFallbackAsync(player, groupId)
96
114
  rank = gotRank
97
115
  end)
98
116
  end
@@ -117,13 +135,13 @@ end
117
135
  @return Promise<string>
118
136
  ]=]
119
137
  function GroupUtils.promiseRoleInGroup(player: Player, groupId: number): Promise.Promise<string>
120
- assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player")
138
+ assert((typeof(player) == "Instance" and player:IsA("Player")) or PlayerMock.isMock(player), "Bad player")
121
139
  assert(type(groupId) == "number", "Bad groupId")
122
140
 
123
141
  return Promise.spawn(function(resolve, reject)
124
142
  local role: string? = nil
125
143
  local ok, err = pcall(function()
126
- local result = GroupService:GetRolesInGroupAsync(player.UserId, groupId) :: GetRolesInGroupAsyncResult
144
+ local result = _getRolesInGroupAsync(player, groupId)
127
145
  if result.IsMember then
128
146
  local highestRoleTable = _getHighestRoleTable(result.Roles)
129
147
  if highestRoleTable then
@@ -134,7 +152,7 @@ function GroupUtils.promiseRoleInGroup(player: Player, groupId: number): Promise
134
152
 
135
153
  if not role then
136
154
  ok, err = pcall(function()
137
- local _, gotRole = _getRankAndRoleFallback(player.UserId, groupId)
155
+ local _, gotRole = _getRankAndRoleFallbackAsync(player, groupId)
138
156
  role = gotRole
139
157
  end)
140
158
  end
@@ -0,0 +1,214 @@
1
+ --!strict
2
+ --[[
3
+ Coverage for GroupUtils rank/role resolution using PlayerMock players. Only the GroupService
4
+ engine calls are intercepted (raw result shapes injected via PlayerMock.writeLookup /
5
+ GroupTestUtils.assignGroupInfo), so the highest-role scan, GetGroupsAsync fallback ordering,
6
+ and reject paths all execute for real.
7
+
8
+ @class GroupUtils.spec.lua
9
+ ]]
10
+
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local GroupTestUtils = require("GroupTestUtils")
14
+ local GroupUtils = require("GroupUtils")
15
+ local Jest = require("Jest")
16
+ local PlayerMock = require("PlayerMock")
17
+ local PromiseTestUtils = require("PromiseTestUtils")
18
+
19
+ local describe = Jest.Globals.describe
20
+ local expect = Jest.Globals.expect
21
+ local it = Jest.Globals.it
22
+
23
+ local GROUP_ID = 372
24
+
25
+ describe("GroupUtils.promiseRankInGroup", function()
26
+ it("should resolve 0 for a mock with no injected membership", function()
27
+ local player = PlayerMock.new({ UserId = 111 })
28
+
29
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
30
+ expect(outcome).toBe("resolved")
31
+ expect(value).toBe(0)
32
+
33
+ player:Destroy()
34
+ end)
35
+
36
+ it("should resolve an assigned rank", function()
37
+ local player = PlayerMock.new({ UserId = 111 })
38
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 230, role = "Admin" })
39
+
40
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
41
+ expect(outcome).toBe("resolved")
42
+ expect(value).toBe(230)
43
+
44
+ player:Destroy()
45
+ end)
46
+
47
+ it("should resolve the highest rank when the result carries multiple roles", function()
48
+ local player = PlayerMock.new({ UserId = 111 })
49
+ PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", GROUP_ID, {
50
+ IsMember = true,
51
+ Roles = {
52
+ { Name = "Member", Rank = 1 },
53
+ { Name = "Admin", Rank = 230 },
54
+ { Name = "Moderator", Rank = 150 },
55
+ },
56
+ })
57
+
58
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
59
+ expect(outcome).toBe("resolved")
60
+ expect(value).toBe(230)
61
+
62
+ player:Destroy()
63
+ end)
64
+
65
+ it("should resolve from the GetGroupsAsync fallback when the primary result reports non-member", function()
66
+ local player = PlayerMock.new({ UserId = 111 })
67
+ PlayerMock.writeLookup(player, "GroupService.GetGroupsAsync", 0, {
68
+ { Id = GROUP_ID, Rank = 42, Role = "Member" },
69
+ })
70
+
71
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
72
+ expect(outcome).toBe("resolved")
73
+ expect(value).toBe(42)
74
+
75
+ player:Destroy()
76
+ end)
77
+
78
+ it("should ignore membership assigned for a different group", function()
79
+ local player = PlayerMock.new({ UserId = 111 })
80
+ GroupTestUtils.assignGroupInfo(player, 99999, { rank = 255, role = "Owner" })
81
+
82
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
83
+ expect(outcome).toBe("resolved")
84
+ expect(value).toBe(0)
85
+
86
+ player:Destroy()
87
+ end)
88
+
89
+ it("should throw on a non-player value", function()
90
+ expect(function()
91
+ GroupUtils.promiseRankInGroup(nil :: any, GROUP_ID)
92
+ end).toThrow("Bad player")
93
+ end)
94
+
95
+ it("should throw on a bad groupId", function()
96
+ local player = PlayerMock.new({ UserId = 111 })
97
+
98
+ expect(function()
99
+ GroupUtils.promiseRankInGroup(player, "372" :: any)
100
+ end).toThrow("Bad groupId")
101
+
102
+ player:Destroy()
103
+ end)
104
+ end)
105
+
106
+ describe("GroupUtils.promiseRoleInGroup", function()
107
+ it("should resolve an assigned role", function()
108
+ local player = PlayerMock.new({ UserId = 111 })
109
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 230, role = "Admin" })
110
+
111
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRoleInGroup(player, GROUP_ID))
112
+ expect(outcome).toBe("resolved")
113
+ expect(value).toBe("Admin")
114
+
115
+ player:Destroy()
116
+ end)
117
+
118
+ it("should resolve the highest role's name when the result carries multiple roles", function()
119
+ local player = PlayerMock.new({ UserId = 111 })
120
+ PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", GROUP_ID, {
121
+ IsMember = true,
122
+ Roles = {
123
+ { Name = "Member", Rank = 1 },
124
+ { Name = "Admin", Rank = 230 },
125
+ { Name = "Moderator", Rank = 150 },
126
+ },
127
+ })
128
+
129
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRoleInGroup(player, GROUP_ID))
130
+ expect(outcome).toBe("resolved")
131
+ expect(value).toBe("Admin")
132
+
133
+ player:Destroy()
134
+ end)
135
+
136
+ it("should resolve from the GetGroupsAsync fallback when the primary result reports non-member", function()
137
+ local player = PlayerMock.new({ UserId = 111 })
138
+ PlayerMock.writeLookup(player, "GroupService.GetGroupsAsync", 0, {
139
+ { Id = GROUP_ID, Rank = 42, Role = "Member" },
140
+ })
141
+
142
+ local outcome, value = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRoleInGroup(player, GROUP_ID))
143
+ expect(outcome).toBe("resolved")
144
+ expect(value).toBe("Member")
145
+
146
+ player:Destroy()
147
+ end)
148
+
149
+ it("should reject for a mock with no injected membership, like a real non-member", function()
150
+ local player = PlayerMock.new({ UserId = 111 })
151
+
152
+ local outcome, err = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRoleInGroup(player, GROUP_ID))
153
+ expect(outcome).toBe("rejected")
154
+ expect(err).toBe("Role is not a string")
155
+
156
+ player:Destroy()
157
+ end)
158
+ end)
159
+
160
+ describe("GroupTestUtils.assignGroupInfo", function()
161
+ it("should keep rank and role coherent from one assignment", function()
162
+ local player = PlayerMock.new({ UserId = 111 })
163
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 150, role = "Moderator" })
164
+
165
+ local _, rank = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
166
+ local _, role = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRoleInGroup(player, GROUP_ID))
167
+ expect(rank).toBe(150)
168
+ expect(role).toBe("Moderator")
169
+
170
+ player:Destroy()
171
+ end)
172
+
173
+ it("should keep assignments to multiple groups independent", function()
174
+ local player = PlayerMock.new({ UserId = 111 })
175
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 230, role = "Admin" })
176
+ GroupTestUtils.assignGroupInfo(player, 99999, { rank = 5, role = "Member" })
177
+
178
+ -- Clearing one group's primary result forces its answer through the shared GetGroupsAsync
179
+ -- fallback list, proving the second assignment appended rather than replaced it.
180
+ PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", GROUP_ID, nil)
181
+
182
+ local _, rank = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
183
+ local _, otherRank = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, 99999))
184
+ expect(rank).toBe(230)
185
+ expect(otherRank).toBe(5)
186
+
187
+ player:Destroy()
188
+ end)
189
+
190
+ it("should replace an earlier assignment for the same group", function()
191
+ local player = PlayerMock.new({ UserId = 111 })
192
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 10, role = "Member" })
193
+ GroupTestUtils.assignGroupInfo(player, GROUP_ID, { rank = 20, role = "Moderator" })
194
+
195
+ local _, rank = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
196
+ expect(rank).toBe(20)
197
+
198
+ PlayerMock.writeLookup(player, "GroupService.GetRolesInGroupAsync", GROUP_ID, nil)
199
+ local _, fallbackRank = PromiseTestUtils.awaitOutcome(GroupUtils.promiseRankInGroup(player, GROUP_ID))
200
+ expect(fallbackRank).toBe(20)
201
+
202
+ player:Destroy()
203
+ end)
204
+
205
+ it("should throw when passed something that is not a PlayerMock", function()
206
+ local folder = Instance.new("Folder")
207
+
208
+ expect(function()
209
+ GroupTestUtils.assignGroupInfo(folder :: any, GROUP_ID, { rank = 1, role = "Member" })
210
+ end).toThrow("Not a PlayerMock")
211
+
212
+ folder:Destroy()
213
+ end)
214
+ end)
@@ -0,0 +1,3 @@
1
+ return {
2
+ testMatch = { "**/*.spec" },
3
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "GroupUtilsTest",
3
+ "tree": {
4
+ "$className": "DataModel",
5
+ "ServerScriptService": {
6
+ "$properties": {
7
+ "LoadStringEnabled": true
8
+ },
9
+ "grouputils": {
10
+ "$path": ".."
11
+ },
12
+ "Script": {
13
+ "$path": "scripts/Server"
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ --!nonstrict
2
+ local ServerScriptService = game:GetService("ServerScriptService")
3
+
4
+ local root = ServerScriptService.grouputils
5
+ local loader = root:FindFirstChild("LoaderUtils", true).Parent
6
+ local require = require(loader).bootstrapGame(root)
7
+
8
+ local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils")
9
+
10
+ if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then
11
+ return
12
+ end