controller.client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. /**
  2. * MessageFeedbackController: the browser-local object layer over one Session's
  3. * message-feedback sidecar. These specs pin the per-item compare-and-set
  4. * contract — every mutation sends the version last observed, a conflict
  5. * reconciles from the authoritative item carried by the reply, mutations
  6. * serialize per Session, and a disposed controller stops publishing.
  7. */
  8. import { describe, expect, it, vi } from 'vitest'
  9. import type { Context as ClientContext } from '@deepseek-ai/cordis'
  10. import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
  11. import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
  12. import type {
  13. MessageFeedbackItem, MessageFeedbackVersion,
  14. } from '@deepseek-ai/dsh-message-feedback/types'
  15. import { MessageFeedbackController } from '../src/client/controller.ts'
  16. const SESSION = 's-1' as SessionId
  17. const MSG = 'm-1' as MessageId
  18. const OTHER = 'm-2' as MessageId
  19. const version = (v: string): MessageFeedbackVersion => v as MessageFeedbackVersion
  20. function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
  21. return {
  22. messageId: MSG,
  23. rating: 'positive',
  24. version: version('v1'),
  25. createdAt: 1,
  26. updatedAt: 1,
  27. ...overrides,
  28. }
  29. }
  30. /** A recording fake Remote whose per-method answers are scripted per call. */
  31. type Script = {
  32. list?: (request: unknown) => Promise<unknown>
  33. put?: (request: unknown) => Promise<unknown>
  34. delete?: (request: unknown) => Promise<unknown>
  35. }
  36. /**
  37. * A recording fake context. Scripts return the *business* result; this wraps it
  38. * in the carrier envelope the generated face uses, so specs stay readable. A
  39. * script may also return an already-enveloped `{ok:false,error:RemoteError}` to
  40. * exercise a carrier failure.
  41. */
  42. function fakeRemote(script: Script = {}) {
  43. const calls: { method: string; request: unknown }[] = []
  44. const isCarrier = (v: unknown): boolean =>
  45. typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
  46. && 'error' in v && 'details' in ((v as { error: object }).error ?? {})
  47. const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
  48. (request: never): Promise<never> => {
  49. calls.push({ method, request })
  50. const business = real === undefined ? Promise.resolve(fallback) : real(request)
  51. return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
  52. }
  53. const ctx = {
  54. remote: {
  55. messageFeedback: {
  56. list: record('list', script.list, { ok: true, value: { items: [] } }),
  57. put: record('put', script.put, { ok: true, value: item() }),
  58. delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
  59. },
  60. },
  61. } as unknown as ClientContext
  62. return { ctx, calls }
  63. }
  64. describe('MessageFeedbackController', () => {
  65. it('seeds the view from one list read and keys items by message id', async () => {
  66. const seeded = item({ note: 'good' })
  67. const { ctx, calls } = fakeRemote({
  68. list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
  69. })
  70. const controller = new MessageFeedbackController(ctx, SESSION)
  71. expect(controller.getSnapshot().status).toBe('cold')
  72. expect(await controller.ensure()).toEqual({ ok: true })
  73. const view = controller.getSnapshot()
  74. expect(view.status).toBe('ready')
  75. expect(view.items.get(MSG)).toEqual(seeded)
  76. expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
  77. })
  78. it('collapses concurrent loads onto one in-flight read', async () => {
  79. const { ctx, calls } = fakeRemote()
  80. const controller = new MessageFeedbackController(ctx, SESSION)
  81. await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
  82. expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
  83. })
  84. it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
  85. const first = item({ version: version('v1') })
  86. const second = item({ version: version('v2'), rating: 'negative' })
  87. const { ctx, calls } = fakeRemote({
  88. put: request => Promise.resolve({
  89. ok: true,
  90. value: (request as { rating: string }).rating === 'positive' ? first : second,
  91. }),
  92. })
  93. const controller = new MessageFeedbackController(ctx, SESSION)
  94. expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
  95. expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
  96. const puts = calls.filter(call => call.method === 'put').map(call => call.request)
  97. expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
  98. expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
  99. expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
  100. })
  101. it('forwards an optional note and omits the field when absent', async () => {
  102. const { ctx, calls } = fakeRemote()
  103. const controller = new MessageFeedbackController(ctx, SESSION)
  104. await controller.rate(MSG, 'positive', 'helpful')
  105. await controller.rate(OTHER, 'negative')
  106. const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
  107. expect(puts[0]?.note).toBe('helpful')
  108. expect(puts[1]).not.toHaveProperty('note')
  109. })
  110. it('reconciles a version conflict from the authoritative item without refetching', async () => {
  111. const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
  112. const { ctx, calls } = fakeRemote({
  113. put: () => Promise.resolve({
  114. ok: false,
  115. error: { code: 'version-conflict', current: authoritative },
  116. }),
  117. })
  118. const controller = new MessageFeedbackController(ctx, SESSION)
  119. expect(await controller.rate(MSG, 'positive')).toEqual({
  120. ok: false,
  121. error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
  122. })
  123. expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
  124. expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
  125. })
  126. it('drops the local item when a conflict reports the feedback is gone', async () => {
  127. const { ctx } = fakeRemote({
  128. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  129. delete: () => Promise.resolve({
  130. ok: false,
  131. error: { code: 'version-conflict', current: null },
  132. }),
  133. })
  134. const controller = new MessageFeedbackController(ctx, SESSION)
  135. await controller.ensure()
  136. expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
  137. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  138. })
  139. it('deletes with the observed version and removes the item on success', async () => {
  140. const { ctx, calls } = fakeRemote({
  141. list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
  142. })
  143. const controller = new MessageFeedbackController(ctx, SESSION)
  144. await controller.ensure()
  145. expect(await controller.clear(MSG)).toEqual({ ok: true })
  146. expect(calls.filter(call => call.method === 'delete')[0]?.request)
  147. .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
  148. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  149. })
  150. it('treats clearing an unrated message as already satisfied without a call', async () => {
  151. const { ctx, calls } = fakeRemote()
  152. const controller = new MessageFeedbackController(ctx, SESSION)
  153. expect(await controller.clear(MSG)).toEqual({ ok: true })
  154. expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
  155. })
  156. it('serializes mutations so each one compares against the committed version', async () => {
  157. let inFlight = 0
  158. let overlapped = false
  159. const versions = [version('v1'), version('v2')]
  160. let index = 0
  161. const { ctx, calls } = fakeRemote({
  162. put: async () => {
  163. inFlight += 1
  164. if (inFlight > 1) overlapped = true
  165. await Promise.resolve()
  166. inFlight -= 1
  167. const next = versions[index] ?? version('vN')
  168. index += 1
  169. return { ok: true, value: item({ version: next }) }
  170. },
  171. })
  172. const controller = new MessageFeedbackController(ctx, SESSION)
  173. await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
  174. expect(overlapped).toBe(false)
  175. const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
  176. expect(puts[0]?.ifVersion).toBeNull()
  177. expect(puts[1]?.ifVersion).toBe(version('v1'))
  178. })
  179. it('publishes an error status when the list read is rejected by the Host', async () => {
  180. const { ctx } = fakeRemote({
  181. list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  182. })
  183. const controller = new MessageFeedbackController(ctx, SESSION)
  184. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  185. expect(controller.getSnapshot()).toMatchObject({
  186. status: 'error',
  187. error: 'this session is no longer persisted',
  188. })
  189. })
  190. it('notifies subscribers on publication and stops after unsubscribe', async () => {
  191. const { ctx } = fakeRemote()
  192. const controller = new MessageFeedbackController(ctx, SESSION)
  193. const listener = vi.fn()
  194. const unsubscribe = controller.subscribe(listener)
  195. await controller.ensure()
  196. const seen = listener.mock.calls.length
  197. expect(seen).toBeGreaterThan(0)
  198. unsubscribe()
  199. await controller.rate(MSG, 'positive')
  200. expect(listener).toHaveBeenCalledTimes(seen)
  201. })
  202. it('contains a throwing subscriber at the observable boundary', async () => {
  203. const { ctx } = fakeRemote()
  204. const controller = new MessageFeedbackController(ctx, SESSION)
  205. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  206. controller.subscribe(() => { throw new Error('subscriber exploded') })
  207. const healthy = vi.fn()
  208. controller.subscribe(healthy)
  209. await controller.ensure()
  210. expect(healthy).toHaveBeenCalled()
  211. expect(spy).toHaveBeenCalled()
  212. spy.mockRestore()
  213. })
  214. it('refuses mutations and stops publishing once disposed', async () => {
  215. const { ctx, calls } = fakeRemote()
  216. const controller = new MessageFeedbackController(ctx, SESSION)
  217. await controller.ensure()
  218. const listener = vi.fn()
  219. controller.subscribe(listener)
  220. controller.dispose()
  221. const before = calls.length
  222. expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
  223. expect(calls).toHaveLength(before)
  224. expect(listener).not.toHaveBeenCalled()
  225. })
  226. it('renders a human explanation for every business failure code', async () => {
  227. const codes = [
  228. ['session-not-found', 'this session is no longer persisted'],
  229. ['target-not-found', 'this message is not a persisted assistant message'],
  230. ['note-blank', 'a note must contain a non-whitespace character'],
  231. ['note-too-large', 'the note is too long'],
  232. ] as const
  233. for (const [code, message] of codes) {
  234. const { ctx } = fakeRemote({
  235. list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
  236. })
  237. const controller = new MessageFeedbackController(ctx, SESSION)
  238. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
  239. expect(controller.getSnapshot().error).toBe(message)
  240. }
  241. })
  242. it('falls back to the raw code for an unrecognized failure', async () => {
  243. const { ctx } = fakeRemote({
  244. list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
  245. })
  246. const controller = new MessageFeedbackController(ctx, SESSION)
  247. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
  248. expect(controller.getSnapshot().error).toBe('brand-new-code')
  249. })
  250. it('publishes nothing when the list settles after disposal', async () => {
  251. let release = (): void => {}
  252. const gate = new Promise<void>((resolve) => { release = resolve })
  253. const { ctx } = fakeRemote({
  254. list: async () => {
  255. await gate
  256. return { ok: true, value: { items: [item()] } }
  257. },
  258. })
  259. const controller = new MessageFeedbackController(ctx, SESSION)
  260. const pending = controller.ensure()
  261. const listener = vi.fn()
  262. controller.subscribe(listener)
  263. controller.dispose()
  264. release()
  265. expect(await pending).toEqual({ ok: true })
  266. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  267. expect(listener).not.toHaveBeenCalled()
  268. })
  269. it('propagates a failed load to a queued mutation without calling the wire', async () => {
  270. const { ctx, calls } = fakeRemote({
  271. list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  272. })
  273. const controller = new MessageFeedbackController(ctx, SESSION)
  274. expect(await controller.rate(MSG, 'positive')).toMatchObject({
  275. ok: false,
  276. error: { code: 'session-not-found' },
  277. })
  278. expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
  279. })
  280. it('keeps a later mutation running after an earlier one settles as a failure', async () => {
  281. let first = true
  282. const { ctx } = fakeRemote({
  283. put: () => {
  284. if (first) {
  285. first = false
  286. return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) })
  287. }
  288. return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
  289. },
  290. })
  291. const controller = new MessageFeedbackController(ctx, SESSION)
  292. const [a, b] = await Promise.all([
  293. controller.rate(MSG, 'positive'),
  294. controller.rate(MSG, 'negative'),
  295. ])
  296. expect(a).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  297. expect(b).toEqual({ ok: true })
  298. expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
  299. })
  300. it('ignores a conflict reconciliation that lands after disposal', async () => {
  301. // The mutate() guard only refuses work admitted after disposal, so this
  302. // exercises commit()'s own guard: the call is already in flight when the
  303. // fiber unloads, and its authoritative item must not be published.
  304. let release = (): void => {}
  305. const gate = new Promise<void>((resolve) => { release = resolve })
  306. const { ctx } = fakeRemote({
  307. list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
  308. put: async () => {
  309. await gate
  310. return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
  311. },
  312. })
  313. const controller = new MessageFeedbackController(ctx, SESSION)
  314. await controller.ensure()
  315. const listener = vi.fn()
  316. controller.subscribe(listener)
  317. const pending = controller.rate(MSG, 'negative')
  318. controller.dispose()
  319. release()
  320. await pending
  321. // publish() drops its listener set on dispose, so no subscriber is told.
  322. expect(listener).not.toHaveBeenCalled()
  323. })
  324. it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
  325. let release = (): void => {}
  326. const gate = new Promise<void>((resolve) => { release = resolve })
  327. const { ctx } = fakeRemote({
  328. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  329. delete: async () => {
  330. await gate
  331. return { ok: false, error: { code: 'version-conflict', current: null } }
  332. },
  333. })
  334. const controller = new MessageFeedbackController(ctx, SESSION)
  335. await controller.ensure()
  336. const pending = controller.clear(MSG)
  337. const listener = vi.fn()
  338. controller.subscribe(listener)
  339. controller.dispose()
  340. release()
  341. await pending
  342. // The reconciliation still computes, but no subscriber is notified.
  343. expect(listener).not.toHaveBeenCalled()
  344. })
  345. it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
  346. const existing = item({ version: version('v3'), rating: 'positive' })
  347. const { ctx } = fakeRemote({
  348. list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
  349. put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
  350. })
  351. const controller = new MessageFeedbackController(ctx, SESSION)
  352. await controller.ensure()
  353. expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({
  354. ok: false,
  355. error: { code: 'note-too-large' },
  356. })
  357. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  358. })
  359. it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
  360. const existing = item({ version: version('v4') })
  361. const { ctx } = fakeRemote({
  362. list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
  363. delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  364. })
  365. const controller = new MessageFeedbackController(ctx, SESSION)
  366. await controller.ensure()
  367. expect(await controller.clear(MSG)).toMatchObject({
  368. ok: false,
  369. error: { code: 'session-not-found' },
  370. })
  371. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  372. })
  373. it('preserves a stored note when a rating switch omits one', async () => {
  374. // Regression: a control that rendered before the first list read holds no
  375. // item, so it passes note=undefined; that must not erase the stored note.
  376. const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
  377. const { ctx, calls } = fakeRemote({
  378. list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
  379. })
  380. const controller = new MessageFeedbackController(ctx, SESSION)
  381. expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
  382. const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
  383. expect(put.note).toBe('keep me')
  384. expect(put.rating).toBe('negative')
  385. })
  386. it('toggle retracts when the committed rating already matches', async () => {
  387. const stored = item({ version: version('v1'), rating: 'positive' })
  388. const { ctx, calls } = fakeRemote({
  389. list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
  390. })
  391. const controller = new MessageFeedbackController(ctx, SESSION)
  392. expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
  393. expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
  394. expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
  395. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  396. })
  397. it('toggle decides from the committed item, not a cold view', async () => {
  398. // The click lands before any list read: the cold view knows no item, yet the
  399. // stored rating matches, so the toggle must retract rather than re-put.
  400. const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
  401. const { ctx, calls } = fakeRemote({
  402. list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
  403. })
  404. const controller = new MessageFeedbackController(ctx, SESSION)
  405. expect(controller.getSnapshot().status).toBe('cold')
  406. expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
  407. expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
  408. })
  409. it('toggle replaces the opposite rating and carries the note forward', async () => {
  410. const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
  411. const { ctx, calls } = fakeRemote({
  412. list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
  413. })
  414. const controller = new MessageFeedbackController(ctx, SESSION)
  415. expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
  416. const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
  417. expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
  418. })
  419. it('clearNote drops the note and keeps the rating', async () => {
  420. const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
  421. const { ctx, calls } = fakeRemote({
  422. list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
  423. })
  424. const controller = new MessageFeedbackController(ctx, SESSION)
  425. expect(await controller.clearNote(MSG)).toEqual({ ok: true })
  426. const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
  427. expect(put.rating).toBe('negative')
  428. expect(put).not.toHaveProperty('note')
  429. })
  430. it('clearNote is a no-op when there is no note to drop', async () => {
  431. const { ctx, calls } = fakeRemote({
  432. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  433. })
  434. const controller = new MessageFeedbackController(ctx, SESSION)
  435. expect(await controller.clearNote(MSG)).toEqual({ ok: true })
  436. expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
  437. })
  438. it('resync serializes behind an in-flight mutation', async () => {
  439. // Regression: an unserialized reconnect read could land after a newer put
  440. // and resurrect the version that put had already replaced.
  441. const order: string[] = []
  442. let releasePut = (): void => {}
  443. const putGate = new Promise<void>((r) => { releasePut = r })
  444. const { ctx } = fakeRemote({
  445. list: () => {
  446. order.push('list')
  447. return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
  448. },
  449. put: async () => {
  450. order.push('put:start')
  451. await putGate
  452. order.push('put:end')
  453. return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
  454. },
  455. })
  456. const controller = new MessageFeedbackController(ctx, SESSION)
  457. await controller.ensure()
  458. const rating = controller.rate(MSG, 'negative')
  459. const resync = controller.resync()
  460. releasePut()
  461. await Promise.all([rating, resync])
  462. // The reconnect read runs only after the mutation settled.
  463. expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
  464. })
  465. it('refuses a mutation disposed while its seeding read is in flight', async () => {
  466. // Dispose only once the seeding list call has actually started, so the
  467. // mutation is already past the admission check and must be stopped by the
  468. // second guard that runs after ensure() resolves.
  469. let release = (): void => {}
  470. const gate = new Promise<void>((r) => { release = r })
  471. let started = (): void => {}
  472. const listStarted = new Promise<void>((r) => { started = r })
  473. const { ctx, calls } = fakeRemote({
  474. list: async () => {
  475. started()
  476. await gate
  477. return { ok: true, value: { items: [] } }
  478. },
  479. })
  480. const controller = new MessageFeedbackController(ctx, SESSION)
  481. const pending = controller.rate(MSG, 'positive')
  482. await listStarted
  483. controller.dispose()
  484. release()
  485. expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
  486. expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
  487. })
  488. it('renders a carrier failure from the Remote envelope', async () => {
  489. // The generated face folds transport faults into ok:false with a
  490. // RemoteFailure, so the controller reads them as values, not rejections.
  491. const { ctx } = fakeRemote({
  492. list: () => Promise.resolve({
  493. ok: false,
  494. error: new RemoteError('gateway/internal', 'socket closed', {}),
  495. }),
  496. })
  497. const controller = new MessageFeedbackController(ctx, SESSION)
  498. expect(await controller.ensure()).toEqual({
  499. ok: false,
  500. error: { code: 'gateway/internal', message: 'socket closed' },
  501. })
  502. expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
  503. })
  504. it('renders a carrier failure on a mutation without touching the view', async () => {
  505. const { ctx } = fakeRemote({
  506. put: () => Promise.resolve({
  507. ok: false,
  508. error: new RemoteError('gateway/internal', 'socket closed', {}),
  509. }),
  510. })
  511. const controller = new MessageFeedbackController(ctx, SESSION)
  512. expect(await controller.rate(MSG, 'positive')).toEqual({
  513. ok: false,
  514. error: { code: 'gateway/internal', message: 'socket closed' },
  515. })
  516. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  517. })
  518. it('renders a carrier failure on a delete', async () => {
  519. const { ctx } = fakeRemote({
  520. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  521. delete: () => Promise.resolve({
  522. ok: false,
  523. error: new RemoteError('gateway/internal', 'socket closed', {}),
  524. }),
  525. })
  526. const controller = new MessageFeedbackController(ctx, SESSION)
  527. await controller.ensure()
  528. expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  529. expect(controller.getSnapshot().items.has(MSG)).toBe(true)
  530. })
  531. })