controller.client.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  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 { RemoteMock, ok } from '@deepseek-ai/dsh-remote-mock'
  9. import { beforeEach, describe, expect, it, vi } from 'vitest'
  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. let mock: RemoteMock
  31. beforeEach(() => {
  32. mock = RemoteMock.create()
  33. mock.remote.messageFeedback.list.mockResolvedValue(ok({ ok: true, value: { items: [] } }))
  34. mock.remote.messageFeedback.put.mockResolvedValue(ok({ ok: true, value: item() }))
  35. mock.remote.messageFeedback.delete.mockResolvedValue(ok({ ok: true, value: { absent: true } }))
  36. })
  37. describe('MessageFeedbackController', () => {
  38. it('seeds the view from one list read and keys items by message id', async () => {
  39. const seeded = item({ note: 'good' })
  40. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [seeded] } }))
  41. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  42. expect(controller.getSnapshot().status).toBe('cold')
  43. expect(await controller.ensure()).toEqual({ ok: true })
  44. const view = controller.getSnapshot()
  45. expect(view.status).toBe('ready')
  46. expect(view.items.get(MSG)).toEqual(seeded)
  47. expect(mock.remote.messageFeedback.list).toHaveBeenCalledExactlyOnceWith({ sessionId: SESSION })
  48. })
  49. it('collapses concurrent loads onto one in-flight read', async () => {
  50. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  51. await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
  52. expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
  53. })
  54. it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
  55. const first = item({ version: version('v1') })
  56. const second = item({ version: version('v2'), rating: 'negative' })
  57. mock.remote.messageFeedback.put.mockImplementation(request => Promise.resolve(ok({
  58. ok: true,
  59. value: request.rating === 'positive' ? first : second,
  60. })))
  61. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  62. expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
  63. expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
  64. const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
  65. expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
  66. expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
  67. expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
  68. })
  69. it('forwards the entry as note and category and omits absent members', async () => {
  70. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  71. await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' })
  72. await controller.rate(OTHER, 'negative')
  73. const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
  74. expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' })
  75. expect(puts[1]).not.toHaveProperty('note')
  76. expect(puts[1]).not.toHaveProperty('category')
  77. })
  78. it('retract deletes a matching rating and ignores an opposite judgment', async () => {
  79. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  80. ok: true, value: { items: [item({ note: 'stored', category: 'other' })] },
  81. }))
  82. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  83. expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true })
  84. expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
  85. expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
  86. expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
  87. sessionId: SESSION, messageId: MSG, ifVersion: version('v1'),
  88. })
  89. })
  90. it('never turns a queued stale retraction into a bare rating put', async () => {
  91. const existing = item({ rating: 'positive', version: version('v1') })
  92. const replacement = item({ rating: 'negative', version: version('v2') })
  93. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
  94. mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ ok: true, value: replacement }))
  95. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  96. await controller.ensure()
  97. const replaced = controller.rate(MSG, 'negative')
  98. const staleRetraction = controller.retract(MSG, 'positive')
  99. await expect(replaced).resolves.toEqual({ ok: true })
  100. await expect(staleRetraction).resolves.toEqual({ ok: true })
  101. expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(1)
  102. expect(mock.remote.messageFeedback.delete).not.toHaveBeenCalled()
  103. expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement)
  104. })
  105. it('reconciles a version conflict from the authoritative item without refetching', async () => {
  106. const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
  107. mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
  108. ok: false,
  109. error: { code: 'version-conflict', current: authoritative },
  110. }))
  111. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  112. expect(await controller.rate(MSG, 'positive')).toEqual({
  113. ok: false,
  114. error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
  115. })
  116. expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
  117. expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
  118. })
  119. it('drops the local item when a conflict reports the feedback is gone', async () => {
  120. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
  121. mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
  122. ok: false,
  123. error: { code: 'version-conflict', current: null },
  124. }))
  125. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  126. await controller.ensure()
  127. expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
  128. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  129. })
  130. it('deletes with the observed version and removes the item on success', async () => {
  131. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  132. ok: true, value: { items: [item({ version: version('v7') })] },
  133. }))
  134. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  135. await controller.ensure()
  136. expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
  137. expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
  138. sessionId: SESSION, messageId: MSG, ifVersion: version('v7'),
  139. })
  140. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  141. })
  142. it('serializes mutations so each one compares against the committed version', async () => {
  143. let inFlight = 0
  144. let overlapped = false
  145. const versions = [version('v1'), version('v2')]
  146. let index = 0
  147. mock.remote.messageFeedback.put.mockImplementation(async () => {
  148. inFlight += 1
  149. if (inFlight > 1) overlapped = true
  150. await Promise.resolve()
  151. inFlight -= 1
  152. const next = versions[index] ?? version('vN')
  153. index += 1
  154. return ok({ ok: true, value: item({ version: next }) })
  155. })
  156. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  157. await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
  158. expect(overlapped).toBe(false)
  159. const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
  160. expect(puts[0]?.ifVersion).toBeNull()
  161. expect(puts[1]?.ifVersion).toBe(version('v1'))
  162. })
  163. it('publishes an error status when the list read is rejected by the Host', async () => {
  164. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  165. ok: false, error: { code: 'session-not-found', sessionId: SESSION },
  166. }))
  167. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  168. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  169. expect(controller.getSnapshot()).toMatchObject({
  170. status: 'error',
  171. error: 'this session is no longer persisted',
  172. })
  173. })
  174. it('notifies subscribers on publication and stops after unsubscribe', async () => {
  175. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  176. const listener = vi.fn()
  177. const unsubscribe = controller.subscribe(listener)
  178. await controller.ensure()
  179. const seen = listener.mock.calls.length
  180. expect(seen).toBeGreaterThan(0)
  181. unsubscribe()
  182. await controller.rate(MSG, 'positive')
  183. expect(listener).toHaveBeenCalledTimes(seen)
  184. })
  185. it('contains a throwing subscriber at the observable boundary', async () => {
  186. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  187. const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
  188. controller.subscribe(() => { throw new Error('subscriber exploded') })
  189. const healthy = vi.fn()
  190. controller.subscribe(healthy)
  191. await controller.ensure()
  192. expect(healthy).toHaveBeenCalled()
  193. expect(spy).toHaveBeenCalled()
  194. spy.mockRestore()
  195. })
  196. it('refuses mutations and stops publishing once disposed', async () => {
  197. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  198. await controller.ensure()
  199. const listener = vi.fn()
  200. controller.subscribe(listener)
  201. controller.dispose()
  202. const puts = mock.remote.messageFeedback.put.mock.calls.length
  203. expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
  204. expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(puts)
  205. expect(listener).not.toHaveBeenCalled()
  206. })
  207. it('renders a human explanation for every business failure code', async () => {
  208. const codes = [
  209. ['session-not-found', 'this session is no longer persisted'],
  210. ['target-not-found', 'this message is not a persisted assistant message'],
  211. ['note-blank', 'a note must contain a non-whitespace character'],
  212. ['note-too-large', 'the note is too long'],
  213. ] as const
  214. for (const [code, message] of codes) {
  215. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  216. ok: false, error: { code, sessionId: SESSION },
  217. } as never))
  218. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  219. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
  220. expect(controller.getSnapshot().error).toBe(message)
  221. }
  222. })
  223. it('falls back to the raw code for an unrecognized failure', async () => {
  224. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  225. ok: false, error: { code: 'brand-new-code' },
  226. } as never))
  227. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  228. expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
  229. expect(controller.getSnapshot().error).toBe('brand-new-code')
  230. })
  231. it('publishes nothing when the list settles after disposal', async () => {
  232. let release = (): void => {}
  233. const gate = new Promise<void>((resolve) => { release = resolve })
  234. mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
  235. await gate
  236. return ok({ ok: true, value: { items: [item()] } })
  237. })
  238. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  239. const pending = controller.ensure()
  240. const listener = vi.fn()
  241. controller.subscribe(listener)
  242. controller.dispose()
  243. release()
  244. expect(await pending).toEqual({ ok: true })
  245. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  246. expect(listener).not.toHaveBeenCalled()
  247. })
  248. it('propagates a failed load to a queued mutation without calling the wire', async () => {
  249. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  250. ok: false, error: { code: 'session-not-found', sessionId: SESSION },
  251. }))
  252. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  253. expect(await controller.rate(MSG, 'positive')).toMatchObject({
  254. ok: false,
  255. error: { code: 'session-not-found' },
  256. })
  257. expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
  258. })
  259. it('keeps a later mutation running after an earlier one settles as a failure', async () => {
  260. let first = true
  261. mock.remote.messageFeedback.put.mockImplementation(() => {
  262. if (first) {
  263. first = false
  264. return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'first blew up', {}) })
  265. }
  266. return Promise.resolve(ok({ ok: true, value: item({ rating: 'negative' }) }))
  267. })
  268. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  269. const [a, b] = await Promise.all([
  270. controller.rate(MSG, 'positive'),
  271. controller.rate(MSG, 'negative'),
  272. ])
  273. expect(a).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  274. expect(b).toEqual({ ok: true })
  275. expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
  276. })
  277. it('ignores a conflict reconciliation that lands after disposal', async () => {
  278. // The mutate() guard only refuses work admitted after disposal, so this
  279. // exercises commit()'s own guard: the call is already in flight when the
  280. // fiber unloads, and its authoritative item must not be published.
  281. let release = (): void => {}
  282. const gate = new Promise<void>((resolve) => { release = resolve })
  283. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
  284. ok: true, value: { items: [item({ version: version('v1') })] },
  285. }))
  286. mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
  287. await gate
  288. return ok({
  289. ok: false,
  290. error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) },
  291. })
  292. })
  293. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  294. await controller.ensure()
  295. const listener = vi.fn()
  296. controller.subscribe(listener)
  297. const pending = controller.rate(MSG, 'negative')
  298. controller.dispose()
  299. release()
  300. await pending
  301. // publish() drops its listener set on dispose, so no subscriber is told.
  302. expect(listener).not.toHaveBeenCalled()
  303. })
  304. it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
  305. let release = (): void => {}
  306. const gate = new Promise<void>((resolve) => { release = resolve })
  307. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
  308. mock.remote.messageFeedback.delete.mockImplementationOnce(async () => {
  309. await gate
  310. return ok({ ok: false, error: { code: 'version-conflict', current: null } })
  311. })
  312. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  313. await controller.ensure()
  314. const pending = controller.retract(MSG, 'positive')
  315. const listener = vi.fn()
  316. controller.subscribe(listener)
  317. controller.dispose()
  318. release()
  319. await pending
  320. // The reconciliation still computes, but no subscriber is notified.
  321. expect(listener).not.toHaveBeenCalled()
  322. })
  323. it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
  324. const existing = item({ version: version('v3'), rating: 'positive' })
  325. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
  326. mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
  327. ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 },
  328. }))
  329. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  330. await controller.ensure()
  331. expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({
  332. ok: false,
  333. error: { code: 'note-too-large' },
  334. })
  335. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  336. })
  337. it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
  338. const existing = item({ version: version('v4') })
  339. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
  340. mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
  341. ok: false, error: { code: 'session-not-found', sessionId: SESSION },
  342. }))
  343. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  344. await controller.ensure()
  345. expect(await controller.retract(MSG, 'positive')).toMatchObject({
  346. ok: false,
  347. error: { code: 'session-not-found' },
  348. })
  349. expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
  350. })
  351. it('resync serializes behind an in-flight mutation', async () => {
  352. // Regression: an unserialized reconnect read could land after a newer put
  353. // and resurrect the version that put had already replaced.
  354. const order: string[] = []
  355. let releasePut = (): void => {}
  356. const putGate = new Promise<void>((r) => { releasePut = r })
  357. mock.remote.messageFeedback.list.mockImplementation(() => {
  358. order.push('list')
  359. return Promise.resolve(ok({ ok: true, value: { items: [item({ version: version('v1') })] } }))
  360. })
  361. mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
  362. order.push('put:start')
  363. await putGate
  364. order.push('put:end')
  365. return ok({ ok: true, value: item({ version: version('v9'), rating: 'negative' }) })
  366. })
  367. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  368. await controller.ensure()
  369. const rating = controller.rate(MSG, 'negative')
  370. const resync = controller.resync()
  371. releasePut()
  372. await Promise.all([rating, resync])
  373. // The reconnect read runs only after the mutation settled.
  374. expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
  375. })
  376. it('refuses a mutation disposed while its seeding read is in flight', async () => {
  377. // Dispose only once the seeding list call has actually started, so the
  378. // mutation is already past the admission check and must be stopped by the
  379. // second guard that runs after ensure() resolves.
  380. let release = (): void => {}
  381. const gate = new Promise<void>((r) => { release = r })
  382. let started = (): void => {}
  383. const listStarted = new Promise<void>((r) => { started = r })
  384. mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
  385. started()
  386. await gate
  387. return ok({ ok: true, value: { items: [] } })
  388. })
  389. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  390. const pending = controller.rate(MSG, 'positive')
  391. await listStarted
  392. controller.dispose()
  393. release()
  394. expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
  395. expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
  396. })
  397. it('renders a carrier failure from the Remote envelope', async () => {
  398. // The generated face folds transport faults into ok:false with a
  399. // RemoteFailure, so the controller reads them as values, not rejections.
  400. mock.remote.messageFeedback.list.mockResolvedValueOnce({
  401. ok: false,
  402. error: new RemoteError('gateway/internal', 'socket closed', {}),
  403. })
  404. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  405. expect(await controller.ensure()).toEqual({
  406. ok: false,
  407. error: { code: 'gateway/internal', message: 'socket closed' },
  408. })
  409. expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
  410. })
  411. it('renders a carrier failure on a mutation without touching the view', async () => {
  412. mock.remote.messageFeedback.put.mockResolvedValueOnce({
  413. ok: false,
  414. error: new RemoteError('gateway/internal', 'socket closed', {}),
  415. })
  416. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  417. expect(await controller.rate(MSG, 'positive')).toEqual({
  418. ok: false,
  419. error: { code: 'gateway/internal', message: 'socket closed' },
  420. })
  421. expect(controller.getSnapshot().items.has(MSG)).toBe(false)
  422. })
  423. it('renders a carrier failure on a delete', async () => {
  424. mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
  425. mock.remote.messageFeedback.delete.mockResolvedValueOnce({
  426. ok: false,
  427. error: new RemoteError('gateway/internal', 'socket closed', {}),
  428. })
  429. const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
  430. await controller.ensure()
  431. expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
  432. expect(controller.getSnapshot().items.has(MSG)).toBe(true)
  433. })
  434. })