notificationText.test.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import { mkdtempSync, readFileSync, 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 { notificationPlainText } from '../../src/server/notificationText.js'
  6. import { PushCoordinator, type PushSender } from '../../src/server/pushCoordinator.js'
  7. import type { APNsRequest, APNsSendResult } from '../../src/server/apns.js'
  8. const roots: string[] = []
  9. afterEach(() => {
  10. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  11. })
  12. function text(value: string, maximum = 180): string {
  13. return notificationPlainText(value, { fallback: '新消息', maximum })
  14. }
  15. class RecordingSender implements PushSender {
  16. requests: APNsRequest[] = []
  17. async send(request: APNsRequest): Promise<APNsSendResult> {
  18. this.requests.push(request)
  19. return { disposition: 'success', status: 200 }
  20. }
  21. }
  22. describe('notification plain text', () => {
  23. it('keeps emoji and decorative bullets while removing Markdown syntax and link targets', () => {
  24. const result = text(`
  25. ## 部署完成 ✅
  26. - 已发布 **v0.2.15**
  27. - [查看报告](https://example.com/report?token=secret)
  28. - ![运行截图](https://example.com/screenshot.png)
  29. `)
  30. expect(result).toContain('部署完成 ✅')
  31. expect(result).toContain('• 已发布 v0.2.15')
  32. expect(result).toContain('查看报告')
  33. expect(result).toContain('🖼️ 运行截图')
  34. expect(result).not.toMatch(/[#*_`~]|https?:\/\//u)
  35. })
  36. it('summarizes code, paths, attachments, raw URLs, stacks, HTML, and unsafe controls', () => {
  37. const result = text(`
  38. <b>构建失败 ⚠️</b>
  39. \`inline --flag\`
  40. \`\`\`bash
  41. npm run build
  42. \`\`\`
  43. @file:/Users/samien/project/report.pdf
  44. 详情 https://example.com/private
  45. Error: missing /Users/samien/project/config.json
  46. at run (/Users/samien/project/app.js:12:3)
  47. \u001b[31m红色\u001b[0m\u202E
  48. `)
  49. expect(result).toContain('构建失败 ⚠️')
  50. expect(result).toContain('inline --flag')
  51. expect(result).toContain('💻 代码片段')
  52. expect(result).toContain('📎 report.pdf')
  53. expect(result).toContain('📎 config.json')
  54. expect(result).toContain('🔗 链接')
  55. expect(result).toContain('红色')
  56. expect(result).not.toContain('/Users/')
  57. expect(result).not.toContain('at run')
  58. expect(result).not.toContain('\u001b')
  59. expect(result).not.toContain('\u202E')
  60. })
  61. it('is idempotent and truncates only at complete grapheme boundaries', () => {
  62. const source = '✅ 👨‍👩‍👧‍👦 完成 • 报告'
  63. const once = text(source, 8)
  64. const twice = text(once, 8)
  65. expect(twice).toBe(once)
  66. expect(once).not.toContain('�')
  67. expect([...new Intl.Segmenter('zh-CN', { granularity: 'grapheme' }).segment(once)].length).toBeLessThanOrEqual(8)
  68. })
  69. it('sanitizes notification text before persisting and delivering new outbox rows', async () => {
  70. const home = mkdtempSync(join(tmpdir(), 'yaoyao-notification-text-'))
  71. roots.push(home)
  72. const sender = new RecordingSender()
  73. const config = {
  74. keyFile: '/unused.p8', keyId: 'KEY1234567', teamId: 'TEAM123456',
  75. topic: 'cn.samien.yaoyao.hermes', environments: ['development' as const],
  76. }
  77. const coordinator = new PushCoordinator({ home, apns: config, provider: sender, autoFlush: false })
  78. coordinator.registerInstallation({
  79. userId: 'user-a', installationId: 'phone-a', clientAccountId: 'account-a',
  80. deviceToken: 'ab'.repeat(32), environment: 'development',
  81. })
  82. expect(coordinator.enqueue({
  83. eventId: 'event-a', userId: 'user-a', kind: 'chat.completed',
  84. title: '## 完成 ✅', body: '**报告** [下载](https://example.com/secret)',
  85. })).toBe(1)
  86. const statePath = join(home, 'push', 'state.json')
  87. const state = JSON.parse(readFileSync(statePath, 'utf8')) as { outbox: Array<{ title: string; body: string }> }
  88. expect(state.outbox[0]).toMatchObject({ title: '完成 ✅', body: '报告 下载' })
  89. await coordinator.flushDue()
  90. const alert = (sender.requests[0]!.payload.aps as { alert: { title: string; body: string } }).alert
  91. expect(alert).toEqual({ title: '完成 ✅', body: '报告 下载' })
  92. coordinator.close()
  93. })
  94. })