chatCache.test.ts 24 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 { DatabaseSync } from 'node:sqlite'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { ChatCacheCoordinator, ChatCacheStore, chatCacheKey } from '../../src/server/chatCache.js'
  8. import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
  9. import type { UpstreamResponse } from '../../src/server/upstream.js'
  10. const homes: string[] = []
  11. const owner = 'owner-1'
  12. const profile = 'default'
  13. const sessionID = 'session-web'
  14. function response(value: unknown, status = 200, contentType = 'application/json'): UpstreamResponse {
  15. return { status, headers: new Headers({ 'content-type': contentType }), body: Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)) }
  16. }
  17. function fixture() {
  18. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-'))
  19. homes.push(home)
  20. const store = new ChatCacheStore(home)
  21. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  22. return { home, store, coordinator: new ChatCacheCoordinator(store, upstream, 'prefer-local') }
  23. }
  24. afterEach(() => {
  25. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  26. })
  27. describe('durable source=web chat cache', () => {
  28. it('serves a warm list without a second 9119 request and survives restart', async () => {
  29. const f = fixture()
  30. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  31. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  32. const search = new URLSearchParams({ source: 'web', profile })
  33. const key = chatCacheKey('list', profile, undefined, search)
  34. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
  35. const first = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  36. const second = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  37. expect(first.source).toBe('upstream')
  38. expect(second.source).toBe('local')
  39. expect(load).toHaveBeenCalledTimes(1)
  40. f.store.close()
  41. const restored = new ChatCacheStore(f.home)
  42. const cached = restored.snapshot(owner, key)
  43. expect(JSON.parse(cached!.response.body.toString()).sessions[0].title).toBe('本地会话')
  44. restored.close()
  45. })
  46. it('reprojects a pre-ownership list snapshot after restart before serving it', async () => {
  47. const f = fixture()
  48. const search = new URLSearchParams({ profile, offset: '0', limit: '100' })
  49. const key = chatCacheKey('list', profile, undefined, search)
  50. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  51. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'ios' })
  52. f.store.putSnapshot(owner, key, 'list', profile, undefined, response({
  53. sessions: [{ id: sessionID, profile, source: 'ios', title: '旧快照标题' }],
  54. total: 1,
  55. }), true)
  56. f.store.db.prepare(`UPDATE chat_sessions SET authoritative_title=NULL,authoritative_title_at=0
  57. WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
  58. expect(JSON.parse(f.store.snapshot(owner, key)!.response.body.toString()).sessions[0].owned)
  59. .toBeUndefined()
  60. f.store.close()
  61. const restored = new ChatCacheStore(f.home)
  62. expect(restored.snapshot(owner, key)?.state).toBe('stale')
  63. expect((restored.db.prepare(`SELECT authoritative_title FROM chat_sessions
  64. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { authoritative_title: string }).authoritative_title)
  65. .toBe('旧快照标题')
  66. const coordinator = new ChatCacheCoordinator(
  67. restored,
  68. { request: vi.fn() } as unknown as UpstreamServiceSession,
  69. 'prefer-local',
  70. )
  71. const projected = await coordinator.read(
  72. owner,
  73. key,
  74. 'list',
  75. profile,
  76. undefined,
  77. async () => { throw new Error('9119 offline') },
  78. true,
  79. { offset: 0, limit: 100 },
  80. )
  81. expect(projected).toMatchObject({ source: 'local', state: 'stale' })
  82. expect(JSON.parse(projected.response.body.toString()).sessions[0]).toMatchObject({
  83. id: sessionID,
  84. source: 'ios',
  85. owned: true,
  86. })
  87. restored.close()
  88. })
  89. it('adopts the released v0.3.26 registry once without using source for later membership', () => {
  90. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-registry-'))
  91. homes.push(home)
  92. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  93. legacy.exec(`CREATE TABLE chat_sessions(
  94. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  95. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  96. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  97. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  98. PRIMARY KEY(owner,profile,session_id))`)
  99. legacy.prepare(`INSERT INTO chat_sessions(
  100. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  101. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  102. owner,
  103. profile,
  104. 'v026-session',
  105. null,
  106. 'web',
  107. JSON.stringify({ id: 'v026-session', profile, source: 'web', title: '已发布旧会话' }),
  108. 'current',
  109. 0,
  110. 0,
  111. 1_000,
  112. )
  113. legacy.close()
  114. const migrated = new ChatCacheStore(home, owner)
  115. expect(migrated.ownsSession(owner, profile, 'v026-session')).toBe(true)
  116. expect(migrated.db.prepare(`SELECT ownership_evidence FROM chat_sessions
  117. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, 'v026-session'))
  118. .toMatchObject({ ownership_evidence: 'legacy-single-user' })
  119. expect(migrated.putSnapshot(owner, 'new-source-row', 'detail', profile, 'new-source-row',
  120. response({ id: 'new-source-row', profile, source: 'web', title: '未登记新历史' }))).toBe(false)
  121. expect(migrated.ownsSession(owner, profile, 'new-source-row')).toBe(false)
  122. migrated.close()
  123. })
  124. it('does not adopt ambiguous multi-owner legacy cache rows without command evidence', () => {
  125. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-multi-owner-'))
  126. homes.push(home)
  127. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  128. legacy.exec(`CREATE TABLE chat_sessions(
  129. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  130. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  131. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  132. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  133. PRIMARY KEY(owner,profile,session_id));
  134. CREATE TABLE chat_events(
  135. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  136. event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
  137. received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));`)
  138. const insertSession = legacy.prepare(`INSERT INTO chat_sessions(
  139. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  140. ) VALUES(?,?,?,?,?,?,?,?,?,?)`)
  141. for (const [legacyOwner, id] of [
  142. ['owner-a', 'evidenced'],
  143. ['owner-a', 'source-only-a'],
  144. ['owner-b', 'source-only-b'],
  145. ] as const) {
  146. insertSession.run(
  147. legacyOwner,
  148. profile,
  149. id,
  150. null,
  151. 'web',
  152. JSON.stringify({ id, profile, source: 'web', title: id }),
  153. 'current',
  154. 0,
  155. 0,
  156. 1_000,
  157. )
  158. }
  159. legacy.prepare(`INSERT INTO chat_events(
  160. owner,profile,session_id,event_seq,event_type,payload,received_at
  161. ) VALUES(?,?,?,?,?,?,?)`).run(
  162. 'owner-a', profile, 'evidenced', 1, 'command:session.create', '{}', 900,
  163. )
  164. legacy.close()
  165. const migrated = new ChatCacheStore(home, 'owner-a')
  166. expect(migrated.ownsSession('owner-a', profile, 'evidenced')).toBe(true)
  167. expect(migrated.ownsSession('owner-a', profile, 'source-only-a')).toBe(false)
  168. expect(migrated.ownsSession('owner-b', profile, 'source-only-b')).toBe(false)
  169. migrated.close()
  170. })
  171. it('does not adopt one cached owner when the sole local user has another id', () => {
  172. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-owner-mismatch-'))
  173. homes.push(home)
  174. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  175. legacy.exec(`CREATE TABLE chat_sessions(
  176. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  177. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  178. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  179. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  180. PRIMARY KEY(owner,profile,session_id))`)
  181. legacy.prepare(`INSERT INTO chat_sessions(
  182. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  183. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  184. 'cached-old-user', profile, 'source-only', null, 'web',
  185. JSON.stringify({ id: 'source-only', profile, source: 'web', title: '旧缓存' }),
  186. 'current', 0, 0, 1_000,
  187. )
  188. legacy.close()
  189. const migrated = new ChatCacheStore(home, 'current-sole-user')
  190. expect(migrated.ownsSession('cached-old-user', profile, 'source-only')).toBe(false)
  191. migrated.close()
  192. })
  193. it('re-enters ownership migration when columns exist but the completion marker is missing', () => {
  194. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-interrupted-migration-'))
  195. homes.push(home)
  196. const interrupted = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  197. interrupted.exec(`CREATE TABLE chat_sessions(
  198. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  199. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  200. authoritative_title TEXT, authoritative_title_at INTEGER NOT NULL DEFAULT 0,
  201. owned_at INTEGER, ownership_evidence TEXT,
  202. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  203. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  204. PRIMARY KEY(owner,profile,session_id))`)
  205. interrupted.prepare(`INSERT INTO chat_sessions(
  206. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  207. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  208. owner, profile, 'interrupted-row', null, 'web',
  209. JSON.stringify({ id: 'interrupted-row', profile, source: 'web', title: '可恢复旧会话' }),
  210. 'current', 0, 0, 1_000,
  211. )
  212. interrupted.close()
  213. const recovered = new ChatCacheStore(home, owner)
  214. expect(recovered.ownsSession(owner, profile, 'interrupted-row')).toBe(true)
  215. expect(recovered.db.prepare("SELECT value FROM chat_meta WHERE key='ownership-registry-v1'").get())
  216. .toMatchObject({ value: 'complete' })
  217. recovered.close()
  218. })
  219. it('materializes complete message pages and keeps owners isolated', () => {
  220. const f = fixture()
  221. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  222. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  223. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID,
  224. response({ id: sessionID, profile, source: 'web', title: '对话' }))
  225. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  226. session: { id: sessionID, profile, source: 'web', title: '对话' },
  227. messages: [
  228. { id: 'm1', role: 'user', content: '开始' },
  229. { id: 'm2', role: 'assistant', content: '完成' },
  230. ],
  231. pagination: { total: 2, returned: 2, offset: 0, limit: 100, hasMore: false },
  232. }))
  233. const page = f.store.messagePage(owner, profile, sessionID, 0, 100)
  234. const payload = JSON.parse(page!.response.body.toString())
  235. expect(payload.session_id).toBe(sessionID)
  236. expect(payload.pagination).toMatchObject({ total: 2, returned: 2, offset: 0, limit: 100, has_more: false })
  237. expect(payload.messages.map((item: { id: string }) => item.id)).toEqual(['m1', 'm2'])
  238. expect(f.store.messagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
  239. f.store.close()
  240. })
  241. it('never persists unregistered history regardless of source metadata', () => {
  242. const f = fixture()
  243. expect(f.store.putSnapshot(owner, 'history', 'detail', profile, 'history-1',
  244. response({ id: 'history-1', profile, source: 'telegram', title: '历史' }))).toBe(false)
  245. expect(f.store.putSnapshot(owner, 'unowned-web', 'detail', profile, 'unowned-web',
  246. response({ id: 'unowned-web', profile, source: 'web', title: '未登记 Web 历史' }))).toBe(false)
  247. expect(f.store.snapshot(owner, 'history')).toBeUndefined()
  248. expect(f.store.snapshot(owner, 'unowned-web')).toBeUndefined()
  249. f.store.close()
  250. })
  251. it('marks event gaps stale, deduplicates events, and falls back while 9119 is offline', async () => {
  252. const f = fixture()
  253. const key = 'detail-key'
  254. f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
  255. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  256. f.store.putSnapshot(owner, key, 'detail', profile, sessionID,
  257. response({ id: sessionID, profile, source: 'web', title: '稳定内容' }))
  258. const frame = { type: 'message.delta', seq: 2, payload: { text: '增量' } }
  259. f.store.recordEvent(owner, profile, sessionID, frame)
  260. f.store.recordEvent(owner, profile, sessionID, frame)
  261. const count = f.store.db.prepare("SELECT COUNT(*) count FROM chat_events WHERE event_type='message.delta'").get() as { count: number }
  262. expect(count.count).toBe(1)
  263. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
  264. expect(f.store.ownsSession('another-owner', profile, sessionID)).toBe(false)
  265. const route = f.store.db.prepare('SELECT runtime_id FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  266. .get(owner, profile, sessionID) as { runtime_id: string }
  267. expect(route.runtime_id).toBe('runtime-1')
  268. const fallback = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  269. async () => { throw new Error('9119 offline') })
  270. expect(fallback).toMatchObject({ source: 'local', state: 'stale' })
  271. const serverFailure = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  272. async () => response({ error: 'unavailable' }, 503))
  273. expect(serverFailure).toMatchObject({ source: 'local', state: 'stale' })
  274. f.store.close()
  275. })
  276. it.each(['session.title', 'session.title.updated'])(
  277. 'projects %s into the owned summary without changing source metadata',
  278. (eventType) => {
  279. const f = fixture()
  280. const ownedSessionID = `owned-${eventType}`
  281. const listKey = chatCacheKey('list', profile, undefined, new URLSearchParams({ source: 'web', profile }))
  282. f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
  283. f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
  284. f.store.putSnapshot(owner, listKey, 'list', profile, undefined, response({ sessions: [] }), true)
  285. f.store.recordEvent(owner, profile, ownedSessionID, {
  286. type: eventType,
  287. payload: { session_id: ownedSessionID, title: '服务端生成的标题' },
  288. })
  289. expect(f.store.snapshot(owner, listKey)?.state).toBe('stale')
  290. const merged = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [], total: 0 }))
  291. expect(JSON.parse(merged.body.toString()).sessions).toContainEqual(expect.objectContaining({
  292. id: ownedSessionID,
  293. profile,
  294. source: 'ios',
  295. owned: true,
  296. title: '服务端生成的标题',
  297. }))
  298. const staleList = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [{
  299. id: ownedSessionID, profile, source: 'ios', title: '新对话',
  300. }] }))
  301. expect(JSON.parse(staleList.body.toString()).sessions[0].title).toBe('服务端生成的标题')
  302. const titleVersion = f.store.db.prepare(`SELECT authoritative_title_at FROM chat_sessions
  303. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, ownedSessionID) as { authoritative_title_at: number }
  304. expect(f.store.putSnapshot(owner, 'delayed-detail', 'detail', profile, ownedSessionID,
  305. response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }), false,
  306. titleVersion.authoritative_title_at - 1)).toBe(true)
  307. const afterDelayedDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
  308. response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }))
  309. expect(JSON.parse(afterDelayedDetail.body.toString()).title).toBe('服务端生成的标题')
  310. expect(f.store.putSnapshot(owner, 'newer-detail', 'detail', profile, ownedSessionID,
  311. response({ id: ownedSessionID, profile, source: 'ios', title: '后续详情标题' }), false,
  312. titleVersion.authoritative_title_at + 1)).toBe(true)
  313. expect(f.store.putSnapshot(owner, 'regressed-detail', 'detail', profile, ownedSessionID,
  314. response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))).toBe(true)
  315. const afterDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
  316. response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))
  317. expect(JSON.parse(afterDetail.body.toString()).title).toBe('后续详情标题')
  318. f.store.close()
  319. },
  320. )
  321. it('accepts detail and messages for a registered session regardless of source metadata', () => {
  322. const f = fixture()
  323. const ownedSessionID = 'owned-ios-session'
  324. f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
  325. f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
  326. expect(f.store.putSnapshot(owner, 'owned-detail', 'detail', profile, ownedSessionID,
  327. response({ id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' }))).toBe(true)
  328. expect(f.store.putSnapshot(owner, 'owned-messages', 'messages', profile, ownedSessionID, response({
  329. session: { id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' },
  330. messages: [{ id: 'm-ios', role: 'assistant', content: '已同步' }],
  331. pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
  332. }))).toBe(true)
  333. const page = f.store.messagePage(owner, profile, ownedSessionID, 0, 100)
  334. expect(JSON.parse(page!.response.body.toString())).toMatchObject({
  335. session_id: ownedSessionID,
  336. session: { source: 'ios', title: 'iOS 权威标题' },
  337. messages: [{ id: 'm-ios' }],
  338. })
  339. f.store.close()
  340. })
  341. it('caches only attachment paths discovered in source=web messages', () => {
  342. const f = fixture()
  343. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  344. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  345. const sourcePath = '/Users/test/.hermes/profiles/default/images/result.png'
  346. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  347. session: { id: sessionID, profile, source: 'web' },
  348. messages: [{ id: 'm1', role: 'assistant', content: `![结果](${sourcePath})` }],
  349. pagination: { total: 1, offset: 0, hasMore: false },
  350. }))
  351. expect(f.store.knowsAttachment(owner, sourcePath)).toBe(true)
  352. const bytes = response('image-data', 200, 'image/png')
  353. expect(f.store.storeAttachment(owner, sourcePath, bytes)).toBeTruthy()
  354. expect(f.store.attachment(owner, sourcePath)?.mimeType).toBe('image/png')
  355. expect(f.store.knowsAttachment(owner, '/Users/test/history.png')).toBe(false)
  356. f.store.close()
  357. })
  358. it('supports shadow and upstream-only rollback modes', async () => {
  359. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-modes-')); homes.push(home)
  360. const store = new ChatCacheStore(home)
  361. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  362. store.recordRoute(owner, profile, sessionID, 'runtime-web')
  363. store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  364. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web' }] }))
  365. const search = new URLSearchParams({ source: 'web' })
  366. const key = chatCacheKey('list', profile, undefined, search)
  367. const shadow = new ChatCacheCoordinator(store, upstream, 'shadow')
  368. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  369. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  370. expect(load).toHaveBeenCalledTimes(2)
  371. const upstreamOnly = new ChatCacheCoordinator(store, upstream, 'upstream-only')
  372. const upstreamResult = await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
  373. expect(load).toHaveBeenCalledTimes(3)
  374. expect(JSON.parse(upstreamResult.response.body.toString()).sessions).toEqual([
  375. expect.objectContaining({ id: sessionID, owned: true }),
  376. ])
  377. upstreamOnly.route(owner, profile, 'upstream-only-created', 'runtime-upstream-only')
  378. expect(store.ownsSession(owner, profile, 'upstream-only-created')).toBe(true)
  379. store.close()
  380. })
  381. it('paginates the complete owner registry before slicing regardless of mixed upstream history', () => {
  382. const f = fixture()
  383. for (let index = 0; index < 15; index++) {
  384. const id = `owned-${index}`
  385. const source = index % 2 ? 'ios' : 'web'
  386. f.store.recordRoute(owner, profile, id, `runtime-${index}`)
  387. f.store.recordCommand(owner, profile, id, 'session.create', { source })
  388. expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
  389. id,
  390. profile,
  391. source,
  392. title: `Owned ${index}`,
  393. last_active: 1_000 + index,
  394. }))).toBe(true)
  395. }
  396. const upstreamSessions = [
  397. ...Array.from({ length: 40 }, (_, index) => ({
  398. id: `unowned-${index}`,
  399. profile,
  400. source: index % 2 ? 'ios' : 'web',
  401. title: `Unowned ${index}`,
  402. last_active: 10_000 + index,
  403. })),
  404. ...Array.from({ length: 15 }, (_, index) => ({
  405. id: `owned-${index}`,
  406. profile,
  407. source: index % 2 ? 'ios' : 'web',
  408. title: '新对话',
  409. last_active: 1_000 + index,
  410. })),
  411. ]
  412. const merged = f.store.mergeOwnedSessionsIntoList(
  413. owner,
  414. profile,
  415. response({ sessions: upstreamSessions, total: upstreamSessions.length }),
  416. { offset: 5, limit: 4, archived: 'exclude' },
  417. )
  418. const payload = JSON.parse(merged.body.toString())
  419. expect(payload).toMatchObject({ total: 15, offset: 5, limit: 4 })
  420. expect(payload.sessions.map((session: { id: string }) => session.id))
  421. .toEqual(['owned-9', 'owned-8', 'owned-7', 'owned-6'])
  422. expect(payload.sessions.every((session: { owned?: boolean }) => session.owned === true)).toBe(true)
  423. const history = f.store.excludeOwnedSessionsFromList(
  424. owner,
  425. profile,
  426. response({ sessions: upstreamSessions, total: upstreamSessions.length }),
  427. { offset: 7, limit: 5 },
  428. )
  429. const historyPayload = JSON.parse(history.body.toString())
  430. expect(historyPayload).toMatchObject({ total: 40, offset: 7, limit: 5 })
  431. expect(historyPayload.sessions.map((session: { id: string }) => session.id))
  432. .toEqual(['unowned-7', 'unowned-8', 'unowned-9', 'unowned-10', 'unowned-11'])
  433. expect(historyPayload.sessions.some((session: { source: string }) => session.source === 'web')).toBe(true)
  434. f.store.close()
  435. })
  436. it('keeps an old pinned owned session on the first page after registry projection', () => {
  437. const f = fixture()
  438. for (let index = 0; index < 101; index++) {
  439. const id = `owned-${index}`
  440. f.store.recordRoute(owner, profile, id, `runtime-${index}`)
  441. f.store.recordCommand(owner, profile, id, 'session.create', { source: 'ios' })
  442. expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
  443. id,
  444. profile,
  445. source: 'ios',
  446. title: `Owned ${index}`,
  447. last_active: 1_000 + index,
  448. pinned: index === 0,
  449. }))).toBe(true)
  450. }
  451. const merged = f.store.mergeOwnedSessionsIntoList(
  452. owner,
  453. profile,
  454. response({ sessions: [], total: 0 }),
  455. { offset: 0, limit: 100, archived: 'exclude' },
  456. )
  457. const payload = JSON.parse(merged.body.toString())
  458. expect(payload).toMatchObject({ total: 101, offset: 0, limit: 100 })
  459. expect(payload.sessions).toHaveLength(100)
  460. expect(payload.sessions[0]).toMatchObject({ id: 'owned-0', pinned: true, owned: true })
  461. expect(payload.sessions.map((session: { id: string }) => session.id)).not.toContain('owned-1')
  462. f.store.close()
  463. })
  464. })