pushCoordinator.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
  7. import type { FCMRequest } from '../../src/server/fcm.js'
  8. import { loadServerConfig, type APNsProviderConfig } from '../../src/server/config.js'
  9. import {
  10. PushCoordinator,
  11. type FCMSender,
  12. type PushSender,
  13. } from '../../src/server/pushCoordinator.js'
  14. const roots: string[] = []
  15. afterEach(() => {
  16. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  17. })
  18. function root(): string {
  19. const value = mkdtempSync(join(tmpdir(), 'yaoyao-push-'))
  20. roots.push(value)
  21. return value
  22. }
  23. const apns: APNsProviderConfig = {
  24. keyFile: '/private/not-read-when-provider-is-injected.p8',
  25. keyId: 'KEY1234567',
  26. teamId: 'TEAM123456',
  27. topic: 'cn.samien.yaoyao.hermes',
  28. }
  29. class FakeSender implements PushSender {
  30. readonly requests: APNsRequest[] = []
  31. readonly results: APNsSendResult[] = []
  32. closed = false
  33. async send(request: APNsRequest): Promise<APNsSendResult> {
  34. this.requests.push(request)
  35. return this.results.shift() ?? { disposition: 'success', status: 200 }
  36. }
  37. close(): void { this.closed = true }
  38. }
  39. function register(coordinator: PushCoordinator, suffix = '1') {
  40. return coordinator.registerInstallation({
  41. userId: 'user-a',
  42. installationId: `phone-${suffix}`,
  43. clientAccountId: 'account-a',
  44. deviceToken: 'ab'.repeat(16 + Number(suffix)),
  45. environment: 'development',
  46. appVersion: '1.2.3',
  47. })
  48. }
  49. describe('push configuration', () => {
  50. it('delivers once to each device and resets only the opened device badge', async () => {
  51. const sender = new FakeSender()
  52. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
  53. register(coordinator, '1')
  54. register(coordinator, '2')
  55. expect(coordinator.enqueue({ eventId: 'shared-reply', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })).toBe(2)
  56. await coordinator.flushDue()
  57. coordinator.resetBadge('user-a', 'phone-1', 'account-a')
  58. expect(coordinator.enqueue({ eventId: 'next-reply', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })).toBe(2)
  59. await coordinator.flushDue()
  60. const badges = sender.requests.slice(2).map(request => (request.payload.aps as { badge: number }).badge).sort()
  61. expect(badges).toEqual([1, 2])
  62. coordinator.close()
  63. })
  64. it('replaces an old installation for the same APNs endpoint and delivers one banner after reinstall', async () => {
  65. const sender = new FakeSender()
  66. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
  67. const endpoint = { userId: 'user-a', deviceToken: 'bc'.repeat(32), environment: 'production' as const }
  68. coordinator.registerInstallation({ ...endpoint, installationId: 'old-native', clientAccountId: 'old-account' })
  69. coordinator.enqueue({ eventId: 'old-pending', userId: 'user-a', kind: 'chat.completed', title: '旧通知', body: '已进入应用' })
  70. coordinator.registerInstallation({ ...endpoint, installationId: 'expo', clientAccountId: 'new-account' })
  71. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 0 })
  72. expect(coordinator.enqueue({ eventId: 'reply', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })).toBe(1)
  73. await coordinator.flushDue()
  74. expect(sender.requests).toHaveLength(1)
  75. expect(sender.requests[0]!.payload).toMatchObject({ clientAccountId: 'new-account', aps: { badge: 1 } })
  76. expect(coordinator.resetBadge('user-a', 'expo', 'new-account')).toBe(0)
  77. coordinator.close()
  78. })
  79. it('does not retire another account, environment, or device when replacing an endpoint', () => {
  80. const coordinator = new PushCoordinator({ home: root(), apns, provider: new FakeSender(), autoFlush: false })
  81. const endpoint = { userId: 'user-a', deviceToken: 'bc'.repeat(32), environment: 'production' as const }
  82. coordinator.registerInstallation({ ...endpoint, installationId: 'phone', clientAccountId: 'a' })
  83. coordinator.registerInstallation({ ...endpoint, userId: 'user-b', installationId: 'phone', clientAccountId: 'b' })
  84. coordinator.registerInstallation({ ...endpoint, environment: 'development', installationId: 'debug', clientAccountId: 'a' })
  85. coordinator.registerInstallation({ ...endpoint, deviceToken: 'de'.repeat(32), installationId: 'other-phone', clientAccountId: 'a' })
  86. coordinator.registerInstallation({ ...endpoint, installationId: 'new-phone-id', clientAccountId: 'new-a' })
  87. expect(coordinator.status().registrationCount).toBe(4)
  88. expect(coordinator.enqueue({ eventId: 'reply', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })).toBe(3)
  89. coordinator.close()
  90. })
  91. it('keeps APNs optional and reports partial configuration without preventing startup', () => {
  92. const home = root()
  93. expect(loadServerConfig({ HERMES_YAOYAO_HOME: home }).apns).toBeUndefined()
  94. const partial = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567' })
  95. expect(partial.apns).toBeUndefined()
  96. expect(partial.apnsConfigurationError).toMatch(/absolute local path/)
  97. })
  98. it('loads a complete provider configuration and the default topic', () => {
  99. const directory = root()
  100. const keyFile = join(directory, 'AuthKey.p8')
  101. const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
  102. writeFileSync(keyFile, privateKey.export({ format: 'pem', type: 'pkcs8' }))
  103. const config = loadServerConfig({
  104. HERMES_YAOYAO_HOME: directory,
  105. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  106. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  107. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  108. })
  109. expect(config.apns).toEqual({
  110. keyFile: realpathSync(keyFile),
  111. keyId: 'KEY1234567',
  112. teamId: 'TEAM123456',
  113. topic: 'cn.samien.yaoyao.hermes',
  114. environments: ['development', 'production'],
  115. })
  116. })
  117. it('disables an unreadable provider key as a capability error', () => {
  118. const keyFile = join(root(), 'AuthKey.p8')
  119. writeFileSync(keyFile, 'not a private key')
  120. const config = loadServerConfig({
  121. HERMES_YAOYAO_HOME: dirname(keyFile),
  122. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  123. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  124. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  125. })
  126. expect(config.apns).toBeUndefined()
  127. expect(config.apnsConfigurationError).toMatch(/ES256 private key/)
  128. })
  129. })
  130. describe('PushCoordinator durable state and delivery', () => {
  131. it('atomically migrates schema 1 APNs state to schema 2 without changing durable recovery data', () => {
  132. const home = root()
  133. const directory = join(home, 'push')
  134. mkdirSync(directory)
  135. const statePath = join(directory, 'state.json')
  136. writeFileSync(statePath, JSON.stringify({
  137. schemaVersion: 1,
  138. installations: [{
  139. userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
  140. deviceToken: 'ab'.repeat(32), environment: 'development', badge: 2, updatedAt: '2026-01-01T00:00:00.000Z',
  141. }],
  142. groupSubscriptions: [{
  143. userId: 'user-a', roomId: 'room-1', enabled: true, updatedAt: '2026-01-01T00:00:00.000Z',
  144. }],
  145. outbox: [{
  146. id: 'outbox-1', eventId: 'pending-event', userId: 'user-a', installationId: 'phone-1',
  147. clientAccountId: 'account-a', kind: 'chat.completed', title: '完成', body: '正文',
  148. data: { sessionId: 'session-1' }, createdAt: 1, expiresAt: 4_000_000_000_000,
  149. attempts: 2, nextAttemptAt: 2_000_000_000_000,
  150. }],
  151. processedEvents: { existing: 123 },
  152. groupWatch: { epoch: 'epoch-1', cursor: 7 },
  153. chatJobs: [],
  154. ambiguousChatSessions: { ambiguous: 456 },
  155. status: { lastSuccessAt: '2026-01-01T00:00:00.000Z' },
  156. }))
  157. const coordinator = new PushCoordinator({ home, autoFlush: false })
  158. const migrated = JSON.parse(readFileSync(statePath, 'utf8')) as Record<string, any>
  159. expect(migrated).toMatchObject({
  160. schemaVersion: 2,
  161. installations: [{ platform: 'ios', environment: 'development' }],
  162. outbox: [{ platform: 'ios', id: 'outbox-1', attempts: 2 }],
  163. processedEvents: { existing: 123 },
  164. groupWatch: { epoch: 'epoch-1', cursor: 7 },
  165. ambiguousChatSessions: { ambiguous: 456 },
  166. providerStatus: { ios: { lastSuccessAt: '2026-01-01T00:00:00.000Z' }, android: {} },
  167. })
  168. expect(migrated).not.toHaveProperty('status')
  169. coordinator.close()
  170. })
  171. it('isolates an unexpected APNs sender exception from the FCM queue and redacts its target', async () => {
  172. const deviceToken = 'ab'.repeat(32)
  173. const fcmRequests: FCMRequest[] = []
  174. const coordinator = new PushCoordinator({
  175. home: root(),
  176. apns,
  177. provider: { send: async () => { throw new Error(`transport leaked ${deviceToken}`) } },
  178. fcm: {
  179. serviceAccountFile: '/private/not-read-when-provider-is-injected.json',
  180. projectId: 'yaoyao-test-project',
  181. packageName: 'cn.samien.yaoyao.hermes',
  182. },
  183. fcmProvider: {
  184. send: async request => {
  185. fcmRequests.push(request)
  186. return { disposition: 'success', status: 200 }
  187. },
  188. },
  189. autoFlush: false,
  190. })
  191. coordinator.registerInstallation({
  192. userId: 'user-a', installationId: 'phone-ios', clientAccountId: 'account-a',
  193. deviceToken, environment: 'development',
  194. })
  195. coordinator.registerInstallation({
  196. platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a',
  197. fid: 'fcm-registration-id-1234567890',
  198. })
  199. expect(coordinator.enqueue({
  200. eventId: 'dual-provider-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文',
  201. })).toBe(2)
  202. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1, retried: 1 })
  203. expect(fcmRequests).toHaveLength(1)
  204. expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
  205. expect(coordinator.status().lastError).not.toContain(deviceToken)
  206. expect(coordinator.fcmStatus()).toMatchObject({ pendingCount: 0, healthy: true })
  207. coordinator.close()
  208. })
  209. it('keeps Android FIDs private and delivers high-priority FCM data independently of APNs', async () => {
  210. const requests: FCMRequest[] = []
  211. const sender: FCMSender = {
  212. send: async request => {
  213. requests.push(request)
  214. return { disposition: 'success', status: 200 }
  215. },
  216. }
  217. const coordinator = new PushCoordinator({
  218. home: root(),
  219. fcm: {
  220. serviceAccountFile: '/private/not-read-when-provider-is-injected.json',
  221. projectId: 'yaoyao-test-project',
  222. packageName: 'cn.samien.yaoyao.hermes',
  223. },
  224. fcmProvider: sender,
  225. autoFlush: false,
  226. })
  227. const fid = 'fcm-registration-id-1234567890'
  228. expect(coordinator.registerInstallation({
  229. platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a',
  230. fid, appVersion: '1.0.0',
  231. })).toEqual(expect.objectContaining({ platform: 'android', installationId: 'phone-android' }))
  232. expect(coordinator.registerInstallation({
  233. platform: 'android', userId: 'user-a', installationId: 'phone-android', clientAccountId: 'account-a', fid,
  234. })).not.toHaveProperty('fid')
  235. expect(coordinator.capabilities()).toMatchObject({
  236. enabled: false,
  237. platforms: { ios: { enabled: false }, android: { enabled: true, provider: 'fcm' } },
  238. })
  239. expect(coordinator.isAnyProviderEnabled()).toBe(true)
  240. expect(coordinator.enqueue({
  241. eventId: 'android-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文',
  242. collapseId: 'chat:1', data: { sessionId: 'session-1' },
  243. })).toBe(1)
  244. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  245. expect(requests).toEqual([expect.objectContaining({
  246. fid,
  247. priority: 'high',
  248. data: expect.objectContaining({
  249. eventId: 'android-event', kind: 'chat.completed', title: '完成', body: '正文',
  250. collapseId: 'chat:1', sessionId: 'session-1',
  251. }),
  252. })])
  253. expect(coordinator.status()).toMatchObject({ configured: false, registrationCount: 0, pendingCount: 0 })
  254. expect(coordinator.fcmStatus()).toMatchObject({ configured: true, healthy: true, registrationCount: 1, pendingCount: 0 })
  255. coordinator.close()
  256. })
  257. it('hot-swaps during an active flush without closing the in-flight provider early', async () => {
  258. const home = root()
  259. let releaseSend!: (value: APNsSendResult) => void
  260. const oldSender: PushSender & { closed: boolean } = {
  261. closed: false,
  262. send: () => new Promise(resolve => { releaseSend = resolve }),
  263. close() { this.closed = true },
  264. }
  265. const replacement = new FakeSender()
  266. const coordinator = new PushCoordinator({
  267. home, apns, provider: oldSender, autoFlush: false,
  268. providerFactory: () => replacement,
  269. })
  270. register(coordinator)
  271. coordinator.enqueue({
  272. eventId: 'active-flush', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
  273. })
  274. const flush = coordinator.flushDue()
  275. const replacementOperation = coordinator.configureAPNs({ ...apns, keyId: 'NEWKEY1234' })
  276. await replacementOperation
  277. expect(oldSender.closed).toBe(false)
  278. expect(coordinator.capabilities().enabled).toBe(true)
  279. releaseSend({ disposition: 'unregister', status: 400, reason: 'BadDeviceToken' })
  280. await flush
  281. await Promise.resolve()
  282. expect(oldSender.closed).toBe(true)
  283. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
  284. await coordinator.flushDue()
  285. expect(replacement.requests).toHaveLength(1)
  286. expect(coordinator.status().pendingCount).toBe(0)
  287. })
  288. it('hot-enables providers and only queues installations from selected environments', async () => {
  289. const home = root()
  290. const senders: FakeSender[] = []
  291. const coordinator = new PushCoordinator({
  292. home,
  293. autoFlush: false,
  294. providerFactory: () => {
  295. const sender = new FakeSender()
  296. senders.push(sender)
  297. return sender
  298. },
  299. })
  300. const changes: boolean[] = []
  301. coordinator.onEnabledChange(enabled => { changes.push(enabled) })
  302. coordinator.registerInstallation({
  303. userId: 'user-a', installationId: 'phone-development', clientAccountId: 'account-a',
  304. deviceToken: 'ab'.repeat(32), environment: 'development',
  305. })
  306. coordinator.registerInstallation({
  307. userId: 'user-a', installationId: 'phone-production', clientAccountId: 'account-a',
  308. deviceToken: 'cd'.repeat(32), environment: 'production',
  309. })
  310. await coordinator.configureAPNs({ ...apns, environments: ['production'] })
  311. expect(coordinator.capabilities()).toMatchObject({ enabled: true, environments: ['production'] })
  312. expect(coordinator.enqueue({
  313. eventId: 'environment-event', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '已完成',
  314. })).toBe(1)
  315. await coordinator.flushDue()
  316. expect(senders[0]!.requests).toHaveLength(1)
  317. expect(senders[0]!.requests[0]!.environment).toBe('production')
  318. await coordinator.configureAPNs()
  319. expect(senders[0]!.closed).toBe(true)
  320. expect(coordinator.capabilities().enabled).toBe(false)
  321. expect(changes).toEqual([true, false])
  322. })
  323. it('persists private registrations, subscriptions, outbox delivery, and badge state', async () => {
  324. const home = root()
  325. const sender = new FakeSender()
  326. const coordinator = new PushCoordinator({ home, apns, provider: sender, autoFlush: false })
  327. expect(coordinator.capabilities()).toMatchObject({
  328. protocolVersion: 1, enabled: true, topic: apns.topic, maxSummaryCharacters: 180,
  329. })
  330. expect(register(coordinator)).not.toHaveProperty('deviceToken')
  331. expect(coordinator.setGroupSubscription('user-a', 'room-1', true)).toMatchObject({ roomId: 'room-1', enabled: true })
  332. expect(coordinator.isGroupSubscribed('user-a', 'room-1')).toBe(true)
  333. expect(coordinator.subscribedUserIds('room-1')).toEqual(['user-a'])
  334. expect(coordinator.enqueue({
  335. eventId: 'event-1',
  336. userId: 'user-a',
  337. kind: 'group.message.completed',
  338. title: '任务完成',
  339. body: '好'.repeat(250),
  340. roomId: 'room-1',
  341. threadId: 'topic-1',
  342. collapseId: 'message-1',
  343. data: { roomId: 'room-1', aps: 'untrusted', version: 99 },
  344. })).toBe(1)
  345. expect(coordinator.enqueue({
  346. eventId: 'event-1', userId: 'user-a', kind: 'group.message.completed', title: '重复', body: '重复',
  347. })).toBe(0)
  348. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1, topic: apns.topic })
  349. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  350. expect(sender.requests).toHaveLength(1)
  351. expect(sender.requests[0]!.payload).toMatchObject({
  352. version: 1,
  353. eventId: 'event-1',
  354. kind: 'group.message.completed',
  355. clientAccountId: 'account-a',
  356. aps: { alert: { title: '任务完成' }, badge: 1, 'thread-id': 'topic-1' },
  357. })
  358. expect(Array.from(((sender.requests[0]!.payload.aps as { alert: { body: string } }).alert.body))).toHaveLength(180)
  359. expect(sender.requests[0]!.payload).not.toHaveProperty('userId')
  360. expect(coordinator.status()).toMatchObject({ healthy: true, pendingCount: 0 })
  361. const statePath = join(home, 'push', 'state.json')
  362. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  363. const stored = readFileSync(statePath, 'utf8')
  364. expect(stored).toContain('ab'.repeat(17))
  365. expect(stored).not.toContain(apns.keyFile)
  366. coordinator.close()
  367. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  368. expect(restored.status()).toMatchObject({ registrationCount: 1, subscriptionCount: 1 })
  369. expect(restored.listGroupSubscriptions('user-a')).toEqual([
  370. expect.objectContaining({ roomId: 'room-1', enabled: true }),
  371. ])
  372. restored.close()
  373. })
  374. it('retries transient failures exponentially and removes invalid registrations', async () => {
  375. const home = root()
  376. let now = 1_000_000
  377. const sender = new FakeSender()
  378. sender.results.push(
  379. { disposition: 'retry', status: 503, reason: 'Shutdown' },
  380. { disposition: 'unregister', status: 410, reason: 'Unregistered' },
  381. )
  382. const coordinator = new PushCoordinator({
  383. home, apns, provider: sender, autoFlush: false, now: () => now, baseRetryMilliseconds: 100,
  384. })
  385. register(coordinator)
  386. coordinator.enqueue({ eventId: 'retry-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  387. await expect(coordinator.flushDue()).resolves.toMatchObject({ retried: 1 })
  388. expect(coordinator.status()).toMatchObject({ pendingCount: 1, healthy: false })
  389. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 0, retried: 0 })
  390. expect(sender.requests).toHaveLength(1)
  391. now += 100
  392. await expect(coordinator.flushDue()).resolves.toMatchObject({ removedRegistrations: 1 })
  393. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  394. coordinator.close()
  395. })
  396. it('does not delete a token that rotated while an APNs request was in flight', async () => {
  397. let release!: (result: APNsSendResult) => void
  398. let started!: () => void
  399. const began = new Promise<void>(resolve => { started = resolve })
  400. const first = new Promise<APNsSendResult>(resolve => { release = resolve })
  401. const requests: APNsRequest[] = []
  402. const sender: PushSender = {
  403. send: async request => {
  404. requests.push(request)
  405. if (requests.length === 1) {
  406. started()
  407. return first
  408. }
  409. return { disposition: 'success', status: 200 }
  410. },
  411. }
  412. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender, autoFlush: false })
  413. register(coordinator)
  414. coordinator.enqueue({ eventId: 'rotated-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  415. const flushing = coordinator.flushDue()
  416. await began
  417. coordinator.registerInstallation({
  418. userId: 'user-a', installationId: 'phone-1', clientAccountId: 'account-a',
  419. deviceToken: 'cd'.repeat(32), environment: 'development', appVersion: '1.2.4',
  420. })
  421. release({ disposition: 'unregister', status: 410, reason: 'Unregistered' })
  422. await expect(flushing).resolves.toMatchObject({ retried: 1, removedRegistrations: 0 })
  423. expect(coordinator.status()).toMatchObject({ registrationCount: 1, pendingCount: 1 })
  424. await expect(coordinator.flushDue()).resolves.toMatchObject({ delivered: 1 })
  425. expect(requests[0]!.deviceToken).not.toBe(requests[1]!.deviceToken)
  426. expect(requests[1]!.deviceToken).toBe('cd'.repeat(32))
  427. coordinator.close()
  428. })
  429. it('fails closed when a user is disabled or their authentication version changes', async () => {
  430. let active = true
  431. let authVersion = 1
  432. const sender = new FakeSender()
  433. const coordinator = new PushCoordinator({
  434. home: root(), apns, provider: sender, autoFlush: false,
  435. isUserActive: () => active,
  436. userAuthorizationVersion: () => authVersion,
  437. })
  438. coordinator.registerInstallation({
  439. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  440. deviceToken: 'ab'.repeat(32), environment: 'development', authorizationVersion: 1,
  441. })
  442. coordinator.enqueue({ eventId: 'version-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  443. authVersion = 2
  444. await expect(coordinator.flushDue()).resolves.toMatchObject({ failed: 1, delivered: 0 })
  445. expect(sender.requests).toHaveLength(0)
  446. expect(coordinator.status()).toMatchObject({ registrationCount: 0, pendingCount: 0 })
  447. coordinator.registerInstallation({
  448. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  449. deviceToken: 'cd'.repeat(32), environment: 'development', authorizationVersion: 2,
  450. })
  451. active = false
  452. expect(coordinator.enqueue({
  453. eventId: 'disabled', userId: 'user-a', kind: 'chat.completed', title: '不应发送', body: '正文',
  454. })).toBe(0)
  455. coordinator.close()
  456. })
  457. it('cancels pending team notifications on unsubscribe and removes all user-owned state', () => {
  458. const home = root()
  459. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  460. register(coordinator)
  461. coordinator.setGroupSubscription('user-a', 'room-1', true)
  462. expect(coordinator.enqueueGroupEvent({
  463. eventID: 'group-1', localUserID: 'user-a', kind: 'group.message.completed',
  464. title: '团队消息', body: '正文', collapseID: 'group-1', data: {}, roomID: 'room-1',
  465. })).toBe('enqueued')
  466. coordinator.setGroupSubscription('user-a', 'room-1', false)
  467. expect(coordinator.status().pendingCount).toBe(0)
  468. coordinator.setGroupSubscription('user-a', 'room-2', true)
  469. const removed = coordinator.removeUser('user-a')
  470. expect(removed).toMatchObject({ registrations: 1, subscriptions: 1 })
  471. expect(coordinator.status()).toMatchObject({ registrationCount: 0, subscriptionCount: 0 })
  472. expect(coordinator.listGroupSubscriptions('user-a')).toEqual([])
  473. coordinator.close()
  474. })
  475. it('persists the global group cursor and resumable chat jobs in the same state file', () => {
  476. const home = root()
  477. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  478. expect(coordinator.resetGroupCursor({ epoch: 'epoch-1', cursor: 10, reason: 'initial' })).toMatchObject({
  479. epoch: 'epoch-1', cursor: 10,
  480. })
  481. coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 11 })
  482. expect(() => coordinator.advanceGroupCursor({ epoch: 'epoch-1', cursor: 9 })).toThrow(/backwards/)
  483. coordinator.saveChatJob({
  484. id: 'job-1', localUserID: 'user-a', profile: 'default', runtimeSessionID: 'runtime-1',
  485. storedSessionID: 'stored-1', requestID: 'request-1', queued: false, phase: 'watching',
  486. submittedAt: 1, expiresAt: Date.now() + 60_000,
  487. })
  488. coordinator.close()
  489. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  490. expect(restored.groupWatchAnchor()).toMatchObject({ epoch: 'epoch-1', cursor: 11 })
  491. expect(restored.pendingChatJobs()).toEqual([expect.objectContaining({ id: 'job-1', localUserID: 'user-a' })])
  492. restored.completeChatJob('job-1')
  493. expect(restored.pendingChatJobs()).toEqual([])
  494. restored.close()
  495. })
  496. it('fails closed when more than one local user has written the same stored session', () => {
  497. const coordinator = new PushCoordinator({ home: root(), apns, provider: new FakeSender(), autoFlush: false })
  498. const job = (id: string, localUserID: string) => ({
  499. id, localUserID, profile: 'default', runtimeSessionID: `runtime-${id}`, storedSessionID: 'shared',
  500. requestID: `request-${id}`, queued: false, phase: 'accepted' as const,
  501. submittedAt: 1, expiresAt: Date.now() + 60_000,
  502. })
  503. const first = job('a', 'user-a')
  504. coordinator.saveChatJob(first)
  505. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(true)
  506. const second = job('b', 'user-b')
  507. coordinator.saveChatJob(second)
  508. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  509. expect(coordinator.chatJobRecoveryAllowed(second)).toBe(false)
  510. coordinator.completeChatJob('b')
  511. expect(coordinator.chatJobRecoveryAllowed(first)).toBe(false)
  512. coordinator.close()
  513. })
  514. it('keeps prompt correlation private and stable across restart', () => {
  515. const home = root()
  516. const first = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  517. const digest = first.promptDigest('user-a', '继续执行')
  518. expect(digest).not.toContain('继续执行')
  519. expect(first.promptDigest('user-b', '继续执行')).not.toBe(digest)
  520. first.close()
  521. const restored = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  522. expect(restored.promptDigest('user-a', '继续执行')).toBe(digest)
  523. expect(statSync(join(home, 'push', 'correlation.key')).mode & 0o777).toBe(0o600)
  524. restored.close()
  525. })
  526. it('automatically flushes newly queued work and closes the injected provider', async () => {
  527. const sender = new FakeSender()
  528. const coordinator = new PushCoordinator({ home: root(), apns, provider: sender })
  529. register(coordinator)
  530. coordinator.enqueue({ eventId: 'auto-1', userId: 'user-a', kind: 'chat.completed', title: '完成', body: '正文' })
  531. await expect.poll(() => sender.requests.length).toBe(1)
  532. expect(coordinator.status().pendingCount).toBe(0)
  533. coordinator.close()
  534. expect(sender.closed).toBe(true)
  535. })
  536. it('protects and preserves a malformed existing state file instead of overwriting it', () => {
  537. const home = root()
  538. const directory = join(home, 'push')
  539. const statePath = join(directory, 'state.json')
  540. mkdirSync(directory)
  541. writeFileSync(statePath, '{malformed', { mode: 0o644, flag: 'w' })
  542. const coordinator = new PushCoordinator({ home, apns, provider: new FakeSender(), autoFlush: false })
  543. expect(coordinator.capabilities()).toMatchObject({ enabled: false })
  544. expect(coordinator.status().lastError).toMatch(/避免覆盖/)
  545. expect(() => register(coordinator)).toThrow(/推送状态文件无法读取/)
  546. expect(readFileSync(statePath, 'utf8')).toBe('{malformed')
  547. expect(statSync(statePath).mode & 0o777).toBe(0o600)
  548. coordinator.close()
  549. })
  550. })