controller.client.spec.ts 22 KB

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