controller.client.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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 the entry as note and category and omits absent members', async () => {
  102. const { ctx, calls } = fakeRemote()
  103. const controller = new MessageFeedbackController(ctx, SESSION)
  104. await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' })
  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]).toMatchObject({ note: 'helpful', category: 'task-result' })
  108. expect(puts[1]).not.toHaveProperty('note')
  109. expect(puts[1]).not.toHaveProperty('category')
  110. })
  111. it('retract deletes a matching rating and ignores an opposite judgment', async () => {
  112. const { ctx, calls } = fakeRemote({
  113. list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }),
  114. })
  115. const controller = new MessageFeedbackController(ctx, SESSION)
  116. expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true })
  117. expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
  118. expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
  119. expect(calls.filter(call => call.method === 'delete')[0]?.request)
  120. .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') })
  121. })
  122. it('never turns a queued stale retraction into a bare rating put', async () => {
  123. const existing = item({ rating: 'positive', version: version('v1') })
  124. const replacement = item({ rating: 'negative', version: version('v2') })
  125. const { ctx, calls } = fakeRemote({
  126. list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
  127. put: () => Promise.resolve({ ok: true, value: replacement }),
  128. })
  129. const controller = new MessageFeedbackController(ctx, SESSION)
  130. await controller.ensure()
  131. const replaced = controller.rate(MSG, 'negative')
  132. const staleRetraction = controller.retract(MSG, 'positive')
  133. await expect(replaced).resolves.toEqual({ ok: true })
  134. await expect(staleRetraction).resolves.toEqual({ ok: true })
  135. expect(calls.filter(call => call.method === 'put')).toHaveLength(1)
  136. expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
  137. expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement)
  138. })
  139. it('reconciles a version conflict from the authoritative item without refetching', async () => {
  140. const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
  141. const { ctx, calls } = fakeRemote({
  142. put: () => Promise.resolve({
  143. ok: false,
  144. error: { code: 'version-conflict', current: authoritative },
  145. }),
  146. })
  147. const controller = new MessageFeedbackController(ctx, SESSION)
  148. expect(await controller.rate(MSG, 'positive')).toEqual({
  149. ok: false,
  150. error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
  151. })
  152. expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
  153. expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
  154. })
  155. it('drops the local item when a conflict reports the feedback is gone', async () => {
  156. const { ctx } = fakeRemote({
  157. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  158. delete: () => Promise.resolve({
  159. ok: false,
  160. error: { code: 'version-conflict', current: null },
  161. }),
  162. })
  163. const controller = new MessageFeedbackController(ctx, SESSION)
  164. await controller.ensure()
  165. expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
  166. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  167. })
  168. it('deletes with the observed version and removes the item on success', async () => {
  169. const { ctx, calls } = fakeRemote({
  170. list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
  171. })
  172. const controller = new MessageFeedbackController(ctx, SESSION)
  173. await controller.ensure()
  174. expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
  175. expect(calls.filter(call => call.method === 'delete')[0]?.request)
  176. .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
  177. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  178. })
  179. it('serializes mutations so each one compares against the committed version', async () => {
  180. let inFlight = 0
  181. let overlapped = false
  182. const versions = [version('v1'), version('v2')]
  183. let index = 0
  184. const { ctx, calls } = fakeRemote({
  185. put: async () => {
  186. inFlight += 1
  187. if (inFlight > 1) overlapped = true
  188. await Promise.resolve()
  189. inFlight -= 1
  190. const next = versions[index] ?? version('vN')
  191. index += 1
  192. return { ok: true, value: item({ version: next }) }
  193. },
  194. })
  195. const controller = new MessageFeedbackController(ctx, SESSION)
  196. await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
  197. expect(overlapped).toBe(false)
  198. const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
  199. expect(puts[0]?.ifVersion).toBeNull()
  200. expect(puts[1]?.ifVersion).toBe(version('v1'))
  201. })
  202. it('publishes an error status when the list read is rejected by the Host', async () => {
  203. const { ctx } = fakeRemote({
  204. list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  205. })
  206. const controller = new MessageFeedbackController(ctx, SESSION)
  207. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  208. expect(controller.getSnapshot()).toMatchObject({
  209. status: 'error',
  210. error: 'this session is no longer persisted',
  211. })
  212. })
  213. it('notifies subscribers on publication and stops after unsubscribe', async () => {
  214. const { ctx } = fakeRemote()
  215. const controller = new MessageFeedbackController(ctx, SESSION)
  216. const listener = vi.fn()
  217. const unsubscribe = controller.subscribe(listener)
  218. await controller.ensure()
  219. const seen = listener.mock.calls.length
  220. expect(seen).toBeGreaterThan(0)
  221. unsubscribe()
  222. await controller.rate(MSG, 'positive')
  223. expect(listener).toHaveBeenCalledTimes(seen)
  224. })
  225. it('contains a throwing subscriber at the observable boundary', async () => {
  226. const { ctx } = fakeRemote()
  227. const controller = new MessageFeedbackController(ctx, SESSION)
  228. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  229. controller.subscribe(() => { throw new Error('subscriber exploded') })
  230. const healthy = vi.fn()
  231. controller.subscribe(healthy)
  232. await controller.ensure()
  233. expect(healthy).toHaveBeenCalled()
  234. expect(spy).toHaveBeenCalled()
  235. spy.mockRestore()
  236. })
  237. it('refuses mutations and stops publishing once disposed', async () => {
  238. const { ctx, calls } = fakeRemote()
  239. const controller = new MessageFeedbackController(ctx, SESSION)
  240. await controller.ensure()
  241. const listener = vi.fn()
  242. controller.subscribe(listener)
  243. controller.dispose()
  244. const before = calls.length
  245. expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
  246. expect(calls).toHaveLength(before)
  247. expect(listener).not.toHaveBeenCalled()
  248. })
  249. it('renders a human explanation for every business failure code', async () => {
  250. const codes = [
  251. ['session-not-found', 'this session is no longer persisted'],
  252. ['target-not-found', 'this message is not a persisted assistant message'],
  253. ['note-blank', 'a note must contain a non-whitespace character'],
  254. ['note-too-large', 'the note is too long'],
  255. ] as const
  256. for (const [code, message] of codes) {
  257. const { ctx } = fakeRemote({
  258. list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
  259. })
  260. const controller = new MessageFeedbackController(ctx, SESSION)
  261. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
  262. expect(controller.getSnapshot().error).toBe(message)
  263. }
  264. })
  265. it('falls back to the raw code for an unrecognized failure', async () => {
  266. const { ctx } = fakeRemote({
  267. list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
  268. })
  269. const controller = new MessageFeedbackController(ctx, SESSION)
  270. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
  271. expect(controller.getSnapshot().error).toBe('brand-new-code')
  272. })
  273. it('publishes nothing when the list settles after disposal', async () => {
  274. let release = (): void => {}
  275. const gate = new Promise<void>((resolve) => { release = resolve })
  276. const { ctx } = fakeRemote({
  277. list: async () => {
  278. await gate
  279. return { ok: true, value: { items: [item()] } }
  280. },
  281. })
  282. const controller = new MessageFeedbackController(ctx, SESSION)
  283. const pending = controller.ensure()
  284. const listener = vi.fn()
  285. controller.subscribe(listener)
  286. controller.dispose()
  287. release()
  288. expect(await pending).toEqual({ ok: true })
  289. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  290. expect(listener).not.toHaveBeenCalled()
  291. })
  292. it('propagates a failed load to a queued mutation without calling the wire', async () => {
  293. const { ctx, calls } = fakeRemote({
  294. list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  295. })
  296. const controller = new MessageFeedbackController(ctx, SESSION)
  297. expect(await controller.rate(MSG, 'positive')).toMatchObject({
  298. ok: false,
  299. error: { code: 'session-not-found' },
  300. })
  301. expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
  302. })
  303. it('keeps a later mutation running after an earlier one settles as a failure', async () => {
  304. let first = true
  305. const { ctx } = fakeRemote({
  306. put: () => {
  307. if (first) {
  308. first = false
  309. return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) })
  310. }
  311. return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
  312. },
  313. })
  314. const controller = new MessageFeedbackController(ctx, SESSION)
  315. const [a, b] = await Promise.all([
  316. controller.rate(MSG, 'positive'),
  317. controller.rate(MSG, 'negative'),
  318. ])
  319. expect(a).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  320. expect(b).toEqual({ ok: true })
  321. expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
  322. })
  323. it('ignores a conflict reconciliation that lands after disposal', async () => {
  324. // The mutate() guard only refuses work admitted after disposal, so this
  325. // exercises commit()'s own guard: the call is already in flight when the
  326. // fiber unloads, and its authoritative item must not be published.
  327. let release = (): void => {}
  328. const gate = new Promise<void>((resolve) => { release = resolve })
  329. const { ctx } = fakeRemote({
  330. list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
  331. put: async () => {
  332. await gate
  333. return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
  334. },
  335. })
  336. const controller = new MessageFeedbackController(ctx, SESSION)
  337. await controller.ensure()
  338. const listener = vi.fn()
  339. controller.subscribe(listener)
  340. const pending = controller.rate(MSG, 'negative')
  341. controller.dispose()
  342. release()
  343. await pending
  344. // publish() drops its listener set on dispose, so no subscriber is told.
  345. expect(listener).not.toHaveBeenCalled()
  346. })
  347. it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
  348. let release = (): void => {}
  349. const gate = new Promise<void>((resolve) => { release = resolve })
  350. const { ctx } = fakeRemote({
  351. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  352. delete: async () => {
  353. await gate
  354. return { ok: false, error: { code: 'version-conflict', current: null } }
  355. },
  356. })
  357. const controller = new MessageFeedbackController(ctx, SESSION)
  358. await controller.ensure()
  359. const pending = controller.retract(MSG, 'positive')
  360. const listener = vi.fn()
  361. controller.subscribe(listener)
  362. controller.dispose()
  363. release()
  364. await pending
  365. // The reconciliation still computes, but no subscriber is notified.
  366. expect(listener).not.toHaveBeenCalled()
  367. })
  368. it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
  369. const existing = item({ version: version('v3'), rating: 'positive' })
  370. const { ctx } = fakeRemote({
  371. list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
  372. put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
  373. })
  374. const controller = new MessageFeedbackController(ctx, SESSION)
  375. await controller.ensure()
  376. expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({
  377. ok: false,
  378. error: { code: 'note-too-large' },
  379. })
  380. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  381. })
  382. it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
  383. const existing = item({ version: version('v4') })
  384. const { ctx } = fakeRemote({
  385. list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
  386. delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
  387. })
  388. const controller = new MessageFeedbackController(ctx, SESSION)
  389. await controller.ensure()
  390. expect(await controller.retract(MSG, 'positive')).toMatchObject({
  391. ok: false,
  392. error: { code: 'session-not-found' },
  393. })
  394. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  395. })
  396. it('resync serializes behind an in-flight mutation', async () => {
  397. // Regression: an unserialized reconnect read could land after a newer put
  398. // and resurrect the version that put had already replaced.
  399. const order: string[] = []
  400. let releasePut = (): void => {}
  401. const putGate = new Promise<void>((r) => { releasePut = r })
  402. const { ctx } = fakeRemote({
  403. list: () => {
  404. order.push('list')
  405. return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
  406. },
  407. put: async () => {
  408. order.push('put:start')
  409. await putGate
  410. order.push('put:end')
  411. return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
  412. },
  413. })
  414. const controller = new MessageFeedbackController(ctx, SESSION)
  415. await controller.ensure()
  416. const rating = controller.rate(MSG, 'negative')
  417. const resync = controller.resync()
  418. releasePut()
  419. await Promise.all([rating, resync])
  420. // The reconnect read runs only after the mutation settled.
  421. expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
  422. })
  423. it('refuses a mutation disposed while its seeding read is in flight', async () => {
  424. // Dispose only once the seeding list call has actually started, so the
  425. // mutation is already past the admission check and must be stopped by the
  426. // second guard that runs after ensure() resolves.
  427. let release = (): void => {}
  428. const gate = new Promise<void>((r) => { release = r })
  429. let started = (): void => {}
  430. const listStarted = new Promise<void>((r) => { started = r })
  431. const { ctx, calls } = fakeRemote({
  432. list: async () => {
  433. started()
  434. await gate
  435. return { ok: true, value: { items: [] } }
  436. },
  437. })
  438. const controller = new MessageFeedbackController(ctx, SESSION)
  439. const pending = controller.rate(MSG, 'positive')
  440. await listStarted
  441. controller.dispose()
  442. release()
  443. expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
  444. expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
  445. })
  446. it('renders a carrier failure from the Remote envelope', async () => {
  447. // The generated face folds transport faults into ok:false with a
  448. // RemoteFailure, so the controller reads them as values, not rejections.
  449. const { ctx } = fakeRemote({
  450. list: () => Promise.resolve({
  451. ok: false,
  452. error: new RemoteError('gateway/internal', 'socket closed', {}),
  453. }),
  454. })
  455. const controller = new MessageFeedbackController(ctx, SESSION)
  456. expect(await controller.ensure()).toEqual({
  457. ok: false,
  458. error: { code: 'gateway/internal', message: 'socket closed' },
  459. })
  460. expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
  461. })
  462. it('renders a carrier failure on a mutation without touching the view', async () => {
  463. const { ctx } = fakeRemote({
  464. put: () => Promise.resolve({
  465. ok: false,
  466. error: new RemoteError('gateway/internal', 'socket closed', {}),
  467. }),
  468. })
  469. const controller = new MessageFeedbackController(ctx, SESSION)
  470. expect(await controller.rate(MSG, 'positive')).toEqual({
  471. ok: false,
  472. error: { code: 'gateway/internal', message: 'socket closed' },
  473. })
  474. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  475. })
  476. it('renders a carrier failure on a delete', async () => {
  477. const { ctx } = fakeRemote({
  478. list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
  479. delete: () => Promise.resolve({
  480. ok: false,
  481. error: new RemoteError('gateway/internal', 'socket closed', {}),
  482. }),
  483. })
  484. const controller = new MessageFeedbackController(ctx, SESSION)
  485. await controller.ensure()
  486. expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  487. expect(controller.getSnapshot().items.has(MSG)).toBe(true)
  488. })
  489. })