controller.client.spec.ts 27 KB

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