Files
CherryHQ-cherry-studio/tests/__mocks__/README.md
fullex b424475573 feat(cache): add functional updater support to renderer cache hooks
useCache, useSharedCache and usePersistCache setters now accept a React-style functional updater `(prev) => next` in addition to a concrete value. The updater resolves `prev` from the latest stored value at write time rather than the render-time snapshot, making read-modify-write correct across an `await` — the root cause of the keep-alive overwrite race behind #16460.

`prev` is typed shallow-readonly (`ReadonlyValue<T>`), so mutating it in place and returning the same reference — which the CacheService `isEqual` short-circuit would otherwise swallow silently — is a compile error. Concrete-value calls are unchanged, so existing consumers keep compiling.

The renderer useCache mock mirrors the functional branch with the same default fallback; docs and hook tests updated. Consumer call-site adoption lands separately.
2026-06-29 01:34:05 -07:00

525 lines
18 KiB
Markdown

# Test Mocks
Unified test mocks for the project, organized by process type and globally configured in test setup files.
## Overview
### Available Mocks
| Process | Mock | Description |
|---------|------|-------------|
| Renderer | `CacheService` | Three-tier cache (memory/shared/persist) |
| Renderer | `DataApiService` | HTTP client for Data API |
| Renderer | `PreferenceService` | User preferences |
| Renderer | `useDataApi` | Data API hooks (useQuery, useMutation, etc.) |
| Renderer | `usePreference` | Preference hooks |
| Renderer | `useCache` | Cache hooks |
| Main | `application` | Unified mock application factory with `application.get()` |
| Main | `DbService` | Database service with mock db |
| Main | `CacheService` | Internal + shared cache |
| Main | `DataApiService` | API coordinator |
| Main | `PreferenceService` | Preference service |
### File Structure
```
tests/__mocks__/
├── renderer/
│ ├── CacheService.ts
│ ├── DataApiService.ts
│ ├── PreferenceService.ts
│ ├── useDataApi.ts
│ ├── usePreference.ts
│ └── useCache.ts
├── main/
│ ├── application.ts
│ ├── CacheService.ts
│ ├── DataApiService.ts
│ ├── DbService.ts
│ └── PreferenceService.ts
├── RendererLoggerService.ts
└── MainLoggerService.ts
```
### Test Setup
Mocks are globally configured in setup files:
- **Renderer**: `tests/renderer.setup.ts`
- **Main**: `tests/main.setup.ts`
### Import Path Alias
Use `@test-mocks/*` to import mock utilities:
```typescript
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService'
```
---
## Renderer Mocks
### CacheService
Three-tier cache system with type-safe methods (and casual/dynamic key methods on the Memory tier only).
#### Methods
| Category | Method | Signature |
|----------|--------|-----------|
| Memory (typed) | `get` | `<K>(key: K) => InferUseCacheValue<K>` |
| Memory (typed) | `set` | `<K>(key: K, value, ttl?) => void` |
| Memory (typed) | `has` | `<K>(key: K) => boolean` |
| Memory (typed) | `delete` | `<K>(key: K) => boolean` |
| Memory (typed) | `hasTTL` | `<K>(key: K) => boolean` |
| Memory (casual) | `getCasual` | `<T>(key: string) => T \| undefined` |
| Memory (casual) | `setCasual` | `<T>(key, value, ttl?) => void` |
| Memory (casual) | `hasCasual` | `(key: string) => boolean` |
| Memory (casual) | `deleteCasual` | `(key: string) => boolean` |
| Memory (casual) | `hasTTLCasual` | `(key: string) => boolean` |
| Shared (typed) | `getShared` | `<K>(key: K) => InferSharedCacheValue<K>` |
| Shared (typed) | `setShared` | `<K>(key: K, value, ttl?) => void` |
| Shared (typed) | `hasShared` | `<K>(key: K) => boolean` |
| Shared (typed) | `deleteShared` | `<K>(key: K) => boolean` |
| Shared (typed) | `hasSharedTTL` | `<K>(key: K) => boolean` |
| Persist | `getPersist` | `<K>(key: K) => RendererPersistCacheSchema[K]` |
| Persist | `setPersist` | `<K>(key: K, value) => void` |
| Persist | `hasPersist` | `(key) => boolean` |
| Hook mgmt | `registerHook` | `(key: string) => void` |
| Hook mgmt | `unregisterHook` | `(key: string) => void` |
| Ready state | `isSharedCacheReady` | `() => boolean` |
| Ready state | `onSharedCacheReady` | `(callback) => () => void` |
| Lifecycle | `subscribe` | `(key, callback) => () => void` |
| Lifecycle | `cleanup` | `() => void` |
#### Usage
```typescript
import { cacheService } from '@data/CacheService'
import { MockCacheUtils } from '@test-mocks/renderer/CacheService'
describe('Cache', () => {
beforeEach(() => MockCacheUtils.resetMocks())
it('basic usage', () => {
cacheService.setCasual('key', { data: 'value' }, 5000)
expect(cacheService.getCasual('key')).toEqual({ data: 'value' })
})
it('with test utilities', () => {
MockCacheUtils.setInitialState({
memory: [['key', 'value']],
shared: [['shared.key', 'shared']],
persist: [['persist.key', 'persist']]
})
})
})
```
---
### DataApiService
HTTP client with subscriptions and retry configuration.
#### Methods
| Method | Signature |
|--------|-----------|
| `get` | `(path, options?) => Promise<any>` |
| `post` | `(path, options) => Promise<any>` |
| `put` | `(path, options) => Promise<any>` |
| `patch` | `(path, options) => Promise<any>` |
| `delete` | `(path, options?) => Promise<any>` |
| `subscribe` | `(options, callback) => () => void` |
| `configureRetry` | `(options) => void` |
| `getRetryConfig` | `() => RetryOptions` |
| `getRequestStats` | `() => { pendingRequests, activeSubscriptions }` |
#### Usage
```typescript
import { dataApiService } from '@data/DataApiService'
import { MockDataApiUtils } from '@test-mocks/renderer/DataApiService'
describe('API', () => {
beforeEach(() => MockDataApiUtils.resetMocks())
it('basic request', async () => {
const response = await dataApiService.get('/topics')
expect(response.topics).toBeDefined()
})
it('custom response', async () => {
MockDataApiUtils.setCustomResponse('/topics', 'GET', { custom: true })
const response = await dataApiService.get('/topics')
expect(response.custom).toBe(true)
})
it('error simulation', async () => {
MockDataApiUtils.setErrorResponse('/topics', 'GET', new Error('Failed'))
await expect(dataApiService.get('/topics')).rejects.toThrow('Failed')
})
})
```
---
### useDataApi Hooks
React hooks for data operations.
#### Hooks
| Hook | Signature | Returns |
|------|-----------|---------|
| `useQuery` | `(path, options?)` | `{ data, loading, error, refetch, mutate }` |
| `useMutation` | `(method, path, options?)` | `{ mutate, loading, error }` |
| `usePaginatedQuery` | `(path, options?)` | `{ items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset }` |
| `useInvalidateCache` | `()` | `(keys?) => Promise<any>` |
| `useReadCache` | `()` | `(path, query?) => TResponse \| undefined` |
| `useWriteCache` | `()` | `async (path, value, query?) => void` |
#### Usage
```typescript
import { useQuery, useMutation, useReadCache, useWriteCache } from '@data/hooks/useDataApi'
import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi'
describe('Hooks', () => {
beforeEach(() => MockUseDataApiUtils.resetMocks())
it('useQuery', () => {
const { data, loading } = useQuery('/topics')
expect(loading).toBe(false)
expect(data).toBeDefined()
})
it('useMutation', async () => {
const { mutate } = useMutation('POST', '/topics')
const result = await mutate({ body: { name: 'New' } })
expect(result.created).toBe(true)
})
it('custom data', () => {
MockUseDataApiUtils.mockQueryData('/topics', { custom: true })
const { data } = useQuery('/topics')
expect(data.custom).toBe(true)
})
it('useReadCache reads seeded values', () => {
// Pre-populate the mock cache (key shape mirrors production:
// omit `query` for [path], pass a non-empty `query` for [path, query]).
MockUseDataApiUtils.seedCache('/topics', { topics: [{ id: 't1' }], total: 1 })
const read = useReadCache()
expect(read('/topics')).toEqual({ topics: [{ id: 't1' }], total: 1 })
})
it('useWriteCache persists to mock store (assertable via getCachedValue)', async () => {
const write = useWriteCache()
await write('/topics', { topics: [], total: 0 })
expect(MockUseDataApiUtils.getCachedValue('/topics')).toEqual({ topics: [], total: 0 })
})
})
```
> **Note:** `useReadCache`/`useWriteCache` share one in-memory `Map` under the hood. `resetMocks()` clears both call history and the cache store; use `clearCache()` if you want to drop cache entries without resetting hook mocks.
---
### useCache Hooks
React hooks for cache operations.
| Hook | Signature | Returns |
|------|-----------|---------|
| `useCache` | `(key, initValue?)` | `[value, setValue]` |
| `useSharedCache` | `(key, initValue?)` | `[value, setValue]` |
| `usePersistCache` | `(key)` | `[value, setValue]` |
`setValue` accepts a concrete value or a functional updater `(prev) => next` (mirrors production). The mock resolves the updater against the latest mocked value with the same default fallback, so functional-update call sites run unchanged under the mock.
```typescript
import { useCache } from '@data/hooks/useCache'
const [value, setValue] = useCache('key', 'default')
setValue('new value')
setValue((prev) => prev + '!') // functional updater
```
---
### usePreference Hooks
React hooks for preferences.
| Hook | Signature | Returns |
|------|-----------|---------|
| `usePreference` | `(key)` | `[value, setValue]` |
| `useMultiplePreferences` | `(keyMap)` | `[values, setValues]` |
```typescript
import { usePreference } from '@data/hooks/usePreference'
const [theme, setTheme] = usePreference('ui.theme')
await setTheme('dark')
```
---
## Main Process Mocks
### Scope
`tests/__mocks__/main/` holds mocks for **cross-cutting infrastructure only**: `PreferenceService`, `CacheService`, `DbService`, `DataApiService`, plus minimal `MainWindowService` / `WindowManager` stubs. All are pre-mocked globally via `tests/main.setup.ts`.
**Do not add files here for feature-specific lifecycle services** (e.g., `FileProcessingTaskService`, `KnowledgeRuntimeService`). The `ServiceOverrides` type is deliberately locked to `keyof typeof defaultServiceInstances` to enforce this boundary. Stub them locally — see [Testing Other Lifecycle Services](#testing-other-lifecycle-services).
| Service category | How to mock |
|---|---|
| Infrastructure (listed above) | Already mocked globally; override via `mockApplicationFactory({ Name: {...} })` |
| Feature-specific lifecycle service | Local `vi.mock('@application')` + `MockBaseService` in the test file |
| Direct-import singleton (no lifecycle) | `vi.mock('path/to/module')` directly |
### Application Mock (Unified Factory)
All main-process tests get `application.get()` mocked globally via `tests/main.setup.ts`. Tests that need custom service instances can override specific services using `mockApplicationFactory(overrides)`.
#### API
| Export | Description |
|--------|-------------|
| `mockApplicationFactory(overrides?)` | Returns full mock module `{ application, serviceList }` for `vi.mock()` |
| `createMockApplication(overrides?)` | Returns just the mock `application` object |
| `defaultServiceInstances` | Default mock instances for all registered services |
#### Usage
**Global setup** (already configured in `tests/main.setup.ts`):
```typescript
vi.mock('@application', async () => {
const { mockApplicationFactory } = await import('./__mocks__/main/application')
return mockApplicationFactory()
})
```
**Override infrastructure services** in individual test files:
```typescript
const mockDb = { select: vi.fn(), insert: vi.fn() }
vi.mock('@application', async () => {
const { mockApplicationFactory } = await import('@test-mocks/main/application')
return mockApplicationFactory({
DbService: { getDb: () => mockDb }
})
})
```
For **non-infrastructure** services, don't override here — use [Testing Other Lifecycle Services](#testing-other-lifecycle-services) instead.
---
### Main DbService
Database service providing access to the mock SQLite database.
#### Methods
| Method | Signature |
|--------|-----------|
| `getDb` | `() => MockDb` |
| `withWriteTx` | `<T>(fn: (tx) => Promise<T>) => Promise<T>` (passthrough — calls `fn(db)`) |
| `isReady` | `boolean` (getter) |
```typescript
import { MockMainDbServiceUtils } from '@test-mocks/main/DbService'
beforeEach(() => MockMainDbServiceUtils.resetMocks())
// Use default mock db
MockMainDbServiceUtils.getDefaultMockDb()
// Replace with custom db
MockMainDbServiceUtils.setDb(customMockDb)
```
> **`withWriteTx`**: passthrough (`async (fn) => fn(this.db)`) — no mutex / BUSY retry. Use `vi.spyOn(dbServiceInstance, 'withWriteTx')` to inject custom behavior. Hand-rolled DbService mocks MUST include this method or production code throws `TypeError: dbService.withWriteTx is not a function`.
---
### Main CacheService
Internal cache and cross-window shared cache.
#### Methods
| Category | Method | Signature |
|----------|--------|-----------|
| Lifecycle | `initialize` | `() => Promise<void>` |
| Lifecycle | `cleanup` | `() => void` |
| Internal | `get` | `<T>(key: string) => T \| undefined` |
| Internal | `set` | `<T>(key, value, ttl?) => void` |
| Internal | `has` | `(key: string) => boolean` |
| Internal | `delete` | `(key: string) => boolean` |
| Shared | `getShared` | `<K>(key: K) => SharedCacheSchema[K] \| undefined` |
| Shared | `setShared` | `<K>(key: K, value, ttl?) => void` |
| Shared | `hasShared` | `<K>(key: K) => boolean` |
| Shared | `deleteShared` | `<K>(key: K) => boolean` |
| Subscription | `subscribeChange` | `<T>(key, callback) => () => void` — returns a fresh `vi.fn()` unsubscribe stub |
| Subscription | `subscribeSharedChange` | `<K>(key, callback) => () => void` — returns a fresh `vi.fn()` unsubscribe stub |
> **Note on subscription mocks**: `subscribeChange` / `subscribeSharedChange` are call-tracking stubs — they do **not** replicate the real fire semantics. Use them to verify `registerDisposable(cacheService.subscribeChange(...))` wiring and that subscriptions happen, not to simulate callbacks. The `setShared` / `deleteShared` mocks also record every call to `broadcastCalls` unconditionally (no `isEqual` short-circuit), keeping `getBroadcastHistory()` consumers backward-compatible.
```typescript
import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService'
beforeEach(() => MockMainCacheServiceUtils.resetMocks())
MockMainCacheServiceUtils.setCacheValue('key', 'value')
MockMainCacheServiceUtils.setSharedCacheValue('shared.key', 'shared')
```
---
### Main DataApiService
API coordinator managing ApiServer and IpcAdapter.
#### Methods
| Method | Signature |
|--------|-----------|
| `initialize` | `() => Promise<void>` |
| `shutdown` | `() => Promise<void>` |
| `getSystemStatus` | `() => object` |
| `getApiServer` | `() => ApiServer` |
```typescript
import { MockMainDataApiServiceUtils } from '@test-mocks/main/DataApiService'
beforeEach(() => MockMainDataApiServiceUtils.resetMocks())
MockMainDataApiServiceUtils.simulateInitializationError(new Error('Failed'))
```
---
### Main PreferenceService
Preference store with typed keys, seeded from `DefaultPreferences.default`.
#### Methods
| Method | Signature |
|--------|-----------|
| `initialize` | `() => Promise<void>` |
| `get` | `<K>(key: K) => UnifiedPreferenceType[K]` |
| `set` | `<K>(key: K, value) => Promise<void>` |
| `getMultiple` | `<K>(keys: K[]) => Record<K, UnifiedPreferenceType[K]>` |
| `setMultiple` | `(values) => Promise<void>` |
| `subscribeForWindow` | `(windowId, keys) => void` |
```typescript
import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService'
beforeEach(() => MockMainPreferenceServiceUtils.resetMocks())
// Seed a preference value
MockMainPreferenceServiceUtils.setPreferenceValue('ui.theme', 'dark')
// Simulate an external change (fires main-process subscribers)
MockMainPreferenceServiceUtils.simulateExternalPreferenceChange('ui.theme', 'light')
```
Utilities: `setPreferenceValue`, `getPreferenceValue`, `setMultiplePreferenceValues`, `getAllPreferenceValues`, `simulateWindowSubscription`, `simulateExternalPreferenceChange`, `getSubscriptionCounts`.
---
### Testing Other Lifecycle Services
Stub feature-specific lifecycle services **locally in the test file**. A test typically needs three substitutions: `@application`, `BaseService`, and lifecycle decorators.
#### Canonical Setup
```typescript
import type * as LifecycleModule from '@main/core/lifecycle'
import { getDependencies, getPhase } from '@main/core/lifecycle/decorators'
import { Phase } from '@main/core/lifecycle/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { appGetMock, startTaskMock, getTaskMock } = vi.hoisted(() => ({
appGetMock: vi.fn(),
startTaskMock: vi.fn(),
getTaskMock: vi.fn()
}))
vi.mock('@application', () => ({
application: { get: appGetMock }
}))
vi.mock('@main/core/lifecycle', async (importOriginal) => {
const actual = await importOriginal<typeof LifecycleModule>()
class MockBaseService {
ipcHandle = vi.fn()
protected readonly _disposables: Array<{ dispose: () => void } | (() => void)> = []
protected registerDisposable<T extends { dispose: () => void } | (() => void)>(d: T): T {
this._disposables.push(d)
return d
}
}
return { ...actual, BaseService: MockBaseService }
})
beforeEach(() => {
vi.clearAllMocks()
appGetMock.mockImplementation((name: string) => {
if (name === 'FileProcessingTaskService') {
return { startTask: startTaskMock, getTask: getTaskMock }
}
throw new Error(`Unexpected application.get(${name})`)
})
})
// Import SUT after mocks are declared.
const { FileProcessingService } = await import('../FileProcessingService')
```
#### Common Assertions
Drive lifecycle hooks (`onInit` / `onStart` / `onStop` / `onDestroy`) manually — the container isn't running in tests.
| Target | How |
|---|---|
| Phase | `expect(getPhase(MyService)).toBe(Phase.WhenReady)` |
| Dependencies | `expect(getDependencies(MyService)).toEqual(['OtherService'])` |
| Registered IPC channels | `const svc = new MyService(); (svc as any).onInit(); (svc as any).ipcHandle.mock.calls.map(c => c[0])` |
| Single IPC handler | `ipcHandle.mock.calls.find(c => c[0] === 'channel')?.[1]`, then invoke |
| Disposables | Drive lifecycle, inspect `(svc as any)._disposables` |
#### Reference Implementations
- `src/main/services/knowledge/__tests__/KnowledgeService.test.ts` — dispatch stub + phase/deps + per-channel handler inspection
- `src/main/services/__tests__/ShortcutService.test.ts` — richer `MockBaseService` with `registerDisposable` + no-op decorator replacements
---
## Best Practices
1. Infrastructure services come pre-mocked; override via `mockApplicationFactory({ Name: {...} })`, not ad-hoc `application.get` mocks.
2. Feature-specific lifecycle services are stubbed locally — don't add them to `tests/__mocks__/main/` or `defaultServiceInstances`.
3. Each infrastructure mock exposes `MockMain<Name>ServiceUtils` with `resetMocks()` plus service-specific helpers (seeding values, simulating errors). Call `resetMocks()` in `beforeEach`.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Mock not applied | Check test runs in correct process (renderer/main in vitest.config.ts) |
| Type errors | Ensure mock matches actual interface, use type assertions if needed |
| State pollution | Call `resetMocks()` in `beforeEach` |
| Import issues | Use path aliases (`@data/CacheService`) not relative paths |