@juzi/wechaty 1.0.146 → 1.0.147

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 (131) hide show
  1. package/dist/cjs/src/mods/impls.d.ts +2 -2
  2. package/dist/cjs/src/mods/impls.d.ts.map +1 -1
  3. package/dist/cjs/src/mods/impls.js +2 -1
  4. package/dist/cjs/src/mods/impls.js.map +1 -1
  5. package/dist/cjs/src/mods/users.d.ts +2 -1
  6. package/dist/cjs/src/mods/users.d.ts.map +1 -1
  7. package/dist/cjs/src/package-json.js +4 -4
  8. package/dist/cjs/src/package-json.js.map +1 -1
  9. package/dist/cjs/src/schemas/call-events.d.ts +20 -0
  10. package/dist/cjs/src/schemas/call-events.d.ts.map +1 -0
  11. package/dist/cjs/src/schemas/call-events.js +7 -0
  12. package/dist/cjs/src/schemas/call-events.js.map +1 -0
  13. package/dist/cjs/src/schemas/mod.d.ts +3 -2
  14. package/dist/cjs/src/schemas/mod.d.ts.map +1 -1
  15. package/dist/cjs/src/schemas/mod.js +3 -1
  16. package/dist/cjs/src/schemas/mod.js.map +1 -1
  17. package/dist/cjs/src/schemas/wechaty-events.d.ts +23 -2
  18. package/dist/cjs/src/schemas/wechaty-events.d.ts.map +1 -1
  19. package/dist/cjs/src/schemas/wechaty-events.js +6 -0
  20. package/dist/cjs/src/schemas/wechaty-events.js.map +1 -1
  21. package/dist/cjs/src/user-modules/call.d.ts +158 -0
  22. package/dist/cjs/src/user-modules/call.d.ts.map +1 -1
  23. package/dist/cjs/src/user-modules/call.js +289 -1
  24. package/dist/cjs/src/user-modules/call.js.map +1 -1
  25. package/dist/cjs/src/user-modules/call.spec.d.ts +7 -0
  26. package/dist/cjs/src/user-modules/call.spec.d.ts.map +1 -0
  27. package/dist/cjs/src/user-modules/call.spec.js +759 -0
  28. package/dist/cjs/src/user-modules/call.spec.js.map +1 -0
  29. package/dist/cjs/src/user-modules/contact.d.ts +16 -0
  30. package/dist/cjs/src/user-modules/contact.d.ts.map +1 -1
  31. package/dist/cjs/src/user-modules/contact.js +16 -0
  32. package/dist/cjs/src/user-modules/contact.js.map +1 -1
  33. package/dist/cjs/src/user-modules/mod.d.ts +5 -4
  34. package/dist/cjs/src/user-modules/mod.d.ts.map +1 -1
  35. package/dist/cjs/src/user-modules/mod.js +2 -1
  36. package/dist/cjs/src/user-modules/mod.js.map +1 -1
  37. package/dist/cjs/src/wechaty/wechaty-base.d.ts +66 -7
  38. package/dist/cjs/src/wechaty/wechaty-base.d.ts.map +1 -1
  39. package/dist/cjs/src/wechaty/wechaty-base.js +28 -0
  40. package/dist/cjs/src/wechaty/wechaty-base.js.map +1 -1
  41. package/dist/cjs/src/wechaty/wechaty-impl.spec.js +10 -8
  42. package/dist/cjs/src/wechaty/wechaty-impl.spec.js.map +1 -1
  43. package/dist/cjs/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
  44. package/dist/cjs/src/wechaty-mixins/io-mixin.d.ts +2 -2
  45. package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts +15 -5
  46. package/dist/cjs/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
  47. package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts +15 -5
  48. package/dist/cjs/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
  49. package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
  50. package/dist/cjs/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
  51. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
  52. package/dist/cjs/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  53. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js +149 -0
  54. package/dist/cjs/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  55. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
  56. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
  57. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js +3 -0
  58. package/dist/cjs/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
  59. package/dist/esm/src/mods/impls.d.ts +2 -2
  60. package/dist/esm/src/mods/impls.d.ts.map +1 -1
  61. package/dist/esm/src/mods/impls.js +1 -1
  62. package/dist/esm/src/mods/impls.js.map +1 -1
  63. package/dist/esm/src/mods/users.d.ts +2 -1
  64. package/dist/esm/src/mods/users.d.ts.map +1 -1
  65. package/dist/esm/src/package-json.js +4 -4
  66. package/dist/esm/src/package-json.js.map +1 -1
  67. package/dist/esm/src/schemas/call-events.d.ts +20 -0
  68. package/dist/esm/src/schemas/call-events.d.ts.map +1 -0
  69. package/dist/esm/src/schemas/call-events.js +4 -0
  70. package/dist/esm/src/schemas/call-events.js.map +1 -0
  71. package/dist/esm/src/schemas/mod.d.ts +3 -2
  72. package/dist/esm/src/schemas/mod.d.ts.map +1 -1
  73. package/dist/esm/src/schemas/mod.js +2 -1
  74. package/dist/esm/src/schemas/mod.js.map +1 -1
  75. package/dist/esm/src/schemas/wechaty-events.d.ts +23 -2
  76. package/dist/esm/src/schemas/wechaty-events.d.ts.map +1 -1
  77. package/dist/esm/src/schemas/wechaty-events.js +6 -0
  78. package/dist/esm/src/schemas/wechaty-events.js.map +1 -1
  79. package/dist/esm/src/user-modules/call.d.ts +158 -0
  80. package/dist/esm/src/user-modules/call.d.ts.map +1 -1
  81. package/dist/esm/src/user-modules/call.js +289 -1
  82. package/dist/esm/src/user-modules/call.js.map +1 -1
  83. package/dist/esm/src/user-modules/call.spec.d.ts +7 -0
  84. package/dist/esm/src/user-modules/call.spec.d.ts.map +1 -0
  85. package/dist/esm/src/user-modules/call.spec.js +734 -0
  86. package/dist/esm/src/user-modules/call.spec.js.map +1 -0
  87. package/dist/esm/src/user-modules/contact.d.ts +16 -0
  88. package/dist/esm/src/user-modules/contact.d.ts.map +1 -1
  89. package/dist/esm/src/user-modules/contact.js +16 -0
  90. package/dist/esm/src/user-modules/contact.js.map +1 -1
  91. package/dist/esm/src/user-modules/mod.d.ts +5 -4
  92. package/dist/esm/src/user-modules/mod.d.ts.map +1 -1
  93. package/dist/esm/src/user-modules/mod.js +2 -2
  94. package/dist/esm/src/user-modules/mod.js.map +1 -1
  95. package/dist/esm/src/wechaty/wechaty-base.d.ts +66 -7
  96. package/dist/esm/src/wechaty/wechaty-base.d.ts.map +1 -1
  97. package/dist/esm/src/wechaty/wechaty-base.js +28 -0
  98. package/dist/esm/src/wechaty/wechaty-base.js.map +1 -1
  99. package/dist/esm/src/wechaty/wechaty-impl.spec.js +10 -8
  100. package/dist/esm/src/wechaty/wechaty-impl.spec.js.map +1 -1
  101. package/dist/esm/src/wechaty-mixins/gerror-mixin.d.ts +1 -1
  102. package/dist/esm/src/wechaty-mixins/io-mixin.d.ts +2 -2
  103. package/dist/esm/src/wechaty-mixins/login-mixin.d.ts +15 -5
  104. package/dist/esm/src/wechaty-mixins/login-mixin.d.ts.map +1 -1
  105. package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts +15 -5
  106. package/dist/esm/src/wechaty-mixins/misc-mixin.d.ts.map +1 -1
  107. package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts +32 -6
  108. package/dist/esm/src/wechaty-mixins/plugin-mixin.d.ts.map +1 -1
  109. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts +39 -5
  110. package/dist/esm/src/wechaty-mixins/puppet-mixin.d.ts.map +1 -1
  111. package/dist/esm/src/wechaty-mixins/puppet-mixin.js +149 -0
  112. package/dist/esm/src/wechaty-mixins/puppet-mixin.js.map +1 -1
  113. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts +5 -3
  114. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.d.ts.map +1 -1
  115. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js +4 -1
  116. package/dist/esm/src/wechaty-mixins/wechatify-user-module-mixin.js.map +1 -1
  117. package/package.json +3 -3
  118. package/src/mods/impls.ts +2 -0
  119. package/src/mods/users.ts +6 -0
  120. package/src/package-json.ts +4 -4
  121. package/src/schemas/call-events.ts +35 -0
  122. package/src/schemas/mod.ts +6 -0
  123. package/src/schemas/wechaty-events.ts +28 -0
  124. package/src/user-modules/call.spec.ts +929 -0
  125. package/src/user-modules/call.ts +373 -0
  126. package/src/user-modules/contact.ts +18 -0
  127. package/src/user-modules/mod.ts +11 -0
  128. package/src/wechaty/wechaty-base.ts +40 -1
  129. package/src/wechaty/wechaty-impl.spec.ts +4 -0
  130. package/src/wechaty-mixins/puppet-mixin.ts +184 -0
  131. package/src/wechaty-mixins/wechatify-user-module-mixin.ts +6 -0
@@ -0,0 +1,929 @@
1
+ #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
+ /**
3
+ * Tests for the Call first-class object and its lifecycle state machine
4
+ * against the @juzi/wechaty-puppet@^1.0.138 contract.
5
+ */
6
+
7
+ import {
8
+ test,
9
+ sinon,
10
+ } from 'tstest'
11
+
12
+ import * as PUPPET from '@juzi/wechaty-puppet'
13
+ import { PuppetMock } from '@juzi/wechaty-puppet-mock'
14
+ import { WechatyBuilder } from '../wechaty-builder.js'
15
+ import type { CallInterface } from './call.js'
16
+ import type { ContactImpl, ContactInterface } from './contact.js'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const sandbox = sinon.createSandbox()
23
+
24
+ function buildWechaty () {
25
+ const puppet = new PuppetMock() as any
26
+ const wechaty = WechatyBuilder.build({ puppet })
27
+ return { puppet, wechaty }
28
+ }
29
+
30
+ function stubCallPayload (puppet: any, factory: (callId: string) => PUPPET.payloads.Call) {
31
+ const stub = sandbox.stub().callsFake(async (callId: string) => {
32
+ await new Promise(setImmediate)
33
+ return factory(callId)
34
+ })
35
+ puppet.callPayload = stub
36
+ // Used by Call.sync()
37
+ puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
38
+ return stub
39
+ }
40
+
41
+ async function startAndLogin (puppet: any, wechaty: any, userId = 'bot-self') {
42
+ sandbox.stub(puppet, 'contactPayload').callsFake(async (id: string) => {
43
+ await new Promise(setImmediate)
44
+ return { id, name: id } as PUPPET.payloads.Contact
45
+ })
46
+ sandbox.stub(puppet, 'contactSearch').callsFake(async (...args: any[]) => {
47
+ await new Promise(setImmediate)
48
+ return [ args[0]?.id ?? userId ]
49
+ })
50
+ await wechaty.start()
51
+ await puppet.login(userId)
52
+ }
53
+
54
+ function flush (ticks = 8): Promise<void> {
55
+ let p = Promise.resolve()
56
+ for (let i = 0; i < ticks; i++) {
57
+ p = p.then(() => new Promise(resolve => setImmediate(resolve)))
58
+ }
59
+ return p
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // 1. bot.call() — outgoing, mints callId via puppet.callInvite, hydrates payload
64
+ // ---------------------------------------------------------------------------
65
+
66
+ test('bot.call() returns an outgoing Call with status=calling, hydrated payload', async t => {
67
+ const { puppet, wechaty } = buildWechaty()
68
+ await startAndLogin(puppet, wechaty)
69
+
70
+ const CALL_ID = 'call-id-minted-by-protocol'
71
+ const PEER_A_ID = 'peer-a'
72
+ const PEER_B_ID = 'peer-b'
73
+ const START_TS = 1_700_000_000_000
74
+
75
+ const callInviteStub = sandbox.stub().resolves(CALL_ID)
76
+ puppet.callInvite = callInviteStub
77
+
78
+ stubCallPayload(puppet, (id: string) => ({
79
+ id,
80
+ starter : 'bot-self',
81
+ participants : [ PEER_A_ID, PEER_B_ID ],
82
+ media : PUPPET.types.CallMediaType.Video,
83
+ startTime : START_TS,
84
+ }))
85
+
86
+ const contactA = (wechaty.Contact as typeof ContactImpl).load(PEER_A_ID)
87
+ const contactB = (wechaty.Contact as typeof ContactImpl).load(PEER_B_ID)
88
+
89
+ const call: CallInterface = await (wechaty as any).call(
90
+ [ contactA, contactB ],
91
+ { media: PUPPET.types.CallMediaType.Video },
92
+ )
93
+
94
+ t.equal(call.id, CALL_ID, 'call.id should match callInvite return')
95
+ t.equal(call.direction(), 'outgoing', 'direction should be outgoing')
96
+ t.equal(call.status(), 'calling', 'status should be calling')
97
+ t.equal(call.media(), PUPPET.types.CallMediaType.Video, 'media should reflect payload')
98
+ t.same(call.startTime(), new Date(START_TS), 'startTime should match payload')
99
+ t.equal(call.endTime(), undefined, 'endTime should be undefined for a live call')
100
+
101
+ const participants = await call.participants()
102
+ t.same(participants.map(c => c.id).sort(), [ PEER_A_ID, PEER_B_ID ].sort(), 'participants should match payload')
103
+
104
+ t.ok(callInviteStub.calledOnce, 'puppet.callInvite should be called once')
105
+ t.same(
106
+ callInviteStub.firstCall.args,
107
+ [ [ PEER_A_ID, PEER_B_ID ], PUPPET.types.CallMediaType.Video ],
108
+ 'callInvite args should be (contactIds, media)',
109
+ )
110
+
111
+ await wechaty.stop()
112
+ sandbox.restore()
113
+ })
114
+
115
+ test('bot.call() rejects when contacts list is empty', async t => {
116
+ const { puppet, wechaty } = buildWechaty()
117
+ await startAndLogin(puppet, wechaty)
118
+
119
+ await t.rejects(
120
+ (wechaty as any).call([]),
121
+ /at least one contact/,
122
+ 'empty contacts should reject',
123
+ )
124
+
125
+ await wechaty.stop()
126
+ sandbox.restore()
127
+ })
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // 2. contact.call() — 1v1 syntactic sugar over bot.call()
131
+ // ---------------------------------------------------------------------------
132
+
133
+ test('contact.call() delegates to bot.call([this])', async t => {
134
+ const { puppet, wechaty } = buildWechaty()
135
+ await startAndLogin(puppet, wechaty)
136
+
137
+ const CALL_ID = 'call-id-sugar'
138
+ const PEER_ID = 'peer-sugar'
139
+
140
+ const callInviteStub = sandbox.stub().resolves(CALL_ID)
141
+ puppet.callInvite = callInviteStub
142
+
143
+ stubCallPayload(puppet, (id: string) => ({
144
+ id,
145
+ starter : 'bot-self',
146
+ participants : [ PEER_ID ],
147
+ media : PUPPET.types.CallMediaType.Audio,
148
+ startTime : 1,
149
+ }))
150
+
151
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
152
+ const call = await contact.call()
153
+
154
+ t.equal(call.id, CALL_ID, 'call.id should match callInvite return')
155
+ t.equal(call.media(), PUPPET.types.CallMediaType.Audio, 'default media should be audio')
156
+ t.same(
157
+ callInviteStub.firstCall.args,
158
+ [ [ PEER_ID ], PUPPET.types.CallMediaType.Audio ],
159
+ 'callInvite args should be ([peerId], audio)',
160
+ )
161
+
162
+ await wechaty.stop()
163
+ sandbox.restore()
164
+ })
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // 3. Incoming Invite — bot emits 'call' with a hydrated Call
168
+ // ---------------------------------------------------------------------------
169
+
170
+ test('incoming call: Invite hydrates payload then emits bot.on("call")', async t => {
171
+ const { puppet, wechaty } = buildWechaty()
172
+ await startAndLogin(puppet, wechaty)
173
+
174
+ const CALL_ID = 'call-id-incoming'
175
+ const CALLER_ID = 'caller-incoming'
176
+ const START_TS = 1_700_000_000_999
177
+
178
+ stubCallPayload(puppet, (id: string) => ({
179
+ id,
180
+ starter : CALLER_ID,
181
+ participants : [ CALLER_ID, 'bot-self' ],
182
+ media : PUPPET.types.CallMediaType.Video,
183
+ startTime : START_TS,
184
+ }))
185
+
186
+ let received: CallInterface | undefined
187
+ wechaty.on('call', (c: CallInterface) => { received = c })
188
+
189
+ ;(puppet as any).emit('call', {
190
+ callId : CALL_ID,
191
+ signal : PUPPET.types.CallSignal.Invite,
192
+ contactId : CALLER_ID,
193
+ timestamp : START_TS,
194
+ } as PUPPET.payloads.EventCall)
195
+ await flush()
196
+
197
+ t.ok(received, 'bot should emit call event for Invite')
198
+ t.equal(received!.id, CALL_ID, 'call.id should match')
199
+ t.equal(received!.direction(), 'incoming', 'direction should be incoming')
200
+ t.equal(received!.status(), 'ringing', 'status should be ringing')
201
+ t.equal(received!.media(), PUPPET.types.CallMediaType.Video, 'media should reflect payload')
202
+
203
+ const starter = await received!.starter()
204
+ t.ok(starter, 'starter should be resolvable')
205
+ t.equal(starter!.id, CALLER_ID, 'starter should be the caller')
206
+
207
+ await wechaty.stop()
208
+ sandbox.restore()
209
+ })
210
+
211
+ test('duplicate Invite for the same callId is ignored', async t => {
212
+ const { puppet, wechaty } = buildWechaty()
213
+ await startAndLogin(puppet, wechaty)
214
+
215
+ const CALL_ID = 'call-id-dup'
216
+ const CALLER_ID = 'caller-dup'
217
+
218
+ stubCallPayload(puppet, (id: string) => ({
219
+ id,
220
+ starter : CALLER_ID,
221
+ participants : [ CALLER_ID, 'bot-self' ],
222
+ media : PUPPET.types.CallMediaType.Audio,
223
+ startTime : 1,
224
+ }))
225
+
226
+ let count = 0
227
+ wechaty.on('call', () => { count++ })
228
+
229
+ ;(puppet as any).emit('call', {
230
+ callId : CALL_ID,
231
+ signal : PUPPET.types.CallSignal.Invite,
232
+ contactId : CALLER_ID,
233
+ timestamp : 1,
234
+ } as PUPPET.payloads.EventCall)
235
+ await flush()
236
+ ;(puppet as any).emit('call', {
237
+ callId : CALL_ID,
238
+ signal : PUPPET.types.CallSignal.Invite,
239
+ contactId : CALLER_ID,
240
+ timestamp : 2,
241
+ } as PUPPET.payloads.EventCall)
242
+ await flush()
243
+
244
+ t.equal(count, 1, "'call' should fire once even on duplicate Invite")
245
+
246
+ await wechaty.stop()
247
+ sandbox.restore()
248
+ })
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // 4. Ringing / Accept double-emit on outgoing
252
+ // ---------------------------------------------------------------------------
253
+
254
+ test('outgoing Ringing → object + bot emit; Accept → object + bot emit, status=connected', async t => {
255
+ const { puppet, wechaty } = buildWechaty()
256
+ await startAndLogin(puppet, wechaty)
257
+
258
+ const CALL_ID = 'call-id-outgoing-ringing-accept'
259
+ const PEER_ID = 'peer-ringing-accept'
260
+
261
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
262
+ stubCallPayload(puppet, (id: string) => ({
263
+ id,
264
+ starter : 'bot-self',
265
+ participants : [ 'bot-self', PEER_ID ],
266
+ media : PUPPET.types.CallMediaType.Audio,
267
+ startTime : 1,
268
+ }))
269
+
270
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
271
+ const call = await contact.call()
272
+
273
+ let objectRinging = false
274
+ let botRinging = false
275
+ call.on('ringing', () => { objectRinging = true })
276
+ wechaty.on('call-ringing', (c: CallInterface) => { if (c.id === CALL_ID) botRinging = true })
277
+
278
+ ;(puppet as any).emit('call', {
279
+ callId : CALL_ID,
280
+ signal : PUPPET.types.CallSignal.Ringing,
281
+ contactId : PEER_ID,
282
+ timestamp : 2,
283
+ } as PUPPET.payloads.EventCall)
284
+ await flush()
285
+
286
+ t.ok(objectRinging, "object should emit 'ringing'")
287
+ t.ok(botRinging, "bot should emit 'call-ringing'")
288
+ t.equal(call.status(), 'ringing', 'status should be ringing')
289
+
290
+ let objectActor: ContactInterface | undefined
291
+ let botActor: ContactInterface | undefined
292
+ call.on('accept', actor => { objectActor = actor })
293
+ wechaty.on('call-accept', (c: CallInterface, actor: ContactInterface) => {
294
+ if (c.id === CALL_ID) {
295
+ botActor = actor
296
+ }
297
+ })
298
+
299
+ ;(puppet as any).emit('call', {
300
+ callId : CALL_ID,
301
+ signal : PUPPET.types.CallSignal.Accept,
302
+ contactId : PEER_ID,
303
+ timestamp : 3,
304
+ } as PUPPET.payloads.EventCall)
305
+ await flush()
306
+
307
+ t.ok(objectActor, 'object accept actor should be set')
308
+ t.equal(objectActor!.id, PEER_ID, 'object accept actor should be the peer')
309
+ t.ok(botActor, 'bot accept actor should be set')
310
+ t.equal(botActor!.id, PEER_ID, 'bot accept actor should be the peer')
311
+ t.equal(call.status(), 'connected', 'status should be connected after Accept')
312
+
313
+ await wechaty.stop()
314
+ sandbox.restore()
315
+ })
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // 5. accept() control on incoming
319
+ // ---------------------------------------------------------------------------
320
+
321
+ test('incoming call.accept() invokes puppet.callAccept(callId) and connects', async t => {
322
+ const { puppet, wechaty } = buildWechaty()
323
+ await startAndLogin(puppet, wechaty)
324
+
325
+ const CALL_ID = 'call-id-accept'
326
+ const CALLER_ID = 'caller-accept'
327
+
328
+ stubCallPayload(puppet, (id: string) => ({
329
+ id,
330
+ starter : CALLER_ID,
331
+ participants : [ CALLER_ID, 'bot-self' ],
332
+ media : PUPPET.types.CallMediaType.Audio,
333
+ startTime : 1,
334
+ }))
335
+ const acceptStub = sandbox.stub().resolves(undefined)
336
+ puppet.callAccept = acceptStub
337
+
338
+ let incoming: CallInterface | undefined
339
+ wechaty.on('call', (c: CallInterface) => { incoming = c })
340
+
341
+ ;(puppet as any).emit('call', {
342
+ callId : CALL_ID,
343
+ signal : PUPPET.types.CallSignal.Invite,
344
+ contactId : CALLER_ID,
345
+ timestamp : 1,
346
+ } as PUPPET.payloads.EventCall)
347
+ await flush()
348
+
349
+ t.ok(incoming, 'should receive incoming call')
350
+ await incoming!.accept()
351
+
352
+ t.equal(incoming!.status(), 'connected', 'status should be connected after accept()')
353
+ t.ok(acceptStub.calledOnce, 'puppet.callAccept should be called once')
354
+ t.same(acceptStub.firstCall.args, [ CALL_ID ], 'callAccept args should be [callId]')
355
+
356
+ await wechaty.stop()
357
+ sandbox.restore()
358
+ })
359
+
360
+ test('outgoing call.accept() throws (invalid direction)', async t => {
361
+ const { puppet, wechaty } = buildWechaty()
362
+ await startAndLogin(puppet, wechaty)
363
+
364
+ puppet.callInvite = sandbox.stub().resolves('call-id-outgoing-accept-bad')
365
+ stubCallPayload(puppet, (id: string) => ({
366
+ id,
367
+ starter : 'bot-self',
368
+ participants : [ 'peer' ],
369
+ media : PUPPET.types.CallMediaType.Audio,
370
+ startTime : 1,
371
+ }))
372
+
373
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
374
+ const call = await contact.call()
375
+
376
+ await t.rejects(call.accept(), /invalid/i, 'accept() on outgoing should throw')
377
+
378
+ await wechaty.stop()
379
+ sandbox.restore()
380
+ })
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // 6. reject() control on incoming + Reject signal terminal handling (outgoing 1v1)
384
+ // ---------------------------------------------------------------------------
385
+
386
+ test('incoming call.reject() invokes puppet.callReject(callId, reason) and ends', async t => {
387
+ const { puppet, wechaty } = buildWechaty()
388
+ await startAndLogin(puppet, wechaty)
389
+
390
+ const CALL_ID = 'call-id-reject'
391
+
392
+ stubCallPayload(puppet, (id: string) => ({
393
+ id,
394
+ starter : 'caller-x',
395
+ participants : [ 'caller-x', 'bot-self' ],
396
+ media : PUPPET.types.CallMediaType.Audio,
397
+ startTime : 1,
398
+ }))
399
+ const rejectStub = sandbox.stub().resolves(undefined)
400
+ puppet.callReject = rejectStub
401
+
402
+ let incoming: CallInterface | undefined
403
+ wechaty.on('call', (c: CallInterface) => { incoming = c })
404
+
405
+ ;(puppet as any).emit('call', {
406
+ callId : CALL_ID,
407
+ signal : PUPPET.types.CallSignal.Invite,
408
+ contactId : 'caller-x',
409
+ timestamp : 1,
410
+ } as PUPPET.payloads.EventCall)
411
+ await flush()
412
+
413
+ await incoming!.reject('busy')
414
+ t.equal(incoming!.status(), 'ended', 'status should be ended after reject()')
415
+ t.same(rejectStub.firstCall.args, [ CALL_ID, 'busy' ], 'callReject args should be [callId, reason]')
416
+
417
+ await wechaty.stop()
418
+ sandbox.restore()
419
+ })
420
+
421
+ test('outgoing 1v1 Reject from peer → bot emits call-reject + call-ended, pool cleaned', async t => {
422
+ const { puppet, wechaty } = buildWechaty()
423
+ await startAndLogin(puppet, wechaty)
424
+
425
+ const CALL_ID = 'call-id-outgoing-rejected'
426
+ const PEER_ID = 'peer-rejecter'
427
+
428
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
429
+
430
+ // Initial payload: live; on second pull (sync after Reject) the call has endTime set.
431
+ let pulls = 0
432
+ puppet.callPayload = sandbox.stub().callsFake(async (id: string) => {
433
+ pulls++
434
+ await new Promise(setImmediate)
435
+ return {
436
+ id,
437
+ starter : 'bot-self',
438
+ participants : [ 'bot-self', PEER_ID ],
439
+ media : PUPPET.types.CallMediaType.Audio,
440
+ startTime : 1,
441
+ endTime : pulls === 1 ? undefined : 100,
442
+ } as PUPPET.payloads.Call
443
+ })
444
+ puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
445
+
446
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
447
+ const call = await contact.call()
448
+
449
+ let rejectActor: ContactInterface | undefined
450
+ let rejectReason: string | undefined
451
+ let endedEmitCount = 0
452
+ wechaty.on('call-reject', (c: CallInterface, actor: ContactInterface, reason?: string) => {
453
+ if (c.id === CALL_ID) {
454
+ rejectActor = actor
455
+ rejectReason = reason
456
+ }
457
+ })
458
+ wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) endedEmitCount++ })
459
+
460
+ ;(puppet as any).emit('call', {
461
+ callId : CALL_ID,
462
+ signal : PUPPET.types.CallSignal.Reject,
463
+ contactId : PEER_ID,
464
+ reason : 'busy',
465
+ timestamp : 50,
466
+ } as PUPPET.payloads.EventCall)
467
+ await flush()
468
+
469
+ t.ok(rejectActor, 'call-reject actor should be set')
470
+ t.equal(rejectActor!.id, PEER_ID, 'call-reject actor should be the peer')
471
+ t.equal(rejectReason, 'busy', 'call-reject reason should be passed through')
472
+ t.equal(endedEmitCount, 1, 'call-ended should fire exactly once after Reject terminates the 1v1 call')
473
+ t.equal(call.status(), 'ended', 'status should be ended after terminal Reject')
474
+ t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool')
475
+
476
+ await wechaty.stop()
477
+ sandbox.restore()
478
+ })
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // 7. Cancel signal received by callee → cancel + ended emitted
482
+ // ---------------------------------------------------------------------------
483
+
484
+ test('Cancel signal terminates incoming call; emits cancel + ended; pool cleaned; status flips before cancel handler runs', async t => {
485
+ const { puppet, wechaty } = buildWechaty()
486
+ await startAndLogin(puppet, wechaty)
487
+
488
+ const CALL_ID = 'call-id-cancel'
489
+ const CALLER_ID = 'caller-cancel'
490
+
491
+ stubCallPayload(puppet, (id: string) => ({
492
+ id,
493
+ starter : CALLER_ID,
494
+ participants : [ CALLER_ID, 'bot-self' ],
495
+ media : PUPPET.types.CallMediaType.Audio,
496
+ startTime : 1,
497
+ }))
498
+
499
+ let incoming: CallInterface | undefined
500
+ wechaty.on('call', (c: CallInterface) => { incoming = c })
501
+
502
+ ;(puppet as any).emit('call', {
503
+ callId : CALL_ID,
504
+ signal : PUPPET.types.CallSignal.Invite,
505
+ contactId : CALLER_ID,
506
+ timestamp : 1,
507
+ } as PUPPET.payloads.EventCall)
508
+ await flush()
509
+
510
+ let objectCancelReason: string | undefined
511
+ let botCancelReason: string | undefined
512
+ let statusInCancelHandler: string | undefined
513
+ let endedFiredCount = 0
514
+ incoming!.on('cancel', reason => {
515
+ objectCancelReason = reason
516
+ statusInCancelHandler = incoming!.status()
517
+ })
518
+ wechaty.on('call-cancel', (c: CallInterface, reason?: string) => {
519
+ if (c.id === CALL_ID) {
520
+ botCancelReason = reason
521
+ }
522
+ })
523
+ wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) endedFiredCount++ })
524
+
525
+ ;(puppet as any).emit('call', {
526
+ callId : CALL_ID,
527
+ signal : PUPPET.types.CallSignal.Cancel,
528
+ contactId : CALLER_ID,
529
+ reason : 'caller-aborted',
530
+ timestamp : 2,
531
+ } as PUPPET.payloads.EventCall)
532
+ await flush()
533
+
534
+ t.equal(objectCancelReason, 'caller-aborted', "object 'cancel' reason should match")
535
+ t.equal(botCancelReason, 'caller-aborted', "bot 'call-cancel' reason should match")
536
+ t.equal(endedFiredCount, 1, "'call-ended' should fire exactly once after Cancel (idempotent via __endedEmitted)")
537
+ t.equal(statusInCancelHandler, 'ended', "status should be 'ended' inside the cancel handler (no race window)")
538
+ t.equal(incoming!.status(), 'ended', 'status should be ended')
539
+ t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool')
540
+
541
+ await wechaty.stop()
542
+ sandbox.restore()
543
+ })
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // 8. hangup() control on connected
547
+ // ---------------------------------------------------------------------------
548
+
549
+ test('call.hangup() on connected call invokes puppet.callHangup and ends', async t => {
550
+ const { puppet, wechaty } = buildWechaty()
551
+ await startAndLogin(puppet, wechaty)
552
+
553
+ const CALL_ID = 'call-id-hangup'
554
+ const PEER_ID = 'peer-hangup'
555
+
556
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
557
+ stubCallPayload(puppet, (id: string) => ({
558
+ id,
559
+ starter : 'bot-self',
560
+ participants : [ 'bot-self', PEER_ID ],
561
+ media : PUPPET.types.CallMediaType.Audio,
562
+ startTime : 1,
563
+ }))
564
+ const hangupStub = sandbox.stub().resolves(undefined)
565
+ puppet.callHangup = hangupStub
566
+
567
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
568
+ const call = await contact.call()
569
+
570
+ ;(puppet as any).emit('call', {
571
+ callId : CALL_ID,
572
+ signal : PUPPET.types.CallSignal.Accept,
573
+ contactId : PEER_ID,
574
+ timestamp : 2,
575
+ } as PUPPET.payloads.EventCall)
576
+ await flush()
577
+
578
+ t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
579
+
580
+ await call.hangup('done')
581
+ t.equal(call.status(), 'ended', 'status should be ended after hangup()')
582
+ t.same(hangupStub.firstCall.args, [ CALL_ID, 'done' ], 'callHangup args should be [callId, reason]')
583
+
584
+ await wechaty.stop()
585
+ sandbox.restore()
586
+ })
587
+
588
+ test('call.hangup() rejects when puppet.callHangup fails but local status still ends', async t => {
589
+ const { puppet, wechaty } = buildWechaty()
590
+ await startAndLogin(puppet, wechaty)
591
+
592
+ puppet.callInvite = sandbox.stub().resolves('call-id-hangup-fail')
593
+ stubCallPayload(puppet, (id: string) => ({
594
+ id,
595
+ starter : 'bot-self',
596
+ participants : [ 'bot-self', 'peer' ],
597
+ media : PUPPET.types.CallMediaType.Audio,
598
+ startTime : 1,
599
+ }))
600
+ const hangupStub = sandbox.stub().rejects(new Error('network error'))
601
+ puppet.callHangup = hangupStub
602
+
603
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
604
+ const call = await contact.call()
605
+
606
+ ;(puppet as any).emit('call', {
607
+ callId : call.id,
608
+ signal : PUPPET.types.CallSignal.Accept,
609
+ contactId : 'peer',
610
+ timestamp : 2,
611
+ } as PUPPET.payloads.EventCall)
612
+ await flush()
613
+ t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
614
+
615
+ await t.rejects(call.hangup(), /network error/, 'hangup() should re-throw')
616
+ t.equal(call.status(), 'ended', 'status should still be ended despite failure')
617
+
618
+ await wechaty.stop()
619
+ sandbox.restore()
620
+ })
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // 9. add() invokes puppet.callAdd(callId, contactIds)
624
+ // ---------------------------------------------------------------------------
625
+
626
+ test('call.add() invokes puppet.callAdd with the contact ids', async t => {
627
+ const { puppet, wechaty } = buildWechaty()
628
+ await startAndLogin(puppet, wechaty)
629
+
630
+ const CALL_ID = 'call-id-add'
631
+
632
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
633
+ stubCallPayload(puppet, (id: string) => ({
634
+ id,
635
+ starter : 'bot-self',
636
+ participants : [ 'bot-self', 'peer' ],
637
+ media : PUPPET.types.CallMediaType.Audio,
638
+ startTime : 1,
639
+ }))
640
+ const addStub = sandbox.stub().resolves(undefined)
641
+ puppet.callAdd = addStub
642
+
643
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
644
+ const call = await contact.call()
645
+
646
+ const newContactA = (wechaty.Contact as typeof ContactImpl).load('newA')
647
+ const newContactB = (wechaty.Contact as typeof ContactImpl).load('newB')
648
+
649
+ await call.add([ newContactA, newContactB ])
650
+ t.same(addStub.firstCall.args, [ CALL_ID, [ 'newA', 'newB' ] ], 'callAdd args should be [callId, contactIds]')
651
+
652
+ await wechaty.stop()
653
+ sandbox.restore()
654
+ })
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // 10. mediaEndpoint() pass-through
658
+ // ---------------------------------------------------------------------------
659
+
660
+ test('call.mediaEndpoint() forwards to puppet.callMediaEndpoint', async t => {
661
+ const { puppet, wechaty } = buildWechaty()
662
+ await startAndLogin(puppet, wechaty)
663
+
664
+ const CALL_ID = 'call-id-media'
665
+
666
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
667
+ stubCallPayload(puppet, (id: string) => ({
668
+ id,
669
+ starter : 'bot-self',
670
+ participants : [ 'bot-self', 'peer' ],
671
+ media : PUPPET.types.CallMediaType.Audio,
672
+ startTime : 1,
673
+ }))
674
+ const endpoint: PUPPET.payloads.CallMediaEndpoint = {
675
+ url : 'wss://media.example/sfu',
676
+ token : 'token-xyz',
677
+ }
678
+ const endpointStub = sandbox.stub().resolves(endpoint)
679
+ puppet.callMediaEndpoint = endpointStub
680
+
681
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
682
+ const call = await contact.call()
683
+
684
+ const got = await call.mediaEndpoint()
685
+ t.same(got, endpoint, 'mediaEndpoint should pass through')
686
+ t.same(endpointStub.firstCall.args, [ CALL_ID ], 'callMediaEndpoint args should be [callId]')
687
+
688
+ await wechaty.stop()
689
+ sandbox.restore()
690
+ })
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // 11. wechaty.stop() drains the call pool
694
+ // ---------------------------------------------------------------------------
695
+
696
+ test('wechaty.stop() clears the call pool', async t => {
697
+ const { puppet, wechaty } = buildWechaty()
698
+ await startAndLogin(puppet, wechaty)
699
+
700
+ puppet.callInvite = sandbox.stub().resolves('call-id-stop')
701
+ stubCallPayload(puppet, (id: string) => ({
702
+ id,
703
+ starter : 'bot-self',
704
+ participants : [ 'bot-self', 'peer' ],
705
+ media : PUPPET.types.CallMediaType.Audio,
706
+ startTime : 1,
707
+ }))
708
+
709
+ const contact = (wechaty.Contact as typeof ContactImpl).load('peer')
710
+ await contact.call()
711
+
712
+ t.ok((wechaty as any).__callPool.size > 0, 'pool should be non-empty before stop()')
713
+
714
+ await wechaty.stop()
715
+ t.equal((wechaty as any).__callPool.size, 0, 'pool should be empty after stop()')
716
+
717
+ sandbox.restore()
718
+ })
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // 12. Unknown callId signal is silently dropped (no error event)
722
+ // ---------------------------------------------------------------------------
723
+
724
+ test('signal for unknown callId is dropped, not surfaced as error', async t => {
725
+ const { puppet, wechaty } = buildWechaty()
726
+ await startAndLogin(puppet, wechaty)
727
+
728
+ let errorEmitted = false
729
+ wechaty.on('error', () => { errorEmitted = true })
730
+
731
+ ;(puppet as any).emit('call', {
732
+ callId : 'never-seen',
733
+ signal : PUPPET.types.CallSignal.Hangup,
734
+ contactId : 'someone',
735
+ timestamp : 1,
736
+ } as PUPPET.payloads.EventCall)
737
+ await flush()
738
+
739
+ t.notOk(errorEmitted, 'unknown callId should not emit error')
740
+
741
+ await wechaty.stop()
742
+ sandbox.restore()
743
+ })
744
+
745
+ // ---------------------------------------------------------------------------
746
+ // 13. Local control methods evict from pool + emit object 'ended'
747
+ // ---------------------------------------------------------------------------
748
+
749
+ test("local reject() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
750
+ const { puppet, wechaty } = buildWechaty()
751
+ await startAndLogin(puppet, wechaty)
752
+
753
+ const CALL_ID = 'call-id-local-reject'
754
+ const CALLER_ID = 'caller-local-reject'
755
+
756
+ stubCallPayload(puppet, (id: string) => ({
757
+ id,
758
+ starter : CALLER_ID,
759
+ participants : [ CALLER_ID, 'bot-self' ],
760
+ media : PUPPET.types.CallMediaType.Audio,
761
+ startTime : 1,
762
+ }))
763
+ puppet.callReject = sandbox.stub().resolves(undefined)
764
+
765
+ let incoming: CallInterface | undefined
766
+ wechaty.on('call', (c: CallInterface) => { incoming = c })
767
+
768
+ ;(puppet as any).emit('call', {
769
+ callId : CALL_ID,
770
+ signal : PUPPET.types.CallSignal.Invite,
771
+ contactId : CALLER_ID,
772
+ timestamp : 1,
773
+ } as PUPPET.payloads.EventCall)
774
+ await flush()
775
+
776
+ t.ok(incoming, 'should receive incoming call')
777
+ t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before reject')
778
+
779
+ let endedEmitted = false
780
+ let botEndedCount = 0
781
+ incoming!.on('ended', () => { endedEmitted = true })
782
+ wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
783
+
784
+ await incoming!.reject('busy')
785
+
786
+ t.ok(endedEmitted, "object 'ended' should fire after local reject()")
787
+ t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local reject()")
788
+ t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after reject()')
789
+
790
+ await wechaty.stop()
791
+ sandbox.restore()
792
+ })
793
+
794
+ test("local cancel() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
795
+ const { puppet, wechaty } = buildWechaty()
796
+ await startAndLogin(puppet, wechaty)
797
+
798
+ const CALL_ID = 'call-id-local-cancel'
799
+ const PEER_ID = 'peer-local-cancel'
800
+
801
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
802
+ stubCallPayload(puppet, (id: string) => ({
803
+ id,
804
+ starter : 'bot-self',
805
+ participants : [ 'bot-self', PEER_ID ],
806
+ media : PUPPET.types.CallMediaType.Audio,
807
+ startTime : 1,
808
+ }))
809
+ puppet.callCancel = sandbox.stub().resolves(undefined)
810
+
811
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
812
+ const call = await contact.call()
813
+
814
+ t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before cancel')
815
+
816
+ let endedEmitted = false
817
+ let botEndedCount = 0
818
+ call.on('ended', () => { endedEmitted = true })
819
+ wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
820
+
821
+ await call.cancel()
822
+
823
+ t.ok(endedEmitted, "object 'ended' should fire after local cancel()")
824
+ t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local cancel()")
825
+ t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after cancel()')
826
+
827
+ await wechaty.stop()
828
+ sandbox.restore()
829
+ })
830
+
831
+ test("local hangup() evicts from pool and emits object 'ended' + bot 'call-ended'", async t => {
832
+ const { puppet, wechaty } = buildWechaty()
833
+ await startAndLogin(puppet, wechaty)
834
+
835
+ const CALL_ID = 'call-id-local-hangup'
836
+ const PEER_ID = 'peer-local-hangup'
837
+
838
+ puppet.callInvite = sandbox.stub().resolves(CALL_ID)
839
+ stubCallPayload(puppet, (id: string) => ({
840
+ id,
841
+ starter : 'bot-self',
842
+ participants : [ 'bot-self', PEER_ID ],
843
+ media : PUPPET.types.CallMediaType.Audio,
844
+ startTime : 1,
845
+ }))
846
+ puppet.callHangup = sandbox.stub().resolves(undefined)
847
+
848
+ const contact = (wechaty.Contact as typeof ContactImpl).load(PEER_ID)
849
+ const call = await contact.call()
850
+
851
+ ;(puppet as any).emit('call', {
852
+ callId : CALL_ID,
853
+ signal : PUPPET.types.CallSignal.Accept,
854
+ contactId : PEER_ID,
855
+ timestamp : 2,
856
+ } as PUPPET.payloads.EventCall)
857
+ await flush()
858
+
859
+ t.equal(call.status(), 'connected', 'prerequisite: call should be connected')
860
+ t.ok((wechaty as any).__callPool.has(CALL_ID), 'pool should contain call before hangup')
861
+
862
+ let endedEmitted = false
863
+ let botEndedCount = 0
864
+ call.on('ended', () => { endedEmitted = true })
865
+ wechaty.on('call-ended', (c: CallInterface) => { if (c.id === CALL_ID) botEndedCount++ })
866
+
867
+ await call.hangup('done')
868
+
869
+ t.ok(endedEmitted, "object 'ended' should fire after local hangup()")
870
+ t.equal(botEndedCount, 1, "bot 'call-ended' should fire exactly once after local hangup()")
871
+ t.notOk((wechaty as any).__callPool.has(CALL_ID), 'call should be evicted from pool after hangup()')
872
+
873
+ await wechaty.stop()
874
+ sandbox.restore()
875
+ })
876
+
877
+ // ---------------------------------------------------------------------------
878
+ // 14. dirty(Call) refreshes user-layer __payload
879
+ // ---------------------------------------------------------------------------
880
+
881
+ test('dirty(Call) refreshes user-layer payload so getters see new value', async t => {
882
+ const { puppet, wechaty } = buildWechaty()
883
+ await startAndLogin(puppet, wechaty)
884
+
885
+ const CALL_ID = 'call-id-dirty'
886
+ const CALLER_ID = 'caller-dirty'
887
+
888
+ // First pull returns Audio; subsequent pulls return Video, simulating a
889
+ // server-side media switch (e.g. add() upgraded the call).
890
+ let pulls = 0
891
+ puppet.callPayload = sandbox.stub().callsFake(async (id: string) => {
892
+ pulls++
893
+ await new Promise(setImmediate)
894
+ return {
895
+ id,
896
+ starter : CALLER_ID,
897
+ participants : [ CALLER_ID, 'bot-self' ],
898
+ media : pulls === 1 ? PUPPET.types.CallMediaType.Audio : PUPPET.types.CallMediaType.Video,
899
+ startTime : 1,
900
+ } as PUPPET.payloads.Call
901
+ })
902
+ puppet.callPayloadDirty = sandbox.stub().resolves(undefined)
903
+
904
+ let incoming: CallInterface | undefined
905
+ wechaty.on('call', (c: CallInterface) => { incoming = c })
906
+
907
+ ;(puppet as any).emit('call', {
908
+ callId : CALL_ID,
909
+ signal : PUPPET.types.CallSignal.Invite,
910
+ contactId : CALLER_ID,
911
+ timestamp : 1,
912
+ } as PUPPET.payloads.EventCall)
913
+ await flush()
914
+
915
+ t.ok(incoming, 'should receive incoming call')
916
+ t.equal(incoming!.media(), PUPPET.types.CallMediaType.Audio, 'initial media should be Audio')
917
+
918
+ ;(puppet as any).emit('dirty', {
919
+ payloadType : PUPPET.types.Payload.Call,
920
+ payloadId : CALL_ID,
921
+ } as PUPPET.payloads.EventDirty)
922
+ await flush()
923
+
924
+ t.equal(incoming!.media(), PUPPET.types.CallMediaType.Video, 'media should reflect refreshed payload after dirty')
925
+ t.ok((puppet.callPayloadDirty as any).called, 'puppet.callPayloadDirty should be invoked')
926
+
927
+ await wechaty.stop()
928
+ sandbox.restore()
929
+ })