openreader/tests/unit/tts-provider-catalog.vitest.spec.ts
Richard R b2bbd8fbef feat(data-storage): migrate all user state from IndexedDB to server-backed storage
Remove all Dexie/IndexedDB code and dependencies, including document and preview caches, local config, onboarding, and migration logic. Replace with server-backed React Query hooks for documents, folders, preferences, onboarding, and progress. Add browser Cache Storage for blob caching of documents, previews, and audio. Update API routes, database schema, and tests to support folder management, onboarding state, and server-side persistence of all user data. Refactor UI and hooks to use server state exclusively, ensuring all user state is synced and portable across devices.

BREAKING CHANGE: All user data, preferences, onboarding, and document state are now stored and synced on the server; browser IndexedDB is no longer used. Existing local-only data will not be available after this update.
2026-06-14 18:13:39 -06:00

551 lines
17 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
getDefaultVoices,
providerSupportsCustomModel,
resolveProviderModels,
speechSdkProviderPrefix,
supportsNativeModelSpeed,
supportsTtsInstructions,
} from '../../src/lib/shared/tts-provider-catalog';
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
import {
resolveReplicateLanguageInputKey,
resolveReplicateVoiceInputKey,
resolveVoices,
} from '../../src/lib/server/tts/voice-resolution';
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
describe('tts provider catalog', () => {
test('resolves provider models with fixed Deepinfra catalog', () => {
expect(resolveProviderModels('openai').map((model) => model.id)).toEqual([
'tts-1',
'tts-1-hd',
'gpt-4o-mini-tts',
]);
expect(resolveProviderModels('deepinfra', {
apiKey: '',
}).map((model) => model.id)).toEqual([
'hexgrad/Kokoro-82M',
'canopylabs/orpheus-3b-0.1-ft',
'sesame/csm-1b',
'ResembleAI/chatterbox',
'Zyphra/Zonos-v0.1-hybrid',
'Zyphra/Zonos-v0.1-transformer',
'custom',
]);
});
test('resolves default voices and instruction support unchanged', () => {
expect(getDefaultVoices('openai', 'gpt-4o-mini-tts')).toContain('sage');
expect(getDefaultVoices('custom-openai', 'kokoro')).toContain('af_sarah');
// Non-kokoro custom-openai models fall back to the canonical OpenAI six.
expect(getDefaultVoices('custom-openai', 'supertonic-3')).toEqual(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']);
expect(getDefaultVoices('deepinfra', 'ResembleAI/chatterbox')).toEqual(['None']);
expect(getDefaultVoices('deepinfra', 'Zyphra/Zonos-v0.1-transformer')).toEqual(['random']);
expect(supportsTtsInstructions('gpt-4o-mini-tts')).toBe(true);
expect(supportsTtsInstructions('tts-1')).toBe(false);
expect(providerSupportsCustomModel('openai')).toBe(false);
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
});
test('resolves speech-sdk models, voices, and prefix parsing', () => {
expect(resolveProviderModels('speech-sdk').map((model) => model.id)).toContain('openai/gpt-4o-mini-tts');
expect(providerSupportsCustomModel('speech-sdk')).toBe(true);
expect(speechSdkProviderPrefix('elevenlabs/eleven_multilingual_v2')).toBe('elevenlabs');
// fal model ids are path-style, so only the first slash delimits the provider.
expect(speechSdkProviderPrefix('fal-ai/kokoro/american-english')).toBe('fal-ai');
expect(getDefaultVoices('speech-sdk', 'openai/gpt-4o-mini-tts')).toContain('sage');
expect(getDefaultVoices('speech-sdk', 'deepgram/aura-2')).toContain('thalia-en');
// Providers without a static voice list fall back to the provider default.
expect(getDefaultVoices('speech-sdk', 'mistral/voxtral-mini-tts-2603')).toEqual(['default']);
// Speed is applied client-side for speech-sdk so cached audio is speed-independent.
expect(supportsNativeModelSpeed('speech-sdk', 'openai/gpt-4o-mini-tts')).toBe(false);
});
test('uses blocklist semantics for Replicate native speed support', () => {
expect(supportsNativeModelSpeed('replicate', REPLICATE_KOKORO_82M_VERSIONED_MODEL)).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'acme/runtime-speed-model')).toBe(true);
expect(supportsNativeModelSpeed('replicate', 'google/gemini-3.1-flash-tts')).toBe(false);
expect(supportsNativeModelSpeed('replicate', 'qwen/qwen3-tts')).toBe(false);
});
test('preserves default-openai unless explicit non-default fallback is provided', () => {
expect(normalizeLegacyProviderRef('default-openai', 'shared-replicate')).toBe('shared-replicate');
expect(normalizeLegacyProviderRef('default-openai', '')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai', 'default-openai')).toBe('default-openai');
expect(normalizeLegacyProviderRef('default-openai')).toBe('default-openai');
});
test('resolveProviderDefaults keeps runtime/admin default provider ref when valid', () => {
const defaults = resolveProviderDefaults({
providerRef: 'default-openai',
providerType: 'unknown',
});
expect(defaults.providerRef).toBe('default-openai');
expect(defaults.providerType).toBe('unknown');
});
test('resolves shared-provider default model as authoritative', () => {
const defaults = resolveProviderDefaults({
providerRef: 'shared-replicate',
providerType: 'unknown',
fallbackProviderRef: 'shared-replicate',
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: 'Narrate warmly',
},
],
});
expect(defaults.providerRef).toBe('shared-replicate');
expect(defaults.providerType).toBe('replicate');
expect(defaults.defaultModel).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
expect(defaults.defaultInstructions).toBe('Narrate warmly');
});
test('resolveTtsModelForProvider enforces shared default model when showAllProviderModels is false', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: false,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
});
test('resolveTtsModelForProvider keeps requested model when showAllProviderModels is true', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'shared-replicate',
providerType: 'unknown',
model: 'acme/custom-model',
showAllProviderModels: true,
sharedProviders: [
{
slug: 'shared-replicate',
providerType: 'replicate',
defaultModel: REPLICATE_KOKORO_82M_VERSIONED_MODEL,
defaultInstructions: null,
},
],
});
expect(resolved).toBe('acme/custom-model');
});
test('resolveTtsModelForProvider falls back to built-in provider default model when locked', () => {
const resolved = resolveTtsModelForProvider({
providerRef: 'openai',
providerType: 'openai',
model: 'gpt-4o-mini-tts',
showAllProviderModels: false,
});
expect(resolved).toBe('tts-1');
});
test('keeps explicit empty custom-openai voices response', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: true,
json: async () => ({ voices: [] }),
}) as Response;
try {
await expect(resolveVoices({
provider: 'custom-openai',
model: 'kokoro',
apiKey: 'token',
baseUrl: 'https://example.com',
})).resolves.toEqual([]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fetches custom Replicate model voices from model schema', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
let url = '';
globalThis.fetch = async (input) => {
calls += 1;
url = String(input);
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['Narrator A', 'Narrator B'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-voices',
apiKey: 'r8_token',
})).resolves.toEqual(['Narrator A', 'Narrator B']);
expect(calls).toBe(1);
expect(url).toContain('/v1/models/acme/my-tts-model-voices');
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to default voice for custom Replicate models when schema lookup fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => ({
ok: false,
status: 404,
json: async () => ({}),
}) as Response;
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/my-tts-model-fallback',
apiKey: 'r8_token',
})).resolves.toEqual(['default']);
} finally {
globalThis.fetch = originalFetch;
}
});
test('uses schema voices first for built-in Replicate models', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: {
type: 'string',
enum: ['Schema Aiden', 'Schema Dylan'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'qwen/qwen3-tts',
apiKey: 'r8_token',
})).resolves.toEqual(['Schema Aiden', 'Schema Dylan']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('falls back to built-in Replicate static voices when schema has no voice enum', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
speaker: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'inworld/tts-1.5-mini',
apiKey: 'r8_token',
})).resolves.toEqual(['Ashley', 'Dennis', 'Alex', 'Darlene']);
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('resolves Replicate custom-model voice input key and caches it', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice_id: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/custom-voice-model',
apiKey: 'r8_token',
})).resolves.toBe('voice_id');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('shares Replicate schema fetch between voices and voice-key resolution', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
voice: {
type: 'string',
enum: ['V1', 'V2'],
},
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveVoices({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toEqual(['V1', 'V2']);
await expect(resolveReplicateVoiceInputKey({
provider: 'replicate',
model: 'acme/shared-schema-model',
apiKey: 'r8_token',
})).resolves.toBe('voice');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('discovers Replicate language input key from model schema', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
text: { type: 'string' },
language_code: { type: 'string' },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'acme/schema-language-model',
apiKey: 'r8_token',
})).resolves.toBe('language_code');
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'acme/schema-language-model',
apiKey: 'r8_token',
})).resolves.toBe('language_code');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
test('discovers Replicate language input key for built-in models too', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return {
ok: true,
json: async () => ({
latest_version: {
openapi_schema: {
components: {
schemas: {
Input: {
type: 'object',
properties: {
text: { type: 'string' },
language: { type: 'string', enum: ['auto', 'en', 'ja'] },
},
},
},
},
},
},
}),
} as Response;
};
try {
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
await expect(resolveReplicateLanguageInputKey({
provider: 'replicate',
model: 'minimax/speech-2.8-turbo',
apiKey: 'r8_token',
})).resolves.toBe('language');
expect(calls).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('config helpers', () => {
test('persists voice preferences by provider/model pair', () => {
expect(getVoicePreferenceKey('openai', 'tts-1')).toBe('openai:tts-1');
const voiceUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {},
}, 'voice', 'alloy');
expect(voiceUpdate.storagePatch).toEqual({
savedVoices: { 'openai:tts-1': 'alloy' },
voice: 'alloy',
});
const providerUpdate = applyConfigUpdate({
providerRef: 'openai',
providerType: 'openai',
ttsModel: 'tts-1',
savedVoices: {
'deepinfra:hexgrad/Kokoro-82M': 'af_sarah',
},
}, 'providerRef', 'deepinfra');
expect(providerUpdate.storagePatch).toEqual({
providerRef: 'deepinfra',
voice: '',
});
});
test('builds synced preference patches and honors non-default filtering', () => {
expect(buildSyncedPreferencePatch({
voiceSpeed: 1.2,
ttsModel: 'kokoro',
})).toEqual({
voiceSpeed: 1.2,
ttsModel: 'kokoro',
});
// The user's provider/model now default to empty ("inherit the admin
// default"), so empty values are treated as defaults and filtered out.
expect(buildSyncedPreferencePatch({
voiceSpeed: 1,
providerRef: '',
providerType: 'unknown',
ttsModel: '',
}, { nonDefaultOnly: true })).toEqual({});
});
});