mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
We own the ingestion server (hanzoai/insights: Django + rust/capture + nodejs plugin) AND this SDK, so there is no external wire contract to preserve. The server is already 100% posthog-free; this SDK was the last laggard. - $last_posthog_reset -> $last_insights_reset. This FIXES A LIVE MISMATCH: the server taxonomy (insights/taxonomy/taxonomy.py, insights/insightsql/ai.py) already expected the insights form while the SDK emitted the posthog form. - __PosthogExtensions__ -> __InsightsExtensions__; all PostHog/posthog/POSTHOG identifiers -> Insights/insights/INSIGHTS (src, tests, playground, playwright, scripts, rrweb subpackages, package.json keywords). - Hosts: DEFAULT_PLUGIN_HOST + DEFAULT_NUXT_HOST + the core host default pointed at PostHog's cloud (us.i.posthog.com) — a real bug. All -> insights.hanzo.ai. - Removed the us-assets/eu-assets CDN split in getRemoteConfig (PostHog-specific; Hanzo serves remote config from the same host) — it had degenerated to a no-op. - Fixed generate-docs writing to a stray references/@hanzo/ subdir. - Renamed the 3 posthog-named test files; dropped 58MB of stale reference snapshots named for upstream posthog versions we never shipped (they are generated output — references/ is now gitignored). Verified: typecheck errors 42 (HEAD) -> 18 (all pre-existing test-mock looseness); types package 0; every package.json valid with @hanzo/* names. Guard: grep -rioE 'posthog' packages ... | wc -l => 0
1011 lines
32 KiB
TypeScript
1011 lines
32 KiB
TypeScript
import { Prompts } from '../src/prompts'
|
|
import type { PromptApiResponse } from '../src/types'
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = jest.fn()
|
|
global.fetch = mockFetch
|
|
|
|
// Mock console.warn to capture warnings
|
|
const originalWarn = console.warn
|
|
let consoleWarnSpy: jest.SpyInstance
|
|
|
|
describe('Prompts', () => {
|
|
const mockPromptResponse: PromptApiResponse = {
|
|
id: 1,
|
|
name: 'test-prompt',
|
|
prompt: 'Hello, {{name}}! You are a helpful assistant for {{company}}.',
|
|
version: 1,
|
|
created_by: '[email protected]',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
deleted: false,
|
|
}
|
|
|
|
const createMockInsights = (options: { personalApiKey?: string; projectApiKey?: string; host?: string } = {}) => {
|
|
return {
|
|
options: {
|
|
personalApiKey: 'personalApiKey' in options ? options.personalApiKey : 'phx_test_key',
|
|
},
|
|
apiKey: 'projectApiKey' in options ? options.projectApiKey : 'phc_test_key',
|
|
host: options.host ?? 'https://insights.hanzo.ai',
|
|
} as any
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
jest.useFakeTimers()
|
|
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
consoleWarnSpy.mockRestore()
|
|
console.warn = originalWarn
|
|
})
|
|
|
|
describe('get()', () => {
|
|
it('should successfully fetch a prompt', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = await prompts.get('test-prompt')
|
|
|
|
expect(result).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer phx_test_key',
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
it('should trim whitespace-sensitive credentials and host before fetching', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const prompts = new Prompts({
|
|
personalApiKey: ' phx_test_key\n',
|
|
projectApiKey: ' phc_test_key\t ',
|
|
host: ' https://insights.hanzo.ai/\t ',
|
|
})
|
|
|
|
const result = await prompts.get('test-prompt')
|
|
|
|
expect(result).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer phx_test_key',
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
it('should fetch a specific prompt version when requested', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Version 2 prompt' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = await prompts.get('test-prompt', { version: 2 })
|
|
|
|
expect(result).toBe('Version 2 prompt')
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&version=2',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer phx_test_key',
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
it('should return cached prompt when fresh', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call - fetches from API
|
|
const result1 = await prompts.get('test-prompt', { cacheTtlSeconds: 300 })
|
|
expect(result1).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time by 60 seconds (still within TTL)
|
|
jest.advanceTimersByTime(60 * 1000)
|
|
|
|
// Second call - should use cache
|
|
const result2 = await prompts.get('test-prompt', { cacheTtlSeconds: 300 })
|
|
expect(result2).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch
|
|
})
|
|
|
|
it('should keep latest and versioned prompt caches separate', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const latestResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300 })
|
|
const versionedResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })
|
|
|
|
expect(latestResult).toBe('Latest prompt')
|
|
expect(versionedResult).toBe('Version 1 prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300 })).resolves.toBe('Latest prompt')
|
|
await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })).resolves.toBe('Version 1 prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should refetch when cache is stale', async () => {
|
|
const updatedPromptResponse = {
|
|
...mockPromptResponse,
|
|
prompt: 'Updated prompt: Hello, {{name}}!',
|
|
}
|
|
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(updatedPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call - fetches from API
|
|
const result1 = await prompts.get('test-prompt', { cacheTtlSeconds: 60 })
|
|
expect(result1).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time past TTL
|
|
jest.advanceTimersByTime(61 * 1000)
|
|
|
|
// Second call - should refetch
|
|
const result2 = await prompts.get('test-prompt', { cacheTtlSeconds: 60 })
|
|
expect(result2).toBe(updatedPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should use stale cache on fetch failure with warning', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call - populates cache
|
|
const result1 = await prompts.get('test-prompt', { cacheTtlSeconds: 60 })
|
|
expect(result1).toBe(mockPromptResponse.prompt)
|
|
|
|
// Advance time past TTL
|
|
jest.advanceTimersByTime(61 * 1000)
|
|
|
|
// Second call - should use stale cache
|
|
const result2 = await prompts.get('test-prompt', { cacheTtlSeconds: 60 })
|
|
expect(result2).toBe(mockPromptResponse.prompt)
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to fetch prompt "test-prompt", using stale cache:'),
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
it('should use fallback when no cache and fetch fails with warning', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const fallback = 'Default system prompt.'
|
|
const result = await prompts.get('test-prompt', { fallback })
|
|
|
|
expect(result).toBe(fallback)
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to fetch prompt "test-prompt", using fallback:'),
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
it('should throw when no cache, no fallback, and fetch fails', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('test-prompt')).rejects.toThrow('Network error')
|
|
})
|
|
|
|
it('should include the requested version in versioned fetch errors', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('nonexistent-prompt', { version: 7 })).rejects.toThrow(
|
|
'[Insights Prompts] Prompt "nonexistent-prompt" version 7 not found'
|
|
)
|
|
})
|
|
|
|
it('should handle 404 response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('nonexistent-prompt')).rejects.toThrow(
|
|
'[Insights Prompts] Prompt "nonexistent-prompt" not found'
|
|
)
|
|
})
|
|
|
|
it('should handle 403 response', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 403,
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('restricted-prompt')).rejects.toThrow(
|
|
'[Insights Prompts] Access denied for prompt "restricted-prompt"'
|
|
)
|
|
})
|
|
|
|
it('should throw when no personalApiKey is configured', async () => {
|
|
const insights = createMockInsights({ personalApiKey: undefined })
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('test-prompt')).rejects.toThrow(
|
|
'[Insights Prompts] personalApiKey is required to fetch prompts'
|
|
)
|
|
})
|
|
|
|
it('should throw when no projectApiKey is configured', async () => {
|
|
const insights = createMockInsights({ projectApiKey: undefined })
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('test-prompt')).rejects.toThrow(
|
|
'[Insights Prompts] projectApiKey is required to fetch prompts'
|
|
)
|
|
})
|
|
|
|
it('should throw when API returns invalid response format', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ invalid: 'response' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('test-prompt')).rejects.toThrow(
|
|
'[Insights Prompts] Invalid response format for prompt "test-prompt"'
|
|
)
|
|
})
|
|
|
|
it('should use custom host from Insights options', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights({ host: 'https://insights.hanzo.ai' })
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('test-prompt')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key',
|
|
expect.any(Object)
|
|
)
|
|
})
|
|
|
|
it('should use default cache TTL when not specified', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time by 4 minutes (within default 5-minute TTL)
|
|
jest.advanceTimersByTime(4 * 60 * 1000)
|
|
|
|
// Second call - should use cache
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time past 5-minute TTL
|
|
jest.advanceTimersByTime(2 * 60 * 1000)
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
// Third call - should refetch
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should use custom default cache TTL from constructor', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights, defaultCacheTtlSeconds: 60 })
|
|
|
|
// First call
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time past custom TTL
|
|
jest.advanceTimersByTime(61 * 1000)
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
// Second call - should refetch
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should URL-encode prompt names with special characters', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('prompt with spaces/and/slashes')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key',
|
|
expect.any(Object)
|
|
)
|
|
})
|
|
|
|
it('should work with direct options (no Insights client)', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const prompts = new Prompts({
|
|
personalApiKey: 'phx_direct_key',
|
|
projectApiKey: 'phc_direct_key',
|
|
})
|
|
|
|
const result = await prompts.get('test-prompt')
|
|
|
|
expect(result).toBe(mockPromptResponse.prompt)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer phx_direct_key',
|
|
},
|
|
}
|
|
)
|
|
})
|
|
|
|
it('should use custom host from direct options', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const prompts = new Prompts({
|
|
personalApiKey: 'phx_direct_key',
|
|
projectApiKey: 'phc_direct_key',
|
|
host: 'https://insights.hanzo.ai',
|
|
})
|
|
|
|
await prompts.get('test-prompt')
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'https://insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key',
|
|
expect.any(Object)
|
|
)
|
|
})
|
|
|
|
it('should use custom default cache TTL from direct options', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const prompts = new Prompts({
|
|
personalApiKey: 'phx_direct_key',
|
|
projectApiKey: 'phc_direct_key',
|
|
defaultCacheTtlSeconds: 60,
|
|
})
|
|
|
|
// First call
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
// Advance time past custom TTL
|
|
jest.advanceTimersByTime(61 * 1000)
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
// Second call - should refetch
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('get() with withMetadata: true', () => {
|
|
it('should return a PromptRemoteResult with source "api" on fresh fetch', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = await prompts.get('test-prompt', { withMetadata: true })
|
|
|
|
expect(result).toEqual({
|
|
source: 'api',
|
|
prompt: mockPromptResponse.prompt,
|
|
name: 'test-prompt',
|
|
version: 1,
|
|
})
|
|
})
|
|
|
|
it('should return source "cache" on fresh cache hit', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call populates cache
|
|
await prompts.get('test-prompt', { withMetadata: true })
|
|
|
|
// Second call should hit cache
|
|
const result = await prompts.get('test-prompt', { withMetadata: true, cacheTtlSeconds: 300 })
|
|
|
|
expect(result).toEqual({
|
|
source: 'cache',
|
|
prompt: mockPromptResponse.prompt,
|
|
name: 'test-prompt',
|
|
version: 1,
|
|
})
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should return source "stale_cache" on fetch failure with stale cache', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call populates cache
|
|
await prompts.get('test-prompt', { withMetadata: true, cacheTtlSeconds: 60 })
|
|
|
|
// Advance past TTL
|
|
jest.advanceTimersByTime(61 * 1000)
|
|
|
|
// Second call should use stale cache
|
|
const result = await prompts.get('test-prompt', { withMetadata: true, cacheTtlSeconds: 60 })
|
|
|
|
expect(result).toEqual({
|
|
source: 'stale_cache',
|
|
prompt: mockPromptResponse.prompt,
|
|
name: 'test-prompt',
|
|
version: 1,
|
|
})
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('using stale cache'), expect.any(Error))
|
|
})
|
|
|
|
it('should return source "code_fallback" with undefined name/version when fallback is used', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = await prompts.get('test-prompt', {
|
|
withMetadata: true,
|
|
fallback: 'Default system prompt.',
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
source: 'code_fallback',
|
|
prompt: 'Default system prompt.',
|
|
name: undefined,
|
|
version: undefined,
|
|
})
|
|
})
|
|
|
|
it('should throw when no cache and no fallback', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await expect(prompts.get('test-prompt', { withMetadata: true })).rejects.toThrow('Network error')
|
|
})
|
|
|
|
it('should return correct version metadata for versioned fetches', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 3, prompt: 'Version 3 prompt' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = await prompts.get('test-prompt', { withMetadata: true, version: 3 })
|
|
|
|
expect(result).toEqual({
|
|
source: 'api',
|
|
prompt: 'Version 3 prompt',
|
|
name: 'test-prompt',
|
|
version: 3,
|
|
})
|
|
})
|
|
|
|
it('should share cache with non-metadata get() calls', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// First call without metadata populates cache
|
|
const stringResult = await prompts.get('test-prompt', { withMetadata: false })
|
|
expect(stringResult).toBe(mockPromptResponse.prompt)
|
|
|
|
// Second call with metadata should use cache — no additional fetch
|
|
const metadataResult = await prompts.get('test-prompt', { withMetadata: true })
|
|
expect(metadataResult).toEqual({
|
|
source: 'cache',
|
|
prompt: mockPromptResponse.prompt,
|
|
name: 'test-prompt',
|
|
version: 1,
|
|
})
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('get() deprecation warning', () => {
|
|
it.each([
|
|
[undefined, 1],
|
|
[false, 0],
|
|
[true, 0],
|
|
] as const)('deprecation warning count when withMetadata=%s', async (withMetadata, expectedWarnings) => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'other-prompt' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('test-prompt', withMetadata !== undefined ? { withMetadata } : undefined)
|
|
await prompts.get('other-prompt', withMetadata !== undefined ? { withMetadata } : undefined)
|
|
|
|
const deprecationWarnings = consoleWarnSpy.mock.calls.filter(
|
|
(call: unknown[]) => typeof call[0] === 'string' && call[0].includes('deprecated')
|
|
)
|
|
expect(deprecationWarnings).toHaveLength(expectedWarnings)
|
|
})
|
|
})
|
|
|
|
describe('compile()', () => {
|
|
it('should replace a single variable', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Hello, {{name}}!', { name: 'World' })
|
|
|
|
expect(result).toBe('Hello, World!')
|
|
})
|
|
|
|
it('should replace multiple variables', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Hello, {{name}}! Welcome to {{company}}. Your tier is {{tier}}.', {
|
|
name: 'John',
|
|
company: 'Acme Corp',
|
|
tier: 'premium',
|
|
})
|
|
|
|
expect(result).toBe('Hello, John! Welcome to Acme Corp. Your tier is premium.')
|
|
})
|
|
|
|
it('should handle numbers', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('You have {{count}} items.', { count: 42 })
|
|
|
|
expect(result).toBe('You have 42 items.')
|
|
})
|
|
|
|
it('should handle booleans', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Feature enabled: {{enabled}}', { enabled: true })
|
|
|
|
expect(result).toBe('Feature enabled: true')
|
|
})
|
|
|
|
it('should leave unmatched variables unchanged', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Hello, {{name}}! Your {{unknown}} is ready.', { name: 'World' })
|
|
|
|
expect(result).toBe('Hello, World! Your {{unknown}} is ready.')
|
|
})
|
|
|
|
it('should handle prompts with no variables', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('You are a helpful assistant.', {})
|
|
|
|
expect(result).toBe('You are a helpful assistant.')
|
|
})
|
|
|
|
it('should handle empty variables object', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Hello, {{name}}!', {})
|
|
|
|
expect(result).toBe('Hello, {{name}}!')
|
|
})
|
|
|
|
it('should handle multiple occurrences of the same variable', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
const result = prompts.compile('Hello, {{name}}! Goodbye, {{name}}!', { name: 'World' })
|
|
|
|
expect(result).toBe('Hello, World! Goodbye, World!')
|
|
})
|
|
|
|
it('should work with direct options initialization', () => {
|
|
const prompts = new Prompts({ personalApiKey: 'phx_test_key', projectApiKey: 'phc_test_key' })
|
|
|
|
const result = prompts.compile('Hello, {{name}}!', { name: 'World' })
|
|
|
|
expect(result).toBe('Hello, World!')
|
|
})
|
|
|
|
it('should handle variables with hyphens', () => {
|
|
const prompts = new Prompts({ personalApiKey: 'phx_test_key', projectApiKey: 'phc_test_key' })
|
|
|
|
const result = prompts.compile('User ID: {{user-id}}', { 'user-id': '12345' })
|
|
|
|
expect(result).toBe('User ID: 12345')
|
|
})
|
|
|
|
it('should handle variables with dots', () => {
|
|
const prompts = new Prompts({ personalApiKey: 'phx_test_key', projectApiKey: 'phc_test_key' })
|
|
|
|
const result = prompts.compile('Company: {{company.name}}', { 'company.name': 'Acme' })
|
|
|
|
expect(result).toBe('Company: Acme')
|
|
})
|
|
})
|
|
|
|
describe('clearCache()', () => {
|
|
it('should throw when clearing a specific version without a prompt name', () => {
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
expect(() => prompts.clearCache(undefined, 1)).toThrow("'version' requires 'name' to be provided")
|
|
})
|
|
|
|
it('should clear a specific prompt from cache', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'other-prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// Populate cache with two prompts
|
|
await prompts.get('test-prompt')
|
|
await prompts.get('other-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
// Clear only test-prompt
|
|
prompts.clearCache('test-prompt')
|
|
|
|
// test-prompt should be refetched
|
|
await prompts.get('test-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
|
|
// other-prompt should still be cached
|
|
await prompts.get('other-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('should clear all prompts from cache when no name is provided', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'other-prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockPromptResponse),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'other-prompt' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
// Populate cache with two prompts
|
|
await prompts.get('test-prompt')
|
|
await prompts.get('other-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
// Clear all cache
|
|
prompts.clearCache()
|
|
|
|
// Both prompts should be refetched
|
|
await prompts.get('test-prompt')
|
|
await prompts.get('other-prompt')
|
|
expect(mockFetch).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('should clear all cached versions for a prompt when a name is provided', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt refreshed' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('test-prompt')
|
|
await prompts.get('test-prompt', { version: 1 })
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
prompts.clearCache('test-prompt')
|
|
|
|
await expect(prompts.get('test-prompt')).resolves.toBe('Latest prompt refreshed')
|
|
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
|
|
expect(mockFetch).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('should clear only the requested cached version when name and version are provided', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('test-prompt')
|
|
await prompts.get('test-prompt', { version: 1 })
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
prompts.clearCache('test-prompt', 1)
|
|
|
|
await expect(prompts.get('test-prompt')).resolves.toBe('Latest prompt')
|
|
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
|
|
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('should remove the outer cache entry when the last version is cleared', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('test-prompt', { version: 1 })
|
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
|
|
prompts.clearCache('test-prompt', 1)
|
|
|
|
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should not clear cache entries for other prompt names that share the same prefix', async () => {
|
|
mockFetch
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo::bar', prompt: 'Foo bar latest' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest refreshed' }),
|
|
})
|
|
|
|
const insights = createMockInsights()
|
|
const prompts = new Prompts({ insights })
|
|
|
|
await prompts.get('foo')
|
|
await prompts.get('foo::bar')
|
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
|
|
prompts.clearCache('foo')
|
|
|
|
await expect(prompts.get('foo')).resolves.toBe('Foo latest refreshed')
|
|
await expect(prompts.get('foo::bar')).resolves.toBe('Foo bar latest')
|
|
expect(mockFetch).toHaveBeenCalledTimes(3)
|
|
})
|
|
})
|
|
})
|