@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.
- package/dist/cjs/src/auth/call-cred.d.ts +2 -2
- package/dist/cjs/src/auth/call-cred.d.ts.map +1 -1
- package/dist/cjs/src/auth/call-cred.js.map +1 -1
- package/dist/cjs/src/client/puppet-service.d.ts +27 -2
- package/dist/cjs/src/client/puppet-service.d.ts.map +1 -1
- package/dist/cjs/src/client/puppet-service.js +431 -124
- package/dist/cjs/src/client/puppet-service.js.map +1 -1
- package/dist/cjs/src/client/puppet-service.spec.js +107 -0
- package/dist/cjs/src/client/puppet-service.spec.js.map +1 -1
- package/dist/cjs/src/client/room-cache-coordinator.d.ts +14 -0
- package/dist/cjs/src/client/room-cache-coordinator.d.ts.map +1 -0
- package/dist/cjs/src/client/room-cache-coordinator.js +53 -0
- package/dist/cjs/src/client/room-cache-coordinator.js.map +1 -0
- package/dist/cjs/src/client/room-member-cache.spec.d.ts +2 -0
- package/dist/cjs/src/client/room-member-cache.spec.d.ts.map +1 -0
- package/dist/cjs/src/client/room-member-cache.spec.js +417 -0
- package/dist/cjs/src/client/room-member-cache.spec.js.map +1 -0
- package/dist/cjs/src/client/room-member-load.spec.d.ts +2 -0
- package/dist/cjs/src/client/room-member-load.spec.d.ts.map +1 -0
- package/dist/cjs/src/client/room-member-load.spec.js +324 -0
- package/dist/cjs/src/client/room-member-load.spec.js.map +1 -0
- package/dist/cjs/src/package-json.js +4 -4
- package/dist/cjs/src/server/event-stream-manager.d.ts.map +1 -1
- package/dist/cjs/src/server/event-stream-manager.js +30 -0
- package/dist/cjs/src/server/event-stream-manager.js.map +1 -1
- package/dist/cjs/src/server/event-stream-manager.spec.d.ts +3 -0
- package/dist/cjs/src/server/event-stream-manager.spec.d.ts.map +1 -0
- package/dist/cjs/src/server/event-stream-manager.spec.js +22 -0
- package/dist/cjs/src/server/event-stream-manager.spec.js.map +1 -0
- package/dist/cjs/src/server/puppet-implementation.d.ts.map +1 -1
- package/dist/cjs/src/server/puppet-implementation.js +135 -28
- package/dist/cjs/src/server/puppet-implementation.js.map +1 -1
- package/dist/cjs/src/server/puppet-implementation.spec.js +98 -0
- package/dist/cjs/src/server/puppet-implementation.spec.js.map +1 -1
- package/dist/cjs/src/utils/pb-payload-helper.d.ts +10 -0
- package/dist/cjs/src/utils/pb-payload-helper.d.ts.map +1 -1
- package/dist/cjs/src/utils/pb-payload-helper.js +180 -1
- package/dist/cjs/src/utils/pb-payload-helper.js.map +1 -1
- package/dist/cjs/src/utils/room-member-load.spec.d.ts +2 -0
- package/dist/cjs/src/utils/room-member-load.spec.d.ts.map +1 -0
- package/dist/cjs/src/utils/room-member-load.spec.js +83 -0
- package/dist/cjs/src/utils/room-member-load.spec.js.map +1 -0
- package/dist/esm/src/auth/call-cred.d.ts +2 -2
- package/dist/esm/src/auth/call-cred.d.ts.map +1 -1
- package/dist/esm/src/auth/call-cred.js.map +1 -1
- package/dist/esm/src/client/puppet-service.d.ts +27 -2
- package/dist/esm/src/client/puppet-service.d.ts.map +1 -1
- package/dist/esm/src/client/puppet-service.js +432 -125
- package/dist/esm/src/client/puppet-service.js.map +1 -1
- package/dist/esm/src/client/puppet-service.spec.js +107 -0
- package/dist/esm/src/client/puppet-service.spec.js.map +1 -1
- package/dist/esm/src/client/room-cache-coordinator.d.ts +14 -0
- package/dist/esm/src/client/room-cache-coordinator.d.ts.map +1 -0
- package/dist/esm/src/client/room-cache-coordinator.js +49 -0
- package/dist/esm/src/client/room-cache-coordinator.js.map +1 -0
- package/dist/esm/src/client/room-member-cache.spec.d.ts +2 -0
- package/dist/esm/src/client/room-member-cache.spec.d.ts.map +1 -0
- package/dist/esm/src/client/room-member-cache.spec.js +415 -0
- package/dist/esm/src/client/room-member-cache.spec.js.map +1 -0
- package/dist/esm/src/client/room-member-load.spec.d.ts +2 -0
- package/dist/esm/src/client/room-member-load.spec.d.ts.map +1 -0
- package/dist/esm/src/client/room-member-load.spec.js +296 -0
- package/dist/esm/src/client/room-member-load.spec.js.map +1 -0
- package/dist/esm/src/package-json.js +4 -4
- package/dist/esm/src/server/event-stream-manager.d.ts.map +1 -1
- package/dist/esm/src/server/event-stream-manager.js +30 -0
- package/dist/esm/src/server/event-stream-manager.js.map +1 -1
- package/dist/esm/src/server/event-stream-manager.spec.d.ts +3 -0
- package/dist/esm/src/server/event-stream-manager.spec.d.ts.map +1 -0
- package/dist/esm/src/server/event-stream-manager.spec.js +20 -0
- package/dist/esm/src/server/event-stream-manager.spec.js.map +1 -0
- package/dist/esm/src/server/puppet-implementation.d.ts.map +1 -1
- package/dist/esm/src/server/puppet-implementation.js +136 -29
- package/dist/esm/src/server/puppet-implementation.js.map +1 -1
- package/dist/esm/src/server/puppet-implementation.spec.js +98 -0
- package/dist/esm/src/server/puppet-implementation.spec.js.map +1 -1
- package/dist/esm/src/utils/pb-payload-helper.d.ts +10 -0
- package/dist/esm/src/utils/pb-payload-helper.d.ts.map +1 -1
- package/dist/esm/src/utils/pb-payload-helper.js +169 -0
- package/dist/esm/src/utils/pb-payload-helper.js.map +1 -1
- package/dist/esm/src/utils/room-member-load.spec.d.ts +2 -0
- package/dist/esm/src/utils/room-member-load.spec.d.ts.map +1 -0
- package/dist/esm/src/utils/room-member-load.spec.js +58 -0
- package/dist/esm/src/utils/room-member-load.spec.js.map +1 -0
- package/package.json +4 -4
- package/src/auth/call-cred.ts +1 -1
- package/src/client/puppet-service.spec.ts +147 -0
- package/src/client/puppet-service.ts +378 -138
- package/src/client/room-cache-coordinator.ts +50 -0
- package/src/client/room-member-cache.spec.ts +448 -0
- package/src/client/room-member-load.spec.ts +319 -0
- package/src/package-json.ts +4 -4
- package/src/server/event-stream-manager.spec.ts +30 -0
- package/src/server/event-stream-manager.ts +29 -0
- package/src/server/puppet-implementation.spec.ts +127 -0
- package/src/server/puppet-implementation.ts +144 -29
- package/src/utils/pb-payload-helper.ts +150 -0
- 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
|
+
})
|
package/src/package-json.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
78
|
+
"@juzi/wechaty-puppet": "^1.0.158"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@juzi/wechaty-grpc": "^1.0.
|
|
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
|
+
})
|