@juzi/wechaty-puppet-service 1.0.129 → 1.0.131

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.
Files changed (98) hide show
  1. package/dist/cjs/src/auth/call-cred.d.ts +2 -2
  2. package/dist/cjs/src/auth/call-cred.d.ts.map +1 -1
  3. package/dist/cjs/src/auth/call-cred.js.map +1 -1
  4. package/dist/cjs/src/client/puppet-service.d.ts +27 -2
  5. package/dist/cjs/src/client/puppet-service.d.ts.map +1 -1
  6. package/dist/cjs/src/client/puppet-service.js +431 -124
  7. package/dist/cjs/src/client/puppet-service.js.map +1 -1
  8. package/dist/cjs/src/client/puppet-service.spec.js +107 -0
  9. package/dist/cjs/src/client/puppet-service.spec.js.map +1 -1
  10. package/dist/cjs/src/client/room-cache-coordinator.d.ts +14 -0
  11. package/dist/cjs/src/client/room-cache-coordinator.d.ts.map +1 -0
  12. package/dist/cjs/src/client/room-cache-coordinator.js +53 -0
  13. package/dist/cjs/src/client/room-cache-coordinator.js.map +1 -0
  14. package/dist/cjs/src/client/room-member-cache.spec.d.ts +2 -0
  15. package/dist/cjs/src/client/room-member-cache.spec.d.ts.map +1 -0
  16. package/dist/cjs/src/client/room-member-cache.spec.js +417 -0
  17. package/dist/cjs/src/client/room-member-cache.spec.js.map +1 -0
  18. package/dist/cjs/src/client/room-member-load.spec.d.ts +2 -0
  19. package/dist/cjs/src/client/room-member-load.spec.d.ts.map +1 -0
  20. package/dist/cjs/src/client/room-member-load.spec.js +324 -0
  21. package/dist/cjs/src/client/room-member-load.spec.js.map +1 -0
  22. package/dist/cjs/src/package-json.js +4 -4
  23. package/dist/cjs/src/server/event-stream-manager.d.ts.map +1 -1
  24. package/dist/cjs/src/server/event-stream-manager.js +30 -0
  25. package/dist/cjs/src/server/event-stream-manager.js.map +1 -1
  26. package/dist/cjs/src/server/event-stream-manager.spec.d.ts +3 -0
  27. package/dist/cjs/src/server/event-stream-manager.spec.d.ts.map +1 -0
  28. package/dist/cjs/src/server/event-stream-manager.spec.js +22 -0
  29. package/dist/cjs/src/server/event-stream-manager.spec.js.map +1 -0
  30. package/dist/cjs/src/server/puppet-implementation.d.ts.map +1 -1
  31. package/dist/cjs/src/server/puppet-implementation.js +135 -28
  32. package/dist/cjs/src/server/puppet-implementation.js.map +1 -1
  33. package/dist/cjs/src/server/puppet-implementation.spec.js +98 -0
  34. package/dist/cjs/src/server/puppet-implementation.spec.js.map +1 -1
  35. package/dist/cjs/src/utils/pb-payload-helper.d.ts +10 -0
  36. package/dist/cjs/src/utils/pb-payload-helper.d.ts.map +1 -1
  37. package/dist/cjs/src/utils/pb-payload-helper.js +180 -1
  38. package/dist/cjs/src/utils/pb-payload-helper.js.map +1 -1
  39. package/dist/cjs/src/utils/room-member-load.spec.d.ts +2 -0
  40. package/dist/cjs/src/utils/room-member-load.spec.d.ts.map +1 -0
  41. package/dist/cjs/src/utils/room-member-load.spec.js +83 -0
  42. package/dist/cjs/src/utils/room-member-load.spec.js.map +1 -0
  43. package/dist/esm/src/auth/call-cred.d.ts +2 -2
  44. package/dist/esm/src/auth/call-cred.d.ts.map +1 -1
  45. package/dist/esm/src/auth/call-cred.js.map +1 -1
  46. package/dist/esm/src/client/puppet-service.d.ts +27 -2
  47. package/dist/esm/src/client/puppet-service.d.ts.map +1 -1
  48. package/dist/esm/src/client/puppet-service.js +432 -125
  49. package/dist/esm/src/client/puppet-service.js.map +1 -1
  50. package/dist/esm/src/client/puppet-service.spec.js +107 -0
  51. package/dist/esm/src/client/puppet-service.spec.js.map +1 -1
  52. package/dist/esm/src/client/room-cache-coordinator.d.ts +14 -0
  53. package/dist/esm/src/client/room-cache-coordinator.d.ts.map +1 -0
  54. package/dist/esm/src/client/room-cache-coordinator.js +49 -0
  55. package/dist/esm/src/client/room-cache-coordinator.js.map +1 -0
  56. package/dist/esm/src/client/room-member-cache.spec.d.ts +2 -0
  57. package/dist/esm/src/client/room-member-cache.spec.d.ts.map +1 -0
  58. package/dist/esm/src/client/room-member-cache.spec.js +415 -0
  59. package/dist/esm/src/client/room-member-cache.spec.js.map +1 -0
  60. package/dist/esm/src/client/room-member-load.spec.d.ts +2 -0
  61. package/dist/esm/src/client/room-member-load.spec.d.ts.map +1 -0
  62. package/dist/esm/src/client/room-member-load.spec.js +296 -0
  63. package/dist/esm/src/client/room-member-load.spec.js.map +1 -0
  64. package/dist/esm/src/package-json.js +4 -4
  65. package/dist/esm/src/server/event-stream-manager.d.ts.map +1 -1
  66. package/dist/esm/src/server/event-stream-manager.js +30 -0
  67. package/dist/esm/src/server/event-stream-manager.js.map +1 -1
  68. package/dist/esm/src/server/event-stream-manager.spec.d.ts +3 -0
  69. package/dist/esm/src/server/event-stream-manager.spec.d.ts.map +1 -0
  70. package/dist/esm/src/server/event-stream-manager.spec.js +20 -0
  71. package/dist/esm/src/server/event-stream-manager.spec.js.map +1 -0
  72. package/dist/esm/src/server/puppet-implementation.d.ts.map +1 -1
  73. package/dist/esm/src/server/puppet-implementation.js +136 -29
  74. package/dist/esm/src/server/puppet-implementation.js.map +1 -1
  75. package/dist/esm/src/server/puppet-implementation.spec.js +98 -0
  76. package/dist/esm/src/server/puppet-implementation.spec.js.map +1 -1
  77. package/dist/esm/src/utils/pb-payload-helper.d.ts +10 -0
  78. package/dist/esm/src/utils/pb-payload-helper.d.ts.map +1 -1
  79. package/dist/esm/src/utils/pb-payload-helper.js +169 -0
  80. package/dist/esm/src/utils/pb-payload-helper.js.map +1 -1
  81. package/dist/esm/src/utils/room-member-load.spec.d.ts +2 -0
  82. package/dist/esm/src/utils/room-member-load.spec.d.ts.map +1 -0
  83. package/dist/esm/src/utils/room-member-load.spec.js +58 -0
  84. package/dist/esm/src/utils/room-member-load.spec.js.map +1 -0
  85. package/package.json +4 -4
  86. package/src/auth/call-cred.ts +1 -1
  87. package/src/client/puppet-service.spec.ts +147 -0
  88. package/src/client/puppet-service.ts +378 -138
  89. package/src/client/room-cache-coordinator.ts +50 -0
  90. package/src/client/room-member-cache.spec.ts +448 -0
  91. package/src/client/room-member-load.spec.ts +319 -0
  92. package/src/package-json.ts +4 -4
  93. package/src/server/event-stream-manager.spec.ts +30 -0
  94. package/src/server/event-stream-manager.ts +29 -0
  95. package/src/server/puppet-implementation.spec.ts +127 -0
  96. package/src/server/puppet-implementation.ts +144 -29
  97. package/src/utils/pb-payload-helper.ts +150 -0
  98. package/src/utils/room-member-load.spec.ts +67 -0
@@ -0,0 +1,319 @@
1
+ import util from 'util'
2
+ import { sinon, test } from 'tstest'
3
+ import { FileBox } from 'file-box'
4
+ import * as PUPPET from '@juzi/wechaty-puppet'
5
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
6
+ import { grpc, puppet as grpcPuppet } from '@juzi/wechaty-grpc'
7
+
8
+ import { PuppetService } from './puppet-service.js'
9
+ import { puppetImplementation } from '../server/puppet-implementation.js'
10
+ import { EventStreamManager } from '../server/event-stream-manager.js'
11
+ import { EventTypeRev } from '../event-type-rev.js'
12
+
13
+ interface RpcFixture {
14
+ service: PuppetService
15
+ adapter: PUPPET.impls.PuppetInterface
16
+ client: grpcPuppet.PuppetClient
17
+ close: () => Promise<void>
18
+ }
19
+
20
+ async function createRpcFixture (legacyAdapter?: PUPPET.impls.PuppetInterface): Promise<RpcFixture> {
21
+ // The published mock resolves its own older Puppet dependency.
22
+ const adapter = legacyAdapter ?? Object.assign(new PuppetMock(), {
23
+ roomMemberSync: PUPPET.Puppet.prototype.roomMemberSync,
24
+ roomMemberLoadState: PUPPET.Puppet.prototype.roomMemberLoadState,
25
+ roomMemberSnapshot: PUPPET.Puppet.prototype.roomMemberSnapshot,
26
+ }) as unknown as PUPPET.impls.PuppetInterface
27
+ const server = new grpc.Server()
28
+ server.addService(grpcPuppet.PuppetService, puppetImplementation(adapter, FileBox))
29
+ const port = await util.promisify(server.bindAsync.bind(server))(
30
+ '127.0.0.1:0', grpc.ServerCredentials.createInsecure(),
31
+ )
32
+ const client = new grpcPuppet.PuppetClient(`127.0.0.1:${port}`, grpc.credentials.createInsecure())
33
+ const service = new PuppetService({ token: `load-test-${port}` })
34
+ Object.defineProperty(service, 'grpcManager', { value: { client } })
35
+ return {
36
+ adapter,
37
+ service,
38
+ client,
39
+ close: async (): Promise<void> => {
40
+ client.close()
41
+ await util.promisify(server.tryShutdown.bind(server))()
42
+ },
43
+ }
44
+ }
45
+
46
+ const state: PUPPET.payloads.RoomMemberLoadState = {
47
+ roomId: 'room', taskId: 'task', status: 3, revision: '9007199254740993', loadedAt: 123456789,
48
+ }
49
+
50
+ test('real gRPC sync, state, and snapshot roundtrip without dirty fallback', async t => {
51
+ const { adapter, service, close } = await createRpcFixture()
52
+ t.teardown(close)
53
+ const accepted: PUPPET.payloads.RoomMemberLoadState = {
54
+ roomId: 'room', taskId: 'task', status: PUPPET.types.RoomMemberLoadStatus.Pending, revision: '0',
55
+ }
56
+ const sync = sinon.stub(adapter, 'roomMemberSync').resolves(accepted)
57
+ const read = sinon.stub(adapter, 'roomMemberLoadState').resolves(state)
58
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({
59
+ roomId: 'room',
60
+ revision: state.revision,
61
+ loadedAt: state.loadedAt,
62
+ members: [ {
63
+ id: 'member',
64
+ name: 'name',
65
+ avatar: 'avatar',
66
+ roomAlias: 'alias',
67
+ inviterId: 'inviter',
68
+ additionalInfo: 'info',
69
+ joinTime: 12,
70
+ joinScene: 0,
71
+ } ],
72
+ nextPageToken: 'next',
73
+ })
74
+ const dirty = sinon.spy(adapter, 'dirtyPayload')
75
+ t.same(await service.roomMemberSync('room', { force: true }), accepted, 'returns accepted Pending state without waiting for Loaded')
76
+ t.same(sync.args[0], [ 'room', { force: true } ])
77
+ await service.roomMemberSync('room')
78
+ t.same(sync.args[1], [ 'room', undefined ], 'does not inject default force')
79
+ t.same(await service.roomMemberLoadState('room'), state)
80
+ t.equal(read.callCount, 1)
81
+ const page = await service.roomMemberSnapshot('room', state.revision, { pageToken: 'cursor', pageSize: 2 })
82
+ t.same(snapshot.args[0], [ 'room', state.revision, { pageToken: 'cursor', pageSize: 2 } ])
83
+ t.equal(page.nextPageToken, 'next')
84
+ t.equal(page.members[0]?.roomAlias, 'alias')
85
+ t.equal(page.loadedAt, state.loadedAt)
86
+ t.equal(dirty.callCount, 0)
87
+ await service.roomMemberSnapshot('room', state.revision)
88
+ t.same(snapshot.args[1], [ 'room', state.revision, { pageToken: '', pageSize: 0 } ])
89
+ await t.rejects(service.roomMemberSnapshot('room', '01'), /revision/)
90
+ await t.rejects(service.roomMemberSnapshot('room', '1', { pageSize: 501 }), /pageSize/)
91
+ })
92
+
93
+ test('snapshot conflicts and adapter unsupported errors keep standard gRPC status', async t => {
94
+ const { adapter, service, close } = await createRpcFixture()
95
+ t.teardown(close)
96
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({ roomId: 'room', revision: '2', members: [] })
97
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
98
+ snapshot.resolves({ roomId: 'other', revision: '1', members: [] })
99
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
100
+ snapshot.rejects(Object.assign(new Error('snapshot not loaded'), { code: grpc.status.FAILED_PRECONDITION }))
101
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION })
102
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.UNIMPLEMENTED })
103
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.UNIMPLEMENTED })
104
+ snapshot.restore()
105
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.UNIMPLEMENTED })
106
+ })
107
+
108
+ async function deliverEvent (service: PuppetService, event: grpcPuppet.EventResponse): Promise<void> {
109
+ const handler: unknown = Reflect.get(service, 'onGrpcStreamEvent')
110
+ if (typeof handler !== 'function') { throw new Error('Missing event handler') }
111
+ await handler.call(service, event)
112
+ }
113
+
114
+ test('independent event waits for both persistent stores then invalidates base caches', async t => {
115
+ const service = new PuppetService({ token: 'load-event-test' })
116
+ await service.cache.start()
117
+ t.teardown(() => service.cache.stop())
118
+ service.cache.room?.set('room', { id: 'room', topic: '', adminIdList: [], memberIdList: [] })
119
+ service.cache.roomMember?.set('room', { member: { id: 'member', name: '', avatar: '' } })
120
+ service.on('room-member-load', service.onRoomMemberLoad.bind(service))
121
+ let finishRoom: () => void = () => { throw new Error('Missing room resolver') }
122
+ let finishMembers: () => void = () => { throw new Error('Missing members resolver') }
123
+ const roomDeleted = new Promise<void>(resolve => { finishRoom = resolve })
124
+ const membersDeleted = new Promise<void>(resolve => { finishMembers = resolve })
125
+ const deleteRoom = sinon.stub().returns(roomDeleted)
126
+ const deleteMembers = sinon.stub().returns(membersDeleted)
127
+ Object.defineProperty(service, '_payloadStore', {
128
+ value: { room: { delete: deleteRoom }, roomMember: { delete: deleteMembers } },
129
+ })
130
+ const dirty = sinon.spy(service, 'dirtyPayload')
131
+ const dirtyEvent = sinon.spy()
132
+ const load = sinon.spy()
133
+ service.on('dirty', dirtyEvent)
134
+ service.on('room-member-load', load)
135
+ const event = new grpcPuppet.EventResponse()
136
+ event.setType(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD)
137
+ event.setPayload(JSON.stringify(state))
138
+ const pending = deliverEvent(service, event)
139
+ t.equal(load.callCount, 0)
140
+ finishRoom()
141
+ await Promise.resolve()
142
+ t.equal(load.callCount, 0, 'second store still pending')
143
+ finishMembers()
144
+ await pending
145
+ t.same(deleteRoom.args[0], [ 'room' ])
146
+ t.same(deleteMembers.args[0], [ 'room' ])
147
+ t.same(load.args[0], [ state ])
148
+ t.equal(service.cache.room?.has('room'), false)
149
+ t.equal(service.cache.roomMember?.has('room'), false)
150
+ t.equal(dirty.callCount, 0)
151
+ t.equal(dirtyEvent.callCount, 0)
152
+ t.equal(EventTypeRev[39], 'EVENT_TYPE_ROOM_MEMBER_LOAD')
153
+ })
154
+
155
+ test('server forwards original room-member-load event and removes its listener', async t => {
156
+ const adapter = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
157
+ const manager = new EventStreamManager(adapter)
158
+ const emit = sinon.stub(manager, 'grpcEmit')
159
+ manager.connectPuppetEventToStreamingCall()
160
+ adapter.emit('room-member-load', state)
161
+ t.same(emit.args[0], [ grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD, state ])
162
+ manager.removePuppetListeners()
163
+ adapter.emit('room-member-load', state)
164
+ t.equal(emit.callCount, 1)
165
+ })
166
+
167
+ test('single and batch room payload RPCs retain every new field', async t => {
168
+ const { adapter, service, close } = await createRpcFixture()
169
+ t.teardown(close)
170
+ const payload: PUPPET.payloads.Room = {
171
+ id: 'room',
172
+ topic: 'topic',
173
+ avatar: 'avatar',
174
+ handle: 'handle',
175
+ ownerId: 'owner',
176
+ adminIdList: [ 'admin' ],
177
+ memberIdList: [],
178
+ additionalInfo: 'info',
179
+ remark: 'remark',
180
+ external: true,
181
+ createTime: 1234,
182
+ memberCount: 8,
183
+ memberLoadMode: 2,
184
+ memberLoadStatus: 3,
185
+ memberLoadRevision: state.revision,
186
+ memberLoadedAt: 0,
187
+ }
188
+ sinon.stub(adapter, 'roomPayload').resolves(payload)
189
+ sinon.stub(adapter, 'batchRoomPayload').resolves(new Map([ [ 'room', payload ] ]))
190
+ t.same(await service.roomRawPayload('room'), payload)
191
+ t.same((await service.batchRoomRawPayload([ 'room' ])).get('room'), payload)
192
+ })
193
+
194
+ test('malformed status and revision fail rather than falling back to dirty', async t => {
195
+ const { adapter, service, close } = await createRpcFixture()
196
+ t.teardown(close)
197
+ const read = sinon.stub(adapter, 'roomMemberLoadState').resolves({ ...state, status: 99 })
198
+ const dirty = sinon.spy(adapter, 'dirtyPayload')
199
+ await t.rejects(service.roomMemberLoadState('room'), /status/)
200
+ read.resolves({ ...state, revision: '01' })
201
+ await t.rejects(service.roomMemberLoadState('room'), /revision/)
202
+ read.resolves({ ...state, roomId: 'other' })
203
+ await t.rejects(service.roomMemberLoadState('room'), /roomId/)
204
+ t.equal(dirty.callCount, 0)
205
+ })
206
+
207
+ test('cache deletion failure rejects without announcing a successful load', async t => {
208
+ const service = new PuppetService({ token: 'load-event-failure' })
209
+ Object.defineProperty(service, '_payloadStore', {
210
+ value: {
211
+ room: { delete: async (): Promise<void> => { throw new Error('cache unavailable') } },
212
+ roomMember: { delete: async (): Promise<void> => {} },
213
+ },
214
+ })
215
+ const load = sinon.spy()
216
+ service.on('room-member-load', load)
217
+ const event = new grpcPuppet.EventResponse()
218
+ event.setType(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD)
219
+ event.setPayload(JSON.stringify(state))
220
+ await t.rejects(deliverEvent(service, event), /cache unavailable/)
221
+ t.equal(load.callCount, 0)
222
+ })
223
+
224
+ test('server enforces snapshot request bounds before invoking the adapter', async t => {
225
+ const { adapter, client, close } = await createRpcFixture()
226
+ t.teardown(close)
227
+ const snapshot = sinon.stub(adapter, 'roomMemberSnapshot').resolves({ roomId: 'room', revision: '1', members: [] })
228
+ const request = new grpcPuppet.RoomMemberSnapshotRequest()
229
+ request.setId('room')
230
+ request.setRevision('1')
231
+ const invoke = util.promisify(client.roomMemberSnapshot.bind(client))
232
+ request.setPageSize(501)
233
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
234
+ request.setPageSize(0)
235
+ request.setRevision('01')
236
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
237
+ request.setRevision('1')
238
+ request.setId('')
239
+ await t.rejects(invoke(request), { code: grpc.status.INVALID_ARGUMENT })
240
+ t.equal(snapshot.callCount, 0)
241
+ request.setId('room')
242
+ await invoke(request)
243
+ t.same(snapshot.args[0], [ 'room', '1', { pageToken: '', pageSize: 0 } ])
244
+ request.setPageSize(500)
245
+ await invoke(request)
246
+ t.same(snapshot.args[1], [ 'room', '1', { pageToken: '', pageSize: 500 } ])
247
+ })
248
+
249
+ test('all three RPCs preserve adapter error codes', async t => {
250
+ const { adapter, service, close } = await createRpcFixture()
251
+ t.teardown(close)
252
+ const error = Object.assign(new Error('adapter capacity exhausted'), { code: grpc.status.RESOURCE_EXHAUSTED })
253
+ sinon.stub(adapter, 'roomMemberSync').rejects(error)
254
+ sinon.stub(adapter, 'roomMemberLoadState').rejects(error)
255
+ sinon.stub(adapter, 'roomMemberSnapshot').rejects(error)
256
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.RESOURCE_EXHAUSTED })
257
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.RESOURCE_EXHAUSTED })
258
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.RESOURCE_EXHAUSTED })
259
+ })
260
+
261
+ test('legacy adapter without member loading methods returns UNIMPLEMENTED', async t => {
262
+ const legacy = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
263
+ Object.defineProperties(legacy, {
264
+ roomMemberSync: { value: undefined },
265
+ roomMemberLoadState: { value: undefined },
266
+ roomMemberSnapshot: { value: undefined },
267
+ })
268
+ const { service, close } = await createRpcFixture(legacy)
269
+ t.teardown(close)
270
+ t.equal(typeof legacy.roomMemberSync, 'undefined')
271
+ t.equal(typeof legacy.roomMemberLoadState, 'undefined')
272
+ t.equal(typeof legacy.roomMemberSnapshot, 'undefined')
273
+ await t.rejects(service.roomMemberSync('room'), { code: grpc.status.UNIMPLEMENTED })
274
+ await t.rejects(service.roomMemberLoadState('room'), { code: grpc.status.UNIMPLEMENTED })
275
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.UNIMPLEMENTED })
276
+ })
277
+
278
+ test('plain adapter status objects retain their original details', async t => {
279
+ const { adapter, service, close } = await createRpcFixture()
280
+ t.teardown(close)
281
+ const logs = sinon.spy(PUPPET.log, 'error')
282
+ t.teardown(() => logs.restore())
283
+ sinon.stub(adapter, 'roomMemberSnapshot').rejects({ code: grpc.status.FAILED_PRECONDITION, details: 'revision retired' })
284
+ await t.rejects(service.roomMemberSnapshot('room', '1'), { code: grpc.status.FAILED_PRECONDITION, details: 'revision retired' })
285
+ t.equal(logs.args.some(args => args.includes('roomMemberSnapshot') && args.some(value => typeof value === 'string' && value.includes('revision retired'))), true)
286
+ })
287
+
288
+ test('server event validator rejects invalid state without forwarding a load event', async t => {
289
+ const adapter = new PuppetMock() as unknown as PUPPET.impls.PuppetInterface
290
+ const manager = new EventStreamManager(adapter)
291
+ const emit = sinon.stub(manager, 'grpcEmit')
292
+ const errors = sinon.spy()
293
+ adapter.on('error', errors)
294
+ manager.connectPuppetEventToStreamingCall()
295
+ adapter.emit('room-member-load', { ...state, status: 99 })
296
+ t.equal(errors.callCount, 1)
297
+ t.equal(emit.args.some(args => args[0] === grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD), false)
298
+ manager.removePuppetListeners()
299
+ })
300
+
301
+ test('persistent batch cache failure rejects without retrying raw RPCs', async t => {
302
+ const { adapter, service, close } = await createRpcFixture()
303
+ t.teardown(close)
304
+ const room: PUPPET.payloads.Room = { id: 'room', topic: '', adminIdList: [], memberIdList: [] }
305
+ const member: PUPPET.payloads.RoomMember = { id: 'member', name: '', avatar: '' }
306
+ sinon.stub(adapter, 'batchRoomPayload').resolves(new Map([ [ 'room', room ] ]))
307
+ sinon.stub(adapter, 'batchRoomMemberPayload').resolves(new Map([ [ 'member', member ] ]))
308
+ const roomFallback = sinon.spy(service, 'roomRawPayload')
309
+ const memberFallback = sinon.spy(service, 'roomMemberRawPayload')
310
+ const store = {
311
+ get: async (): Promise<undefined> => undefined,
312
+ set: async (): Promise<void> => { throw new Error('persistent cache unavailable') },
313
+ }
314
+ Object.defineProperty(service, '_payloadStore', { value: { room: store, roomMember: store } })
315
+ await t.rejects(service.batchRoomRawPayload([ 'room' ]), /persistent cache unavailable/)
316
+ await t.rejects(service.batchRoomMemberRawPayload('room', [ 'member' ]), /persistent cache unavailable/)
317
+ t.equal(roomFallback.callCount, 0)
318
+ t.equal(memberFallback.callCount, 0)
319
+ })
@@ -4,7 +4,7 @@
4
4
  import type { PackageJson } from 'type-fest'
5
5
  export const packageJson: PackageJson = {
6
6
  "name": "@juzi/wechaty-puppet-service",
7
- "version": "1.0.129",
7
+ "version": "1.0.131",
8
8
  "description": "Puppet Service for Wechaty",
9
9
  "type": "module",
10
10
  "exports": {
@@ -55,7 +55,7 @@ export const packageJson: PackageJson = {
55
55
  "@chatie/eslint-config": "^1.0.4",
56
56
  "@chatie/semver": "^0.4.7",
57
57
  "@chatie/tsconfig": "^4.6.3",
58
- "@juzi/wechaty-puppet": "^1.0.156",
58
+ "@juzi/wechaty-puppet": "^1.0.158",
59
59
  "@juzi/wechaty-puppet-mock": "^1.0.1",
60
60
  "@swc/core": "1.3.39",
61
61
  "@types/google-protobuf": "^3.15.5",
@@ -75,10 +75,10 @@ export const packageJson: PackageJson = {
75
75
  "why-is-node-running": "^2.2.1"
76
76
  },
77
77
  "peerDependencies": {
78
- "@juzi/wechaty-puppet": "^1.0.156"
78
+ "@juzi/wechaty-puppet": "^1.0.158"
79
79
  },
80
80
  "dependencies": {
81
- "@juzi/wechaty-grpc": "^1.0.108",
81
+ "@juzi/wechaty-grpc": "^1.0.110",
82
82
  "clone-class": "^1.1.1",
83
83
  "ducks": "^1.0.2",
84
84
  "file-box": "^1.5.5",
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+
3
+ import {
4
+ sinon,
5
+ test,
6
+ } from 'tstest'
7
+
8
+ import { puppet as grpcPuppet } from '@juzi/wechaty-grpc'
9
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
10
+
11
+ import { EventStreamManager } from './event-stream-manager.js'
12
+
13
+ test('org broadcast puppet events are forwarded as their own grpc event types', async t => {
14
+ const puppet = new PuppetMock() as any
15
+ const manager = new EventStreamManager(puppet)
16
+
17
+ const write = sinon.spy()
18
+ ;(manager as any).eventStream = { write }
19
+ manager.connectPuppetEventToStreamingCall()
20
+
21
+ puppet.emit('org-broadcast-created', { orgBroadcastId: 'org-broadcast-1', messageId: 'message-1' })
22
+ puppet.emit('org-broadcast-sent', { orgBroadcastId: 'org-broadcast-1' })
23
+
24
+ const [ created, sent ] = write.args.map(args => args[0] as grpcPuppet.EventResponse)
25
+
26
+ t.equal(created?.getType(), grpcPuppet.EventType.EVENT_TYPE_ORG_BROADCAST_CREATED, 'forwards org-broadcast-created as EVENT_TYPE_ORG_BROADCAST_CREATED')
27
+ t.same(JSON.parse(created!.getPayload()), { orgBroadcastId: 'org-broadcast-1', messageId: 'message-1' }, 'keeps the created payload')
28
+ t.equal(sent?.getType(), grpcPuppet.EventType.EVENT_TYPE_ORG_BROADCAST_SENT, 'forwards org-broadcast-sent as EVENT_TYPE_ORG_BROADCAST_SENT')
29
+ t.same(JSON.parse(sent!.getPayload()), { orgBroadcastId: 'org-broadcast-1' }, 'keeps the sent payload')
30
+ })
@@ -23,6 +23,7 @@ import {
23
23
  } from '@juzi/wechaty-grpc'
24
24
 
25
25
  import * as PUPPET from '@juzi/wechaty-puppet'
26
+ import { validateRoomMemberLoadState } from '../utils/pb-payload-helper.js'
26
27
 
27
28
  import { log, NO_LOG_EVENTS } from '../config.js'
28
29
  import {
@@ -245,6 +246,20 @@ class EventStreamManager {
245
246
  this.offCallbackList.push(off)
246
247
  break
247
248
  }
249
+ case 'room-member-load': {
250
+ const listener = (payload: PUPPET.payloads.EventRoomMemberLoad): void => {
251
+ try {
252
+ validateRoomMemberLoadState(payload)
253
+ this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_MEMBER_LOAD, payload)
254
+ } catch (error) {
255
+ log.error('EventStreamManager', 'room-member-load rejected: %s', error)
256
+ this.puppet.emit('error', error)
257
+ }
258
+ }
259
+ this.puppet.on('room-member-load', listener)
260
+ this.offCallbackList.push(() => this.puppet.off('room-member-load', listener))
261
+ break
262
+ }
248
263
  case 'room-join': {
249
264
  const listener = (payload: PUPPET.payloads.EventRoomJoin) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_JOIN, payload)
250
265
  this.puppet.on('room-join', listener)
@@ -336,6 +351,20 @@ class EventStreamManager {
336
351
  this.offCallbackList.push(off)
337
352
  break
338
353
  }
354
+ case 'org-broadcast-created': {
355
+ const listener = (payload: PUPPET.payloads.EventOrgBroadcastCreated) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ORG_BROADCAST_CREATED, payload)
356
+ this.puppet.on('org-broadcast-created', listener)
357
+ const off = () => this.puppet.off('org-broadcast-created', listener)
358
+ this.offCallbackList.push(off)
359
+ break
360
+ }
361
+ case 'org-broadcast-sent': {
362
+ const listener = (payload: PUPPET.payloads.EventOrgBroadcastSent) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ORG_BROADCAST_SENT, payload)
363
+ this.puppet.on('org-broadcast-sent', listener)
364
+ const off = () => this.puppet.off('org-broadcast-sent', listener)
365
+ this.offCallbackList.push(off)
366
+ break
367
+ }
339
368
  case 'reset':
340
369
  // the `reset` event should be dealed internally, should not send out
341
370
  break
@@ -119,3 +119,130 @@ test('callInviteWithMedia rejects an empty callId returned by the puppet', async
119
119
 
120
120
  t.match(error?.message, /empty callId/, 'does not pass an unusable empty callId back on the wire')
121
121
  })
122
+
123
+ function invokeOrgBroadcast<Req, Res> (
124
+ puppet : any,
125
+ method : 'orgBroadcastPayload' | 'orgBroadcastExecute',
126
+ request : Req,
127
+ ): Promise<{ error: null | Error, response: null | Res }> {
128
+ const impl = puppetImplementation(puppet, FileBox)
129
+
130
+ return new Promise(resolve => {
131
+ void (impl[method] as any)(
132
+ { request } as any,
133
+ ((error: any, response: any) => resolve({ error, response })) as any,
134
+ )
135
+ })
136
+ }
137
+
138
+ test('orgBroadcastPayload serializes every payload field onto the response', async t => {
139
+ const puppet = new PuppetMock() as any
140
+ const stub = sinon.stub().resolves({
141
+ id : 'org-broadcast-1',
142
+ sendType : 2,
143
+ conversationType : 1,
144
+ creatorId : 'creator-1',
145
+ execTime : 1758600000000,
146
+ status : 3,
147
+ canCancel : true,
148
+ allowSelect : true,
149
+ sent : false,
150
+ totalCount : 7,
151
+ sentCount : 5,
152
+ contentListJson : '[{"contentType":2}]',
153
+ targets : [ { roomId: 'R:1', status: 4 }, { roomId: 'R:2', status: 1 } ],
154
+ })
155
+ puppet.orgBroadcastPayload = stub
156
+
157
+ const request = new grpcPuppet.OrgBroadcastPayloadRequest()
158
+ request.setId('org-broadcast-1')
159
+
160
+ const { error, response } = await invokeOrgBroadcast<grpcPuppet.OrgBroadcastPayloadRequest, grpcPuppet.OrgBroadcastPayloadResponse>(puppet, 'orgBroadcastPayload', request)
161
+
162
+ t.equal(error, null, 'returns without error')
163
+ t.same(stub.args[0], [ 'org-broadcast-1' ], 'passes the id to the puppet')
164
+ t.same(response?.toObject(), {
165
+ id : 'org-broadcast-1',
166
+ sendType : 2,
167
+ conversationType: 1,
168
+ creatorId : 'creator-1',
169
+ execTime : 1758600000000,
170
+ status : 3,
171
+ canCancel : true,
172
+ allowSelect : true,
173
+ sent : false,
174
+ totalCount : 7,
175
+ sentCount : 5,
176
+ contentListJson : '[{"contentType":2}]',
177
+ targetsList : [
178
+ { contactId: '', roomId: 'R:1', status: 4 },
179
+ { contactId: '', roomId: 'R:2', status: 1 },
180
+ ],
181
+ }, 'serializes every field')
182
+ })
183
+
184
+ test('orgBroadcastPayload serializes a missing creatorId and contact targets as empty strings', async t => {
185
+ const puppet = new PuppetMock() as any
186
+ puppet.orgBroadcastPayload = sinon.stub().resolves({
187
+ id : 'org-broadcast-2',
188
+ sendType : 0,
189
+ conversationType : 0,
190
+ execTime : 0,
191
+ status : 0,
192
+ canCancel : false,
193
+ allowSelect : false,
194
+ sent : false,
195
+ totalCount : 1,
196
+ sentCount : 0,
197
+ contentListJson : '[]',
198
+ targets : [ { contactId: 'contact-1', status: 0 } ],
199
+ })
200
+
201
+ const request = new grpcPuppet.OrgBroadcastPayloadRequest()
202
+ request.setId('org-broadcast-2')
203
+
204
+ const { response } = await invokeOrgBroadcast<grpcPuppet.OrgBroadcastPayloadRequest, grpcPuppet.OrgBroadcastPayloadResponse>(puppet, 'orgBroadcastPayload', request)
205
+
206
+ t.equal(response?.getCreatorId(), '', 'serializes a missing creatorId as empty')
207
+ t.same(response?.getTargetsList()[0]?.toObject(), { contactId: 'contact-1', roomId: '', status: 0 }, 'leaves room_id empty for a contact target')
208
+ })
209
+
210
+ test('orgBroadcastExecute turns an empty target_ids into sending to all targets', async t => {
211
+ const puppet = new PuppetMock() as any
212
+ const stub = sinon.stub().resolves()
213
+ puppet.orgBroadcastExecute = stub
214
+
215
+ const request = new grpcPuppet.OrgBroadcastExecuteRequest()
216
+ request.setId('org-broadcast-1')
217
+
218
+ const { error } = await invokeOrgBroadcast(puppet, 'orgBroadcastExecute', request)
219
+
220
+ t.equal(error, null, 'returns without error')
221
+ t.same(stub.args[0], [ 'org-broadcast-1', undefined ], 'omits targetIds')
222
+ })
223
+
224
+ test('orgBroadcastExecute forwards the selected target_ids', async t => {
225
+ const puppet = new PuppetMock() as any
226
+ const stub = sinon.stub().resolves()
227
+ puppet.orgBroadcastExecute = stub
228
+
229
+ const request = new grpcPuppet.OrgBroadcastExecuteRequest()
230
+ request.setId('org-broadcast-1')
231
+ request.setTargetIdsList([ 'contact-1' ])
232
+
233
+ await invokeOrgBroadcast(puppet, 'orgBroadcastExecute', request)
234
+
235
+ t.same(stub.args[0], [ 'org-broadcast-1', [ 'contact-1' ] ], 'passes targetIds through')
236
+ })
237
+
238
+ test('orgBroadcastExecute reports a puppet failure as a grpc error', async t => {
239
+ const puppet = new PuppetMock() as any
240
+ puppet.orgBroadcastExecute = sinon.stub().rejects(new Error('broadcast not found'))
241
+
242
+ const request = new grpcPuppet.OrgBroadcastExecuteRequest()
243
+ request.setId('org-broadcast-missing')
244
+
245
+ const { error } = await invokeOrgBroadcast(puppet, 'orgBroadcastExecute', request)
246
+
247
+ t.match(error?.message, /broadcast not found/, 'surfaces the puppet error')
248
+ })