chatCacheRoutes.test.ts 26 KB

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