|
|
@@ -0,0 +1,166 @@
|
|
|
+import { describe, expect, it, vi } from 'vitest'
|
|
|
+import type { RpcResponse } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
+import { WelcomeNoticeStore } from '../src/client/welcome-store.ts'
|
|
|
+import { refreshWelcomeIfLoaded } from '../src/client/welcome-store.ts'
|
|
|
+import {
|
|
|
+ WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
|
|
+} from '../src/onboarding-copy.ts'
|
|
|
+
|
|
|
+let rpc = 0
|
|
|
+function ok<T>(value: T): RpcResponse<T> {
|
|
|
+ return { rpcId: `welcome-${rpc++}` as never, result: { ok: true, value } }
|
|
|
+}
|
|
|
+
|
|
|
+function namespace(version?: string) {
|
|
|
+ return {
|
|
|
+ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
|
+ schema: {},
|
|
|
+ value: version === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: version },
|
|
|
+ applies: 'live' as const,
|
|
|
+ secrets: [],
|
|
|
+ revision: 0,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function deferred<T>() {
|
|
|
+ let resolve!: (value: T) => void
|
|
|
+ let reject!: (reason: unknown) => void
|
|
|
+ const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
|
|
+ return { promise, resolve, reject }
|
|
|
+}
|
|
|
+
|
|
|
+describe('WelcomeNoticeStore', () => {
|
|
|
+ it('acknowledges only the exact current copy version', async () => {
|
|
|
+ for (const [version, acknowledged] of [
|
|
|
+ [undefined, false],
|
|
|
+ ['older-copy', false],
|
|
|
+ [WELCOME_NOTICE_VERSION, true],
|
|
|
+ ] as const) {
|
|
|
+ const api = {
|
|
|
+ settings: {
|
|
|
+ describe: vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(version)] }))),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const controller = new WelcomeNoticeStore(api as never)
|
|
|
+ await controller.load()
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged })
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('persists the owner version through one idempotent path mutation', async () => {
|
|
|
+ const mutate = vi.fn(() => Promise.resolve(ok(namespace(WELCOME_NOTICE_VERSION))))
|
|
|
+ const controller = new WelcomeNoticeStore({ settings: { mutate } } as never)
|
|
|
+ await expect(controller.acknowledge()).resolves.toBe(true)
|
|
|
+ expect(mutate).toHaveBeenCalledWith({
|
|
|
+ ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
|
|
+ ops: [{ op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION }],
|
|
|
+ })
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps the notice pending when loading or persistence fails', async () => {
|
|
|
+ const load = new WelcomeNoticeStore({
|
|
|
+ settings: { describe: () => Promise.reject(new Error('offline')) },
|
|
|
+ } as never)
|
|
|
+ await load.load()
|
|
|
+ expect(load.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'offline' })
|
|
|
+
|
|
|
+ const save = new WelcomeNoticeStore({
|
|
|
+ settings: { mutate: () => Promise.reject(new Error('disk full')) },
|
|
|
+ } as never)
|
|
|
+ await expect(save.acknowledge()).resolves.toBe(false)
|
|
|
+ expect(save.store.getSnapshot()).toEqual({ status: 'error', acknowledged: false, error: 'disk full' })
|
|
|
+
|
|
|
+ const nonError = new WelcomeNoticeStore({
|
|
|
+ // Durable/wire failures are unknown; exercise containment of a non-Error rejection.
|
|
|
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors
|
|
|
+ settings: { describe: () => Promise.reject('offline string') },
|
|
|
+ } as never)
|
|
|
+ await nonError.load()
|
|
|
+ expect(nonError.store.getSnapshot().error).toBe('offline string')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports business failures, missing namespaces, and malformed durable values', async () => {
|
|
|
+ for (const describe of [
|
|
|
+ () => Promise.resolve({
|
|
|
+ rpcId: 'failed' as never,
|
|
|
+ result: { ok: false as const, error: { code: 'internal' as const, message: 'denied', details: {} } },
|
|
|
+ }),
|
|
|
+ () => Promise.resolve(ok({ writable: true, namespaces: [] })),
|
|
|
+ ]) {
|
|
|
+ const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
|
+ await controller.load()
|
|
|
+ expect(controller.store.getSnapshot().status).toBe('error')
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const value of [null, 42, { [WELCOME_NOTICE_ACK_FIELD]: 42 }]) {
|
|
|
+ const controller = new WelcomeNoticeStore({
|
|
|
+ settings: { describe: () => Promise.resolve(ok({
|
|
|
+ writable: true,
|
|
|
+ namespaces: [{ ...namespace(), value }],
|
|
|
+ })) },
|
|
|
+ } as never)
|
|
|
+ await controller.load()
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
|
|
|
+ }
|
|
|
+
|
|
|
+ const save = new WelcomeNoticeStore({
|
|
|
+ settings: { mutate: () => Promise.resolve({
|
|
|
+ rpcId: 'failed-save' as never,
|
|
|
+ result: { ok: false, error: { code: 'settings-rejected', message: 'denied', details: { ns: WELCOME_NOTICE_SETTINGS_NAMESPACE } } },
|
|
|
+ }) },
|
|
|
+ } as never)
|
|
|
+ await expect(save.acknowledge()).resolves.toBe(false)
|
|
|
+ expect(save.store.getSnapshot().error).toBe('denied')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('lets the latest load win over stale success and failure', async () => {
|
|
|
+ const first = deferred<ReturnType<typeof ok>>()
|
|
|
+ const describe = vi.fn()
|
|
|
+ .mockImplementationOnce(() => first.promise)
|
|
|
+ .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
|
|
+ const controller = new WelcomeNoticeStore({ settings: { describe } } as never)
|
|
|
+ const stale = controller.load()
|
|
|
+ await controller.load()
|
|
|
+ first.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] }))
|
|
|
+ await stale
|
|
|
+ expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
|
+
|
|
|
+ const failed = deferred<ReturnType<typeof ok>>()
|
|
|
+ describe
|
|
|
+ .mockImplementationOnce(() => failed.promise)
|
|
|
+ .mockImplementationOnce(() => Promise.resolve(ok({ writable: true, namespaces: [namespace(WELCOME_NOTICE_VERSION)] })))
|
|
|
+ const staleFailure = controller.load()
|
|
|
+ await controller.load()
|
|
|
+ failed.reject('stale failure')
|
|
|
+ await staleFailure
|
|
|
+ expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true, error: null })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains stale acknowledgement settlements and refreshes only a loaded store', async () => {
|
|
|
+ const write = deferred<ReturnType<typeof ok>>()
|
|
|
+ const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [namespace()] })))
|
|
|
+ const controller = new WelcomeNoticeStore({
|
|
|
+ settings: { mutate: () => write.promise, describe },
|
|
|
+ } as never)
|
|
|
+ refreshWelcomeIfLoaded(controller)
|
|
|
+ expect(describe).not.toHaveBeenCalled()
|
|
|
+ const staleWrite = controller.acknowledge()
|
|
|
+ await controller.load()
|
|
|
+ write.resolve(ok(namespace(WELCOME_NOTICE_VERSION)))
|
|
|
+ await expect(staleWrite).resolves.toBe(true)
|
|
|
+ expect(controller.store.getSnapshot().acknowledged).toBe(false)
|
|
|
+ refreshWelcomeIfLoaded(controller)
|
|
|
+ await vi.waitFor(() => { expect(describe).toHaveBeenCalledTimes(2) })
|
|
|
+
|
|
|
+ const failedWrite = deferred<ReturnType<typeof ok>>()
|
|
|
+ const staleFailure = new WelcomeNoticeStore({
|
|
|
+ settings: { mutate: () => failedWrite.promise, describe },
|
|
|
+ } as never)
|
|
|
+ const pending = staleFailure.acknowledge()
|
|
|
+ await staleFailure.load()
|
|
|
+ failedWrite.reject('late failure')
|
|
|
+ await expect(pending).resolves.toBe(false)
|
|
|
+ expect(staleFailure.store.getSnapshot().status).toBe('ready')
|
|
|
+ })
|
|
|
+})
|