pushEventAdapter.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { mkdtempSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import type { APNsRequest } from '../../src/server/apns.js'
  6. import type { APNsProviderConfig } from '../../src/server/config.js'
  7. import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
  8. import { PushCoordinator } from '../../src/server/pushCoordinator.js'
  9. import { PushCoordinatorEventAdapter } from '../../src/server/pushEventAdapter.js'
  10. const roots: string[] = []
  11. afterEach(() => {
  12. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  13. })
  14. describe('push event reset reconciliation', () => {
  15. it('recovers only post-subscription terminal messages and pending interactions before advancing cursor', async () => {
  16. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-reconcile-'))
  17. roots.push(home)
  18. let now = 1_000_000
  19. const requests: APNsRequest[] = []
  20. const apns: APNsProviderConfig = {
  21. keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
  22. }
  23. const push = new PushCoordinator({
  24. home, apns, autoFlush: false, now: () => now,
  25. provider: { send: async request => { requests.push(request); return { disposition: 'success', status: 200 } } },
  26. })
  27. push.registerInstallation({
  28. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  29. deviceToken: 'ab'.repeat(32), environment: 'development',
  30. })
  31. push.setGroupSubscription('user-a', 'room-1', true, 1)
  32. now += 10_000
  33. const upstreamSession = {
  34. request: async (path: string) => {
  35. if (path.endsWith('/messages')) {
  36. return {
  37. status: 200,
  38. body: Buffer.from(JSON.stringify({ items: [
  39. { id: 'old-message', seq: 1, senderKind: 'agent', senderName: '旧 Agent', status: 'completed', content: '旧消息', updatedAt: 900 },
  40. { id: 'new-message', seq: 2, topicId: 'topic-1', senderKind: 'agent', senderName: '新 Agent', status: 'completed', content: '断线期间回复', updatedAt: 1_005 },
  41. ] })),
  42. }
  43. }
  44. return {
  45. status: 200,
  46. body: Buffer.from(JSON.stringify({
  47. room: {
  48. id: 'room-1', name: '项目团队',
  49. pendingInteractions: [{
  50. id: 'interaction-1', kind: 'clarification', status: 'pending', topicId: 'topic-1',
  51. updatedAt: 1_006, payload: { question: '请选择方案' },
  52. }],
  53. },
  54. })),
  55. }
  56. },
  57. } as unknown as UpstreamServiceSession
  58. const adapter = new PushCoordinatorEventAdapter(push, upstreamSession)
  59. await expect(adapter.resetGroupCursor({
  60. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 42, reason: 'cursor_expired',
  61. })).resolves.toEqual({ epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 42 })
  62. expect(push.status().pendingCount).toBe(2)
  63. await expect(push.flushDue()).resolves.toMatchObject({ delivered: 2 })
  64. expect(requests).toHaveLength(2)
  65. expect(requests.map(request => request.payload)).toEqual(expect.arrayContaining([
  66. expect.objectContaining({ eventId: 'group-message:new-message:completed', roomId: 'room-1', messageId: 'new-message' }),
  67. expect.objectContaining({ eventId: 'group-interaction:interaction-1', roomId: 'room-1', interactionId: 'interaction-1' }),
  68. ]))
  69. expect(JSON.stringify(requests)).not.toContain('old-message')
  70. await adapter.resetGroupCursor({
  71. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 43, reason: 'cursor_expired',
  72. })
  73. expect(push.status().pendingCount).toBe(0)
  74. push.close()
  75. })
  76. it('ignores a lagged live event older than the current subscription', () => {
  77. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-lagged-'))
  78. roots.push(home)
  79. let now = 2_000_000
  80. const apns: APNsProviderConfig = {
  81. keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
  82. }
  83. const push = new PushCoordinator({
  84. home, apns, autoFlush: false, now: () => now,
  85. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  86. })
  87. push.registerInstallation({
  88. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  89. deviceToken: 'ab'.repeat(32), environment: 'development',
  90. })
  91. push.setGroupSubscription('user-a', 'room-1', true)
  92. const adapter = new PushCoordinatorEventAdapter(push, {} as UpstreamServiceSession)
  93. expect(adapter.enqueueNotification({
  94. eventID: 'group-message:old:completed', localUserID: 'user-a', kind: 'group.message.completed',
  95. title: '旧消息', body: '旧消息', collapseID: 'old', data: {}, roomID: 'room-1',
  96. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 1, occurredAt: now - 10_000,
  97. })).toBe('ignored')
  98. expect(push.status().pendingCount).toBe(0)
  99. now += 10_000
  100. expect(adapter.enqueueNotification({
  101. eventID: 'group-message:new:completed', localUserID: 'user-a', kind: 'group.message.completed',
  102. title: '新消息', body: '新消息', collapseID: 'new', data: {}, roomID: 'room-1',
  103. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 2, occurredAt: now,
  104. })).toBe('enqueued')
  105. push.close()
  106. })
  107. it('drops a deleted room subscription instead of wedging the global reset', async () => {
  108. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-deleted-room-'))
  109. roots.push(home)
  110. const apns: APNsProviderConfig = {
  111. keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
  112. }
  113. const push = new PushCoordinator({
  114. home, apns, autoFlush: false,
  115. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  116. })
  117. push.setGroupSubscription('user-a', 'deleted-room', true, 9)
  118. const upstream = {
  119. request: async () => ({ status: 404, body: Buffer.from('{}') }),
  120. } as unknown as UpstreamServiceSession
  121. const adapter = new PushCoordinatorEventAdapter(push, upstream)
  122. await expect(adapter.resetGroupCursor({
  123. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 10, reason: 'epoch_mismatch',
  124. })).resolves.toMatchObject({ cursor: 10 })
  125. expect(push.listGroupSubscriptions('user-a')).toEqual([])
  126. push.close()
  127. })
  128. it('walks backward from the latest page when a room baseline is zero', async () => {
  129. const home = mkdtempSync(join(tmpdir(), 'yaoyao-push-zero-baseline-'))
  130. roots.push(home)
  131. const apns: APNsProviderConfig = {
  132. keyFile: join(home, 'unused.p8'), keyId: 'KEY123', teamId: 'TEAM123', topic: 'cn.samien.yaoyao.hermes',
  133. }
  134. const push = new PushCoordinator({
  135. home, apns, autoFlush: false,
  136. provider: { send: async () => ({ disposition: 'success', status: 200 }) },
  137. })
  138. push.setGroupSubscription('user-a', 'room-zero', true, 0)
  139. const searches: string[] = []
  140. const message = (seq: number) => ({
  141. id: `message-${seq}`, seq, senderKind: 'agent', senderName: 'Agent', status: 'completed', content: `消息 ${seq}`,
  142. })
  143. const upstream = {
  144. request: async (path: string, options?: { search?: URLSearchParams }) => {
  145. if (!path.endsWith('/messages')) {
  146. return { status: 200, body: Buffer.from('{"room":{"id":"room-zero","name":"零基线团队"}}') }
  147. }
  148. const search = options?.search?.toString() ?? ''
  149. searches.push(search)
  150. const before = Number(options?.search?.get('beforeSeq') ?? 0)
  151. const items = before === 51
  152. ? Array.from({ length: 50 }, (_, index) => message(index + 1))
  153. : Array.from({ length: 100 }, (_, index) => message(index + 51))
  154. return { status: 200, body: Buffer.from(JSON.stringify({ items })) }
  155. },
  156. } as unknown as UpstreamServiceSession
  157. const adapter = new PushCoordinatorEventAdapter(push, upstream)
  158. await adapter.resetGroupCursor({
  159. epoch: '123e4567-e89b-42d3-a456-426614174000', cursor: 150, reason: 'cursor_expired',
  160. })
  161. expect(searches).toEqual(['limit=100', 'limit=100&beforeSeq=51'])
  162. expect(push.listGroupSubscriptions('user-a')[0]?.lastMessageSeq).toBe(150)
  163. push.close()
  164. })
  165. })