chatCacheRoutes.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import request from 'supertest'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
  8. import type { ServerConfig } from '../../src/server/config.js'
  9. const homes: string[] = []
  10. const runtimes: ApplicationRuntime[] = []
  11. const host = '127.0.0.1:15300'
  12. const origin = `http://${host}`
  13. function cookie(response: request.Response): string {
  14. const values = response.headers['set-cookie'] as unknown as string[] | undefined
  15. return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
  16. }
  17. function config(home: string): ServerConfig {
  18. return {
  19. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
  20. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  21. mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
  22. chatCacheMode: 'prefer-local',
  23. }
  24. }
  25. afterEach(() => {
  26. for (const runtime of runtimes.splice(0)) runtime.close()
  27. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  28. })
  29. describe('source=web chat cache routes', () => {
  30. it('uses the Web-owned session registry when Hermes source metadata is stale', async () => {
  31. const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-chat-routes-'))
  32. homes.push(home)
  33. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  34. const url = new URL(String(input))
  35. const headers = new Headers(init?.headers)
  36. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  37. if (url.pathname === '/') {
  38. return new Response(
  39. '<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>',
  40. )
  41. }
  42. if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') {
  43. return Response.json({}, { status: 401 })
  44. }
  45. if (url.pathname === '/api/profiles') {
  46. return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  47. }
  48. if (url.pathname === '/api/sessions') {
  49. return Response.json({
  50. sessions: [{
  51. id: 'owned-session',
  52. profile: 'default',
  53. source: 'ios',
  54. title: 'Hermes 仍标记为 iOS',
  55. }],
  56. total: 1,
  57. })
  58. }
  59. if (url.pathname === '/api/sessions/owned-session') {
  60. if (init?.method === 'PATCH') return Response.json({ ok: true })
  61. return Response.json({
  62. id: 'owned-session',
  63. profile: 'default',
  64. source: 'ios',
  65. title: 'Hermes 仍标记为 iOS',
  66. })
  67. }
  68. return Response.json({ error: 'missing fixture' }, { status: 404 })
  69. })
  70. const runtime = createApplication({ config: config(home), fetchImpl })
  71. runtimes.push(runtime)
  72. const agent = request.agent(runtime.app.callback())
  73. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  74. const setup = await agent.post('/api/app/setup').set('Host', host)
  75. .set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
  76. .send({ username: 'owner', password: 'fixture-password' }).expect(200)
  77. const owner = String(setup.body.user.id)
  78. runtime.chatCache!.store.recordRoute(
  79. owner,
  80. 'default',
  81. 'owned-session',
  82. 'runtime-owned',
  83. )
  84. const list = await agent
  85. .get('/api/app/sessions?view=chat&profile=default&limit=100')
  86. .set('Host', host)
  87. .expect(200)
  88. expect(list.body.sessions).toContainEqual(expect.objectContaining({
  89. id: 'owned-session',
  90. source: 'web',
  91. title: 'Hermes 仍标记为 iOS',
  92. }))
  93. await agent.patch('/api/app/sessions/owned-session?profile=default')
  94. .set('Host', host).set('Origin', origin)
  95. .set('X-CSRF-Token', setup.body.csrfToken)
  96. .send({ title: 'Web 可继续管理' })
  97. .expect(200)
  98. })
  99. it('serves warm and restarted chat reads locally while history stays upstream-only', async () => {
  100. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-routes-')); homes.push(home)
  101. const counts = { list: 0, detail: 0, messages: 0, history: 0, media: 0 }
  102. const mediaPath = '/Users/test/.hermes/profiles/default/images/result.png'
  103. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  104. const url = new URL(String(input))
  105. const headers = new Headers(init?.headers)
  106. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  107. if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>')
  108. if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') return Response.json({}, { status: 401 })
  109. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  110. if (url.pathname === '/api/sessions' && url.searchParams.get('source') === 'web') {
  111. counts.list++
  112. return Response.json({ sessions: [{ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' }] })
  113. }
  114. if (url.pathname === '/api/profiles/sessions' && url.searchParams.get('exclude_sources')?.includes('web')) {
  115. counts.history++
  116. return Response.json({ sessions: [{ id: 'history-only', source: 'telegram', title: '只读历史' }] })
  117. }
  118. if (url.pathname === '/api/sessions/session-web/messages') {
  119. counts.messages++
  120. return Response.json({
  121. session: { id: 'session-web', profile: 'default', source: 'web', title: '持久会话' },
  122. messages: [{ id: 'message-1', role: 'assistant', content: `持久消息\n![结果](${mediaPath})` }],
  123. pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
  124. })
  125. }
  126. if (url.pathname === '/api/files/download') {
  127. counts.media++
  128. expect(url.searchParams.get('path')).toBe(mediaPath)
  129. return new Response('png-bytes', { headers: { 'content-type': 'image/png' } })
  130. }
  131. if (url.pathname === '/api/sessions/session-web') {
  132. counts.detail++
  133. return Response.json({ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' })
  134. }
  135. return Response.json({ error: 'missing fixture' }, { status: 404 })
  136. })
  137. const first = createApplication({ config: config(home), fetchImpl }); runtimes.push(first)
  138. const agent = request.agent(first.app.callback())
  139. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  140. const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
  141. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
  142. const sessionCookie = cookie(setup)
  143. const listPath = '/api/app/sessions?view=chat&profile=default&limit=100'
  144. await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
  145. await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'local').expect(200)
  146. await agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass')
  147. .expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
  148. const messagesPath = '/api/app/sessions/session-web/messages?profile=default&offset=0&limit=100'
  149. await agent.get(messagesPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
  150. const cachedMessages = await agent.get(messagesPath).set('Host', host)
  151. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  152. expect(cachedMessages.body.messages[0].content).toContain('持久消息')
  153. await agent.get(mediaPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
  154. const cachedMedia = await agent.get(mediaPath).set('Host', host)
  155. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  156. expect(Buffer.from(cachedMedia.body).toString()).toBe('png-bytes')
  157. const rangedMedia = await agent.get(mediaPath).set('Host', host).set('Range', 'bytes=0-2')
  158. .expect('X-Yaoyao-Data-Source', 'local').expect('Content-Range', 'bytes 0-2/9').expect(206)
  159. expect(Buffer.from(rangedMedia.body).toString()).toBe('png')
  160. await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
  161. await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
  162. expect(counts).toMatchObject({ list: 2, messages: 1, history: 1, media: 1 })
  163. const historySnapshots = first.chatCache!.store.db.prepare("SELECT COUNT(*) count FROM chat_snapshots WHERE kind='history'").get() as { count: number }
  164. expect(historySnapshots.count).toBe(0)
  165. first.close(); runtimes.splice(runtimes.indexOf(first), 1)
  166. const offlineFetch = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
  167. const restarted = createApplication({ config: config(home), fetchImpl: offlineFetch }); runtimes.push(restarted)
  168. const offlineList = await request(restarted.app.callback()).get(listPath).set('Host', host).set('Cookie', sessionCookie)
  169. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  170. expect(offlineList.body.sessions[0].title).toBe('持久会话')
  171. const offlineMessages = await request(restarted.app.callback()).get(messagesPath).set('Host', host).set('Cookie', sessionCookie)
  172. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  173. expect(offlineMessages.body.messages[0].content).toContain('持久消息')
  174. const offlineMedia = await request(restarted.app.callback()).get(mediaPath).set('Host', host).set('Cookie', sessionCookie)
  175. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  176. expect(Buffer.from(offlineMedia.body).toString()).toBe('png-bytes')
  177. expect(offlineFetch).not.toHaveBeenCalled()
  178. await request(restarted.app.callback()).get('/api/app/sessions?view=history').set('Host', host).set('Cookie', sessionCookie)
  179. .expect(502)
  180. expect(offlineFetch).toHaveBeenCalled()
  181. })
  182. })