chatCacheRoutes.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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. import { saveFileAccess } from '../../src/server/fileAccess.js'
  10. const homes: string[] = []
  11. const runtimes: ApplicationRuntime[] = []
  12. const host = '127.0.0.1:15300'
  13. const origin = `http://${host}`
  14. function cookie(response: request.Response): string {
  15. const values = response.headers['set-cookie'] as unknown as string[] | undefined
  16. return (values ?? []).map(value => value.split(';', 1)[0]).join('; ')
  17. }
  18. function config(home: string): ServerConfig {
  19. return {
  20. host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:19119'),
  21. allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
  22. mediaOwner: 'test', allowInsecureLan: false, insecureLan: false, production: false,
  23. chatCacheMode: 'prefer-local',
  24. }
  25. }
  26. afterEach(() => {
  27. for (const runtime of runtimes.splice(0)) runtime.close()
  28. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  29. })
  30. describe('source=web chat cache routes', () => {
  31. it('uses the Web-owned session registry when Hermes source metadata is stale', async () => {
  32. const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-chat-routes-'))
  33. homes.push(home)
  34. const listQueries: URLSearchParams[] = []
  35. let upstreamOwnedTitle = 'Hermes 仍标记为 iOS'
  36. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  37. const url = new URL(String(input))
  38. const headers = new Headers(init?.headers)
  39. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  40. if (url.pathname === '/') {
  41. return new Response(
  42. '<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>',
  43. )
  44. }
  45. if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') {
  46. return Response.json({}, { status: 401 })
  47. }
  48. if (url.pathname === '/api/profiles') {
  49. return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  50. }
  51. if (url.pathname === '/api/sessions/search') {
  52. const requestedSource = url.searchParams.get('source')
  53. const results = [
  54. { id: 'unowned-web', profile: 'default', source: 'web', title: '未登记 Web 历史' },
  55. { id: 'owned-session', profile: 'default', source: 'ios', title: 'Hermes 仍标记为 iOS' },
  56. ].filter(session => !requestedSource || session.source === requestedSource)
  57. return Response.json({ results })
  58. }
  59. if (url.pathname === '/api/sessions') {
  60. listQueries.push(new URLSearchParams(url.searchParams))
  61. // Model the real source filter so this fixture cannot pass by returning
  62. // an iOS row to a source=web request.
  63. if (url.searchParams.get('source') === 'web') {
  64. return Response.json({
  65. sessions: [{ id: 'unowned-web', profile: 'default', source: 'web', title: '未登记 Web 历史' }],
  66. total: 1,
  67. })
  68. }
  69. return Response.json({
  70. sessions: [
  71. {
  72. id: 'owned-session',
  73. profile: 'default',
  74. source: 'ios',
  75. title: 'Hermes 仍标记为 iOS',
  76. },
  77. {
  78. id: 'history-only',
  79. profile: 'default',
  80. source: 'ios',
  81. title: '未登记历史',
  82. },
  83. {
  84. id: 'unowned-web',
  85. profile: 'default',
  86. source: 'web',
  87. title: '未登记 Web 历史',
  88. },
  89. ],
  90. total: 3,
  91. })
  92. }
  93. if (url.pathname === '/api/sessions/owned-session') {
  94. if (init?.method === 'PATCH') {
  95. upstreamOwnedTitle = String(JSON.parse(String(init.body)).title ?? upstreamOwnedTitle)
  96. return Response.json({ ok: true })
  97. }
  98. return Response.json({
  99. id: 'owned-session',
  100. profile: 'default',
  101. source: 'ios',
  102. title: upstreamOwnedTitle,
  103. })
  104. }
  105. if (url.pathname === '/api/sessions/owned-session/messages') {
  106. return Response.json({
  107. session: { id: 'owned-session', profile: 'default', source: 'ios', title: 'Hermes 仍标记为 iOS' },
  108. messages: [{ id: 'owned-message', role: 'assistant', content: '已同步' }],
  109. pagination: { total: 1, returned: 1, offset: 0, limit: 100, has_more: false },
  110. })
  111. }
  112. if (url.pathname === '/api/sessions/history-only') {
  113. return Response.json({ id: 'history-only', profile: 'default', source: 'ios', title: '未登记历史' })
  114. }
  115. return Response.json({ error: 'missing fixture' }, { status: 404 })
  116. })
  117. const runtime = createApplication({ config: config(home), fetchImpl })
  118. runtimes.push(runtime)
  119. const agent = request.agent(runtime.app.callback())
  120. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  121. const setup = await agent.post('/api/app/setup').set('Host', host)
  122. .set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
  123. .send({ username: 'owner', password: 'fixture-password' }).expect(200)
  124. const owner = String(setup.body.user.id)
  125. runtime.chatCache!.store.recordRoute(
  126. owner,
  127. 'default',
  128. 'owned-session',
  129. 'runtime-owned',
  130. )
  131. runtime.chatCache!.store.recordCommand(
  132. owner,
  133. 'default',
  134. 'owned-session',
  135. 'session.create',
  136. { source: 'ios' },
  137. )
  138. await runtime.chatCache!.reconcile(owner, 'default', 'owned-session')
  139. const list = await agent
  140. .get('/api/app/sessions?view=chat&profile=default&limit=100')
  141. .set('Host', host)
  142. .expect(200)
  143. expect(list.body.sessions).toEqual([expect.objectContaining({
  144. id: 'owned-session',
  145. source: 'ios',
  146. owned: true,
  147. title: 'Hermes 仍标记为 iOS',
  148. final_unread_count: 1,
  149. })])
  150. const unread = await agent.get('/api/app/sessions/unread?profile=default').set('Host', host).expect(200)
  151. expect(unread.body.sessions).toEqual([expect.objectContaining({
  152. session_id: 'owned-session', final_unread_count: 1,
  153. })])
  154. const history = await agent
  155. .get('/api/app/sessions?view=history&profile=default&limit=100')
  156. .set('Host', host)
  157. .expect(200)
  158. expect(history.body.sessions.map((session: { id: string }) => session.id).sort())
  159. .toEqual(['history-only', 'unowned-web'])
  160. expect(listQueries.length).toBeGreaterThan(0)
  161. expect(listQueries.every(query => !query.has('source'))).toBe(true)
  162. expect(listQueries.every(query => !query.get('exclude_sources')?.split(',').includes('web'))).toBe(true)
  163. expect(listQueries.every(query => query.get('order') === 'recent')).toBe(true)
  164. expect(listQueries.every(query => query.get('archived') === 'exclude')).toBe(true)
  165. const ownedDetail = await agent.get('/api/app/sessions/owned-session?profile=default')
  166. .set('Host', host).expect(200)
  167. expect(ownedDetail.body).toMatchObject({ id: 'owned-session', source: 'ios', owned: true })
  168. const historyDetail = await agent.get('/api/app/sessions/history-only?profile=default&view=history')
  169. .set('Host', host).expect(200)
  170. expect(historyDetail.body).toMatchObject({ id: 'history-only', source: 'ios', owned: false })
  171. await runtime.chatCache!.reconcile(String(setup.body.user.id), 'default', 'owned-session')
  172. const ownedMessages = await agent
  173. .get('/api/app/sessions/owned-session/messages?profile=default&offset=0&limit=100')
  174. .set('Host', host).expect(200)
  175. expect(ownedMessages.body).toMatchObject({
  176. owned: true,
  177. session: { id: 'owned-session', source: 'ios', owned: true },
  178. messages: [{ source_message_id: 'owned-message' }],
  179. })
  180. const searched = await agent.get('/api/app/sessions/search?q=Hermes&profile=default')
  181. .set('Host', host).expect(200)
  182. expect(searched.body.results).toEqual([
  183. expect.objectContaining({ id: 'unowned-web', source: 'web', owned: false }),
  184. expect.objectContaining({ id: 'owned-session', source: 'ios', owned: true }),
  185. ])
  186. const searchedChat = await agent
  187. .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat&source=web&limit=1')
  188. .set('Host', host)
  189. .expect(200)
  190. expect(searchedChat.body).toMatchObject({
  191. total: 1,
  192. results: [expect.objectContaining({ id: 'owned-session', owned: true })],
  193. })
  194. const searchedHistory = await agent
  195. .get('/api/app/sessions/search?q=Hermes&profile=default&view=history&limit=1')
  196. .set('Host', host)
  197. .expect(200)
  198. expect(searchedHistory.body).toMatchObject({
  199. total: 1,
  200. results: [expect.objectContaining({ id: 'unowned-web', owned: false })],
  201. })
  202. const upstreamSearchRequests = fetchImpl.mock.calls
  203. .map(([input]) => new URL(String(input)))
  204. .filter(url => url.pathname === '/api/sessions/search')
  205. expect(upstreamSearchRequests.every(url => url.searchParams.get('limit') === '100')).toBe(true)
  206. expect(upstreamSearchRequests.every(url => !url.searchParams.has('source'))).toBe(true)
  207. runtime.chatCache!.store.recordEvent(owner, 'default', 'owned-session', {
  208. type: 'session.title',
  209. payload: { session_id: 'owned-session', title: '事件权威标题' },
  210. })
  211. const eventTitledSearch = await agent
  212. .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat')
  213. .set('Host', host)
  214. .expect(200)
  215. expect(eventTitledSearch.body.results).toEqual([
  216. expect.objectContaining({ id: 'owned-session', title: '事件权威标题', owned: true }),
  217. ])
  218. const forbiddenSourcePatch = await agent.patch('/api/app/sessions/owned-session?profile=default')
  219. .set('Host', host).set('Origin', origin)
  220. .set('X-CSRF-Token', setup.body.csrfToken)
  221. .send({ source: 'web' })
  222. .expect(400)
  223. expect(forbiddenSourcePatch.body.code).toBe('invalid_session_patch')
  224. await agent.patch('/api/app/sessions/owned-session?profile=default')
  225. .set('Host', host).set('Origin', origin)
  226. .set('X-CSRF-Token', setup.body.csrfToken)
  227. .send({ title: 'Web 可继续管理' })
  228. .expect(200)
  229. const patchCalls = fetchImpl.mock.calls.filter(([, init]) => init?.method === 'PATCH')
  230. expect(patchCalls).toHaveLength(0) // Ordinary presentation state is owned by Web.
  231. const immediateRenamedDetail = await agent
  232. .get('/api/app/sessions/owned-session?profile=default')
  233. .set('Host', host)
  234. .expect(200)
  235. expect(immediateRenamedDetail.body.title).toBe('Web 可继续管理')
  236. // A reconcile may complete immediately after the mutation; it must retain
  237. // the synchronously published title.
  238. await runtime.chatCache!.reconcile(owner, 'default', 'owned-session')
  239. const reconciledDetail = await agent
  240. .get('/api/app/sessions/owned-session?profile=default')
  241. .set('Host', host)
  242. .expect(200)
  243. expect(reconciledDetail.body.title).toBe('Web 可继续管理')
  244. const renamedList = await agent
  245. .get('/api/app/sessions?view=chat&profile=default&limit=100')
  246. .set('Host', host)
  247. .expect(200)
  248. expect(renamedList.body.sessions).toContainEqual(expect.objectContaining({
  249. id: 'owned-session',
  250. title: 'Web 可继续管理',
  251. owned: true,
  252. }))
  253. const renamedSearch = await agent
  254. .get('/api/app/sessions/search?q=Hermes&profile=default&view=chat')
  255. .set('Host', host)
  256. .expect(200)
  257. expect(renamedSearch.body.results).toEqual([
  258. expect.objectContaining({ id: 'owned-session', title: 'Web 可继续管理', owned: true }),
  259. ])
  260. })
  261. it('paginates chat and history after projecting a multi-page mixed upstream list', async () => {
  262. const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-pagination-routes-'))
  263. homes.push(home)
  264. const upstreamSessions = Array.from({ length: 12 }, (_, index) => [
  265. { id: `unowned-${index}`, profile: 'default', source: index % 2 ? 'ios' : 'web', title: `Unowned ${index}` },
  266. ...(index < 8 ? [{ id: `owned-${index}`, profile: 'default', source: index % 2 ? 'web' : 'ios', title: `Owned ${index}` }] : []),
  267. ]).flat()
  268. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  269. const url = new URL(String(input))
  270. const headers = new Headers(init?.headers)
  271. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  272. if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="pagination-token-1234567890123456";</script>')
  273. if (headers.get('x-hermes-session-token') !== 'pagination-token-1234567890123456') {
  274. return Response.json({}, { status: 401 })
  275. }
  276. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  277. if (url.pathname === '/api/sessions') {
  278. const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0))
  279. const limit = 5
  280. return Response.json({
  281. sessions: upstreamSessions.slice(offset, offset + limit),
  282. total: upstreamSessions.length,
  283. offset,
  284. limit,
  285. })
  286. }
  287. return Response.json({ error: 'missing fixture' }, { status: 404 })
  288. })
  289. const runtime = createApplication({ config: config(home), fetchImpl })
  290. runtimes.push(runtime)
  291. const agent = request.agent(runtime.app.callback())
  292. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  293. const setup = await agent.post('/api/app/setup').set('Host', host)
  294. .set('Origin', origin).set('X-CSRF-Token', boot.body.csrfToken)
  295. .send({ username: 'owner', password: 'fixture-password' }).expect(200)
  296. const owner = String(setup.body.user.id)
  297. for (let index = 0; index < 8; index++) {
  298. const id = `owned-${index}`
  299. runtime.chatCache!.store.recordRoute(owner, 'default', id, `runtime-${index}`)
  300. runtime.chatCache!.store.recordCommand(owner, 'default', id, 'session.create', {
  301. source: index % 2 ? 'web' : 'ios',
  302. })
  303. runtime.chatCache!.store.putSnapshot(owner, `detail-${index}`, 'detail', 'default', id, {
  304. status: 200,
  305. headers: new Headers({ 'content-type': 'application/json' }),
  306. body: Buffer.from(JSON.stringify({
  307. id,
  308. profile: 'default',
  309. source: index % 2 ? 'web' : 'ios',
  310. title: `Owned ${index}`,
  311. last_active: 1_000 + index,
  312. })),
  313. })
  314. }
  315. const chat = await agent
  316. .get('/api/app/sessions?view=chat&profile=default&offset=2&limit=3')
  317. .set('Host', host).expect(200)
  318. expect(chat.body).toMatchObject({ total: 8, offset: 2, limit: 3 })
  319. expect(chat.body.sessions.map((session: { id: string }) => session.id))
  320. .toEqual(['owned-5', 'owned-4', 'owned-3'])
  321. const history = await agent
  322. .get('/api/app/sessions?view=history&profile=default&offset=4&limit=4')
  323. .set('Host', host).expect(200)
  324. expect(history.body).toMatchObject({ total: 12, offset: 4, limit: 4 })
  325. expect(history.body.sessions.map((session: { id: string }) => session.id))
  326. .toEqual(['unowned-4', 'unowned-5', 'unowned-6', 'unowned-7'])
  327. })
  328. it('serves warm and restarted chat reads locally while history stays upstream-only', async () => {
  329. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-routes-')); homes.push(home)
  330. saveFileAccess(home, { mode: 'all', folders: [] })
  331. const counts = { list: 0, detail: 0, messages: 0, history: 0, media: 0 }
  332. const mediaPath = '/Users/test/.hermes/profiles/default/images/result.png'
  333. const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
  334. const url = new URL(String(input))
  335. const headers = new Headers(init?.headers)
  336. if (url.pathname === '/api/status') return Response.json({ auth_required: false })
  337. if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="cache-token-1234567890123456";</script>')
  338. if (headers.get('x-hermes-session-token') !== 'cache-token-1234567890123456') return Response.json({}, { status: 401 })
  339. if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default', is_default: true }] })
  340. if (url.pathname === '/api/sessions') {
  341. counts.list++
  342. return Response.json({ sessions: [{ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' }] })
  343. }
  344. if (url.pathname === '/api/profiles/sessions') {
  345. counts.history++
  346. return Response.json({ sessions: [{ id: 'history-only', source: 'telegram', title: '只读历史' }] })
  347. }
  348. if (url.pathname === '/api/sessions/session-web/messages') {
  349. counts.messages++
  350. return Response.json({
  351. session: { id: 'session-web', profile: 'default', source: 'web', title: '持久会话' },
  352. messages: [{ id: 'message-1', role: 'assistant', content: `持久消息\n![结果](${mediaPath})` }],
  353. pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
  354. })
  355. }
  356. if (url.pathname === '/api/files/download') {
  357. counts.media++
  358. expect(url.searchParams.get('path')).toBe(mediaPath)
  359. return new Response('png-bytes', { headers: { 'content-type': 'image/png' } })
  360. }
  361. if (url.pathname === '/api/sessions/session-web') {
  362. counts.detail++
  363. return Response.json({ id: 'session-web', profile: 'default', source: 'web', title: '持久会话' })
  364. }
  365. return Response.json({ error: 'missing fixture' }, { status: 404 })
  366. })
  367. const first = createApplication({ config: config(home), fetchImpl }); runtimes.push(first)
  368. const agent = request.agent(first.app.callback())
  369. const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
  370. const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
  371. .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
  372. const sessionCookie = cookie(setup)
  373. const owner = String(setup.body.user.id)
  374. first.chatCache!.store.recordRoute(owner, 'default', 'session-web', 'runtime-web')
  375. first.chatCache!.store.recordCommand(owner, 'default', 'session-web', 'session.create', { source: 'web' })
  376. await first.chatCache!.reconcile(owner,'default','session-web')
  377. const listPath = '/api/app/sessions?view=chat&profile=default&limit=100'
  378. await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'local').expect(200)
  379. await agent.get(listPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'local').expect(200)
  380. await agent.get(listPath).set('Host', host).set('X-Yaoyao-Cache', 'bypass')
  381. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  382. const messagesPath = '/api/app/sessions/session-web/messages?profile=default&offset=0&limit=100'
  383. await agent.get(messagesPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'local').expect(200)
  384. const cachedMessages = await agent.get(messagesPath).set('Host', host)
  385. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  386. expect(cachedMessages.body.messages[0].content).toContain('持久消息')
  387. await agent.get(mediaPath).set('Host', host).expect('X-Yaoyao-Data-Source', 'upstream').expect(200)
  388. const cachedMedia = await agent.get(mediaPath).set('Host', host)
  389. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  390. expect(Buffer.from(cachedMedia.body).toString()).toBe('png-bytes')
  391. const rangedMedia = await agent.get(mediaPath).set('Host', host).set('Range', 'bytes=0-2')
  392. .expect('X-Yaoyao-Data-Source', 'local').expect('Content-Range', 'bytes 0-2/9').expect(206)
  393. expect(Buffer.from(rangedMedia.body).toString()).toBe('png')
  394. await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
  395. await agent.get('/api/app/sessions?view=history').set('Host', host).expect(200)
  396. expect(counts).toMatchObject({ list: 0, messages: 1, history: 1, media: 1 })
  397. const historySnapshots = first.chatCache!.store.db.prepare("SELECT COUNT(*) count FROM chat_snapshots WHERE kind='history'").get() as { count: number }
  398. expect(historySnapshots.count).toBe(0)
  399. first.close(); runtimes.splice(runtimes.indexOf(first), 1)
  400. const offlineFetch = vi.fn<typeof fetch>(async () => { throw new Error('9119 offline') })
  401. const restarted = createApplication({ config: config(home), fetchImpl: offlineFetch }); runtimes.push(restarted)
  402. const offlineList = await request(restarted.app.callback()).get(listPath).set('Host', host).set('Cookie', sessionCookie)
  403. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  404. expect(offlineList.body.sessions[0].title).toBe('持久会话')
  405. const offlineMessages = await request(restarted.app.callback()).get(messagesPath).set('Host', host).set('Cookie', sessionCookie)
  406. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  407. expect(offlineMessages.body.messages[0].content).toContain('持久消息')
  408. const offlineMedia = await request(restarted.app.callback()).get(mediaPath).set('Host', host).set('Cookie', sessionCookie)
  409. .expect('X-Yaoyao-Data-Source', 'local').expect(200)
  410. expect(Buffer.from(offlineMedia.body).toString()).toBe('png-bytes')
  411. expect(offlineFetch).not.toHaveBeenCalled()
  412. await request(restarted.app.callback()).get('/api/app/sessions?view=history').set('Host', host).set('Cookie', sessionCookie)
  413. .expect(502)
  414. expect(offlineFetch).toHaveBeenCalled()
  415. })
  416. })