|
|
@@ -5,8 +5,8 @@
|
|
|
* reconciles from the authoritative item carried by the reply, mutations
|
|
|
* serialize per Session, and a disposed controller stops publishing.
|
|
|
*/
|
|
|
-import { describe, expect, it, vi } from 'vitest'
|
|
|
-import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
|
+import { RemoteMock, ok } from '@deepseek-ai/dsh-remote-mock'
|
|
|
+import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
import type { MessageId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
|
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
|
|
import type {
|
|
|
@@ -31,49 +31,20 @@ function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-/** A recording fake Remote whose per-method answers are scripted per call. */
|
|
|
-type Script = {
|
|
|
- list?: (request: unknown) => Promise<unknown>
|
|
|
- put?: (request: unknown) => Promise<unknown>
|
|
|
- delete?: (request: unknown) => Promise<unknown>
|
|
|
-}
|
|
|
+let mock: RemoteMock
|
|
|
|
|
|
-/**
|
|
|
- * A recording fake context. Scripts return the *business* result; this wraps it
|
|
|
- * in the carrier envelope the generated face uses, so specs stay readable. A
|
|
|
- * script may also return an already-enveloped `{ok:false,error:RemoteError}` to
|
|
|
- * exercise a carrier failure.
|
|
|
- */
|
|
|
-function fakeRemote(script: Script = {}) {
|
|
|
- const calls: { method: string; request: unknown }[] = []
|
|
|
- const isCarrier = (v: unknown): boolean =>
|
|
|
- typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
|
|
|
- && 'error' in v && 'details' in ((v as { error: object }).error ?? {})
|
|
|
- const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
|
|
|
- (request: never): Promise<never> => {
|
|
|
- calls.push({ method, request })
|
|
|
- const business = real === undefined ? Promise.resolve(fallback) : real(request)
|
|
|
- return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
|
|
|
- }
|
|
|
- const ctx = {
|
|
|
- remote: {
|
|
|
- messageFeedback: {
|
|
|
- list: record('list', script.list, { ok: true, value: { items: [] } }),
|
|
|
- put: record('put', script.put, { ok: true, value: item() }),
|
|
|
- delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
|
|
|
- },
|
|
|
- },
|
|
|
- } as unknown as ClientContext
|
|
|
- return { ctx, calls }
|
|
|
-}
|
|
|
+beforeEach(() => {
|
|
|
+ mock = RemoteMock.create()
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValue(ok({ ok: true, value: { items: [] } }))
|
|
|
+ mock.remote.messageFeedback.put.mockResolvedValue(ok({ ok: true, value: item() }))
|
|
|
+ mock.remote.messageFeedback.delete.mockResolvedValue(ok({ ok: true, value: { absent: true } }))
|
|
|
+})
|
|
|
|
|
|
describe('MessageFeedbackController', () => {
|
|
|
it('seeds the view from one list read and keys items by message id', async () => {
|
|
|
const seeded = item({ note: 'good' })
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [seeded] } }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(controller.getSnapshot().status).toBe('cold')
|
|
|
expect(await controller.ensure()).toEqual({ ok: true })
|
|
|
@@ -81,73 +52,68 @@ describe('MessageFeedbackController', () => {
|
|
|
const view = controller.getSnapshot()
|
|
|
expect(view.status).toBe('ready')
|
|
|
expect(view.items.get(MSG)).toEqual(seeded)
|
|
|
- expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
|
|
|
+ expect(mock.remote.messageFeedback.list).toHaveBeenCalledExactlyOnceWith({ sessionId: SESSION })
|
|
|
})
|
|
|
|
|
|
it('collapses concurrent loads onto one in-flight read', async () => {
|
|
|
- const { ctx, calls } = fakeRemote()
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
|
|
+ expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
|
|
|
})
|
|
|
|
|
|
it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
|
|
|
const first = item({ version: version('v1') })
|
|
|
const second = item({ version: version('v2'), rating: 'negative' })
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- put: request => Promise.resolve({
|
|
|
- ok: true,
|
|
|
- value: (request as { rating: string }).rating === 'positive' ? first : second,
|
|
|
- }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.put.mockImplementation(request => Promise.resolve(ok({
|
|
|
+ ok: true,
|
|
|
+ value: request.rating === 'positive' ? first : second,
|
|
|
+ })))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
|
|
|
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
|
|
|
|
|
|
- const puts = calls.filter(call => call.method === 'put').map(call => call.request)
|
|
|
+ const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
|
|
|
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
|
|
|
expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
|
|
|
expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
|
|
|
})
|
|
|
|
|
|
it('forwards the entry as note and category and omits absent members', async () => {
|
|
|
- const { ctx, calls } = fakeRemote()
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
await controller.rate(MSG, 'negative', { text: 'helpful', category: 'task-result' })
|
|
|
await controller.rate(OTHER, 'negative')
|
|
|
|
|
|
- const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
|
|
+ const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
|
|
|
expect(puts[0]).toMatchObject({ note: 'helpful', category: 'task-result' })
|
|
|
expect(puts[1]).not.toHaveProperty('note')
|
|
|
expect(puts[1]).not.toHaveProperty('category')
|
|
|
})
|
|
|
|
|
|
it('retract deletes a matching rating and ignores an opposite judgment', async () => {
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: true, value: { items: [item({ note: 'stored', category: 'other' })] },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.retract(MSG, 'negative')).toEqual({ ok: true })
|
|
|
expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
|
|
|
- expect(calls.filter(call => call.method === 'delete')[0]?.request)
|
|
|
- .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v1') })
|
|
|
+ expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
|
|
|
+ expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
|
|
|
+ sessionId: SESSION, messageId: MSG, ifVersion: version('v1'),
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
it('never turns a queued stale retraction into a bare rating put', async () => {
|
|
|
const existing = item({ rating: 'positive', version: version('v1') })
|
|
|
const replacement = item({ rating: 'negative', version: version('v2') })
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
|
|
|
- put: () => Promise.resolve({ ok: true, value: replacement }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
|
|
|
+ mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({ ok: true, value: replacement }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
const replaced = controller.rate(MSG, 'negative')
|
|
|
@@ -155,20 +121,18 @@ describe('MessageFeedbackController', () => {
|
|
|
|
|
|
await expect(replaced).resolves.toEqual({ ok: true })
|
|
|
await expect(staleRetraction).resolves.toEqual({ ok: true })
|
|
|
- expect(calls.filter(call => call.method === 'put')).toHaveLength(1)
|
|
|
- expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
|
|
|
+ expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(1)
|
|
|
+ expect(mock.remote.messageFeedback.delete).not.toHaveBeenCalled()
|
|
|
expect(controller.getSnapshot().items.get(MSG)).toEqual(replacement)
|
|
|
})
|
|
|
|
|
|
it('reconciles a version conflict from the authoritative item without refetching', async () => {
|
|
|
const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- put: () => Promise.resolve({
|
|
|
- ok: false,
|
|
|
- error: { code: 'version-conflict', current: authoritative },
|
|
|
- }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
|
|
|
+ ok: false,
|
|
|
+ error: { code: 'version-conflict', current: authoritative },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.rate(MSG, 'positive')).toEqual({
|
|
|
ok: false,
|
|
|
@@ -176,18 +140,16 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
|
|
|
- expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
|
|
+ expect(mock.remote.messageFeedback.list).toHaveBeenCalledTimes(1)
|
|
|
})
|
|
|
|
|
|
it('drops the local item when a conflict reports the feedback is gone', async () => {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
|
|
- delete: () => Promise.resolve({
|
|
|
- ok: false,
|
|
|
- error: { code: 'version-conflict', current: null },
|
|
|
- }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
|
|
|
+ mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
|
|
|
+ ok: false,
|
|
|
+ error: { code: 'version-conflict', current: null },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
|
|
|
@@ -195,16 +157,17 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('deletes with the observed version and removes the item on success', async () => {
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: true, value: { items: [item({ version: version('v7') })] },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
expect(await controller.retract(MSG, 'positive')).toEqual({ ok: true })
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'delete')[0]?.request)
|
|
|
- .toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
|
|
|
+ expect(mock.remote.messageFeedback.delete).toHaveBeenCalledExactlyOnceWith({
|
|
|
+ sessionId: SESSION, messageId: MSG, ifVersion: version('v7'),
|
|
|
+ })
|
|
|
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
|
|
})
|
|
|
|
|
|
@@ -213,32 +176,30 @@ describe('MessageFeedbackController', () => {
|
|
|
let overlapped = false
|
|
|
const versions = [version('v1'), version('v2')]
|
|
|
let index = 0
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- put: async () => {
|
|
|
- inFlight += 1
|
|
|
- if (inFlight > 1) overlapped = true
|
|
|
- await Promise.resolve()
|
|
|
- inFlight -= 1
|
|
|
- const next = versions[index] ?? version('vN')
|
|
|
- index += 1
|
|
|
- return { ok: true, value: item({ version: next }) }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.put.mockImplementation(async () => {
|
|
|
+ inFlight += 1
|
|
|
+ if (inFlight > 1) overlapped = true
|
|
|
+ await Promise.resolve()
|
|
|
+ inFlight -= 1
|
|
|
+ const next = versions[index] ?? version('vN')
|
|
|
+ index += 1
|
|
|
+ return ok({ ok: true, value: item({ version: next }) })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
|
|
|
|
|
|
expect(overlapped).toBe(false)
|
|
|
- const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
|
|
+ const puts = mock.remote.messageFeedback.put.mock.calls.map(([request]) => request)
|
|
|
expect(puts[0]?.ifVersion).toBeNull()
|
|
|
expect(puts[1]?.ifVersion).toBe(version('v1'))
|
|
|
})
|
|
|
|
|
|
it('publishes an error status when the list read is rejected by the Host', async () => {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code: 'session-not-found', sessionId: SESSION },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
|
|
expect(controller.getSnapshot()).toMatchObject({
|
|
|
@@ -248,8 +209,7 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('notifies subscribers on publication and stops after unsubscribe', async () => {
|
|
|
- const { ctx } = fakeRemote()
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
const listener = vi.fn()
|
|
|
const unsubscribe = controller.subscribe(listener)
|
|
|
|
|
|
@@ -263,8 +223,7 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('contains a throwing subscriber at the observable boundary', async () => {
|
|
|
- const { ctx } = fakeRemote()
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
controller.subscribe(() => { throw new Error('subscriber exploded') })
|
|
|
const healthy = vi.fn()
|
|
|
@@ -278,17 +237,16 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('refuses mutations and stops publishing once disposed', async () => {
|
|
|
- const { ctx, calls } = fakeRemote()
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
const listener = vi.fn()
|
|
|
controller.subscribe(listener)
|
|
|
|
|
|
controller.dispose()
|
|
|
- const before = calls.length
|
|
|
+ const puts = mock.remote.messageFeedback.put.mock.calls.length
|
|
|
|
|
|
expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
|
|
- expect(calls).toHaveLength(before)
|
|
|
+ expect(mock.remote.messageFeedback.put).toHaveBeenCalledTimes(puts)
|
|
|
expect(listener).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
@@ -300,20 +258,20 @@ describe('MessageFeedbackController', () => {
|
|
|
['note-too-large', 'the note is too long'],
|
|
|
] as const
|
|
|
for (const [code, message] of codes) {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code, sessionId: SESSION },
|
|
|
+ } as never))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
|
|
|
expect(controller.getSnapshot().error).toBe(message)
|
|
|
}
|
|
|
})
|
|
|
|
|
|
it('falls back to the raw code for an unrecognized failure', async () => {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code: 'brand-new-code' },
|
|
|
+ } as never))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
|
|
|
expect(controller.getSnapshot().error).toBe('brand-new-code')
|
|
|
@@ -322,13 +280,11 @@ describe('MessageFeedbackController', () => {
|
|
|
it('publishes nothing when the list settles after disposal', async () => {
|
|
|
let release = (): void => {}
|
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: async () => {
|
|
|
- await gate
|
|
|
- return { ok: true, value: { items: [item()] } }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
|
|
|
+ await gate
|
|
|
+ return ok({ ok: true, value: { items: [item()] } })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
const pending = controller.ensure()
|
|
|
const listener = vi.fn()
|
|
|
controller.subscribe(listener)
|
|
|
@@ -342,30 +298,28 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('propagates a failed load to a queued mutation without calling the wire', async () => {
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code: 'session-not-found', sessionId: SESSION },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.rate(MSG, 'positive')).toMatchObject({
|
|
|
ok: false,
|
|
|
error: { code: 'session-not-found' },
|
|
|
})
|
|
|
- expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
|
|
|
+ expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
it('keeps a later mutation running after an earlier one settles as a failure', async () => {
|
|
|
let first = true
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- put: () => {
|
|
|
- if (first) {
|
|
|
- first = false
|
|
|
- return Promise.resolve({ ok: false, error: new RemoteError('gateway/internal', 'first blew up', {}) })
|
|
|
- }
|
|
|
- return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.put.mockImplementation(() => {
|
|
|
+ if (first) {
|
|
|
+ first = false
|
|
|
+ return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', 'first blew up', {}) })
|
|
|
+ }
|
|
|
+ return Promise.resolve(ok({ ok: true, value: item({ rating: 'negative' }) }))
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
const [a, b] = await Promise.all([
|
|
|
controller.rate(MSG, 'positive'),
|
|
|
@@ -383,14 +337,17 @@ describe('MessageFeedbackController', () => {
|
|
|
// fiber unloads, and its authoritative item must not be published.
|
|
|
let release = (): void => {}
|
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
|
|
|
- put: async () => {
|
|
|
- await gate
|
|
|
- return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({
|
|
|
+ ok: true, value: { items: [item({ version: version('v1') })] },
|
|
|
+ }))
|
|
|
+ mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
|
|
|
+ await gate
|
|
|
+ return ok({
|
|
|
+ ok: false,
|
|
|
+ error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) },
|
|
|
+ })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
const listener = vi.fn()
|
|
|
controller.subscribe(listener)
|
|
|
@@ -407,14 +364,12 @@ describe('MessageFeedbackController', () => {
|
|
|
it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
|
|
|
let release = (): void => {}
|
|
|
const gate = new Promise<void>((resolve) => { release = resolve })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
|
|
- delete: async () => {
|
|
|
- await gate
|
|
|
- return { ok: false, error: { code: 'version-conflict', current: null } }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
|
|
|
+ mock.remote.messageFeedback.delete.mockImplementationOnce(async () => {
|
|
|
+ await gate
|
|
|
+ return ok({ ok: false, error: { code: 'version-conflict', current: null } })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
const pending = controller.retract(MSG, 'positive')
|
|
|
|
|
|
@@ -430,11 +385,11 @@ describe('MessageFeedbackController', () => {
|
|
|
|
|
|
it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
|
|
|
const existing = item({ version: version('v3'), rating: 'positive' })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
|
|
|
- put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
|
|
|
+ mock.remote.messageFeedback.put.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
expect(await controller.rate(MSG, 'negative', { text: 'far too long' })).toMatchObject({
|
|
|
@@ -446,11 +401,11 @@ describe('MessageFeedbackController', () => {
|
|
|
|
|
|
it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
|
|
|
const existing = item({ version: version('v4') })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
|
|
|
- delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
|
|
- })
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [existing] } }))
|
|
|
+ mock.remote.messageFeedback.delete.mockResolvedValueOnce(ok({
|
|
|
+ ok: false, error: { code: 'session-not-found', sessionId: SESSION },
|
|
|
+ }))
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
expect(await controller.retract(MSG, 'positive')).toMatchObject({
|
|
|
@@ -466,19 +421,17 @@ describe('MessageFeedbackController', () => {
|
|
|
const order: string[] = []
|
|
|
let releasePut = (): void => {}
|
|
|
const putGate = new Promise<void>((r) => { releasePut = r })
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => {
|
|
|
- order.push('list')
|
|
|
- return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
|
|
|
- },
|
|
|
- put: async () => {
|
|
|
- order.push('put:start')
|
|
|
- await putGate
|
|
|
- order.push('put:end')
|
|
|
- return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.list.mockImplementation(() => {
|
|
|
+ order.push('list')
|
|
|
+ return Promise.resolve(ok({ ok: true, value: { items: [item({ version: version('v1') })] } }))
|
|
|
+ })
|
|
|
+ mock.remote.messageFeedback.put.mockImplementationOnce(async () => {
|
|
|
+ order.push('put:start')
|
|
|
+ await putGate
|
|
|
+ order.push('put:end')
|
|
|
+ return ok({ ok: true, value: item({ version: version('v9'), rating: 'negative' }) })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
const rating = controller.rate(MSG, 'negative')
|
|
|
@@ -498,14 +451,12 @@ describe('MessageFeedbackController', () => {
|
|
|
const gate = new Promise<void>((r) => { release = r })
|
|
|
let started = (): void => {}
|
|
|
const listStarted = new Promise<void>((r) => { started = r })
|
|
|
- const { ctx, calls } = fakeRemote({
|
|
|
- list: async () => {
|
|
|
- started()
|
|
|
- await gate
|
|
|
- return { ok: true, value: { items: [] } }
|
|
|
- },
|
|
|
+ mock.remote.messageFeedback.list.mockImplementationOnce(async () => {
|
|
|
+ started()
|
|
|
+ await gate
|
|
|
+ return ok({ ok: true, value: { items: [] } })
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
const pending = controller.rate(MSG, 'positive')
|
|
|
|
|
|
await listStarted
|
|
|
@@ -513,19 +464,17 @@ describe('MessageFeedbackController', () => {
|
|
|
release()
|
|
|
|
|
|
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
|
|
- expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
|
|
|
+ expect(mock.remote.messageFeedback.put).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
it('renders a carrier failure from the Remote envelope', async () => {
|
|
|
// The generated face folds transport faults into ok:false with a
|
|
|
// RemoteFailure, so the controller reads them as values, not rejections.
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({
|
|
|
- ok: false,
|
|
|
- error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
- }),
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce({
|
|
|
+ ok: false,
|
|
|
+ error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.ensure()).toEqual({
|
|
|
ok: false,
|
|
|
@@ -535,13 +484,11 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('renders a carrier failure on a mutation without touching the view', async () => {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- put: () => Promise.resolve({
|
|
|
- ok: false,
|
|
|
- error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
- }),
|
|
|
+ mock.remote.messageFeedback.put.mockResolvedValueOnce({
|
|
|
+ ok: false,
|
|
|
+ error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
|
|
|
expect(await controller.rate(MSG, 'positive')).toEqual({
|
|
|
ok: false,
|
|
|
@@ -551,14 +498,12 @@ describe('MessageFeedbackController', () => {
|
|
|
})
|
|
|
|
|
|
it('renders a carrier failure on a delete', async () => {
|
|
|
- const { ctx } = fakeRemote({
|
|
|
- list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
|
|
- delete: () => Promise.resolve({
|
|
|
- ok: false,
|
|
|
- error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
- }),
|
|
|
+ mock.remote.messageFeedback.list.mockResolvedValueOnce(ok({ ok: true, value: { items: [item()] } }))
|
|
|
+ mock.remote.messageFeedback.delete.mockResolvedValueOnce({
|
|
|
+ ok: false,
|
|
|
+ error: new RemoteError('gateway/internal', 'socket closed', {}),
|
|
|
})
|
|
|
- const controller = new MessageFeedbackController(ctx, SESSION)
|
|
|
+ const controller = new MessageFeedbackController(mock.remote.messageFeedback, SESSION)
|
|
|
await controller.ensure()
|
|
|
|
|
|
expect(await controller.retract(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
|