test(ai-context): TODO-009 unit tests for LLM context message generators

Adds 18 new tests covering:

Backend (13 tests in ai-context.test.ts):
- keyword fallback — meeting, doctor, flight, case-insensitive
- generic fallback — no match, minute/hour formatting
- LLM path — flag-gated (off = no fetch), extraction-service success,
  Ollama cascade when extraction returns null, error → keyword fallback,
  short Ollama response rejected, non-200 fallthrough
- prompt construction — includes category, urgency, timeOfDay,
  recentTimerLabels

Web (5 new tests in context-messages.test.ts):
- LLM success path
- keyword fallback when backend returns 500
- keyword fallback when backend throws
- generic fallback when backend fails and no keyword matches
- payload shape — POST /api/context-message with all params

Test counts: backend 240 (was ~227), web 399 (was ~394), all green.
This commit is contained in:
saravanakumardb1 2026-04-17 12:40:10 -07:00
parent 6064d7d227
commit 8ca9e27532
3 changed files with 275 additions and 2 deletions

View File

@ -0,0 +1,199 @@
/**
* Tests for backend/src/lib/ai-context.ts TODO-009.
*
* Covers:
* - keyword fallback matches known keywords
* - generic fallback when no keyword matches
* - LLM path gated by feature flag (off always falls back)
* - extraction-service success path
* - Ollama success path
* - extraction-service Ollama cascade when first fails
* - LLM errors fall through to keyword fallback
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { mockConfig, mockIsFeatureEnabled } = vi.hoisted(() => ({
mockConfig: {
EXTRACTION_SERVICE_URL: undefined as string | undefined,
OLLAMA_URL: undefined as string | undefined,
OLLAMA_MODEL: 'gemma4',
} as Record<string, unknown>,
mockIsFeatureEnabled: vi.fn((_flag: string) => false),
}));
vi.mock('./config.js', () => ({
config: new Proxy({} as Record<string, unknown>, {
get: (_t, prop: string) => mockConfig[prop],
}),
}));
vi.mock('./feature-flags.js', () => ({
isFeatureEnabled: mockIsFeatureEnabled,
}));
// Must import AFTER mocks
const { generateContextMessage } = await import('./ai-context.js');
describe('generateContextMessage — keyword fallback', () => {
beforeEach(() => {
mockIsFeatureEnabled.mockReturnValue(false);
mockConfig.EXTRACTION_SERVICE_URL = undefined;
mockConfig.OLLAMA_URL = undefined;
});
it('matches "meeting" keyword', async () => {
const r = await generateContextMessage({ timerLabel: 'Team meeting', minutesBefore: 10 });
expect(r.source).toBe('keyword');
expect(r.message).toBe('Review your agenda');
});
it('matches "doctor appointment" keyword', async () => {
const r = await generateContextMessage({ timerLabel: 'Doctor appointment', minutesBefore: 30 });
expect(r.source).toBe('keyword');
expect(r.message).toContain('insurance');
});
it('keyword match is case-insensitive', async () => {
const r = await generateContextMessage({ timerLabel: 'FLIGHT to NYC', minutesBefore: 120 });
expect(r.source).toBe('keyword');
expect(r.message).toContain('Check in');
});
});
describe('generateContextMessage — generic fallback', () => {
beforeEach(() => {
mockIsFeatureEnabled.mockReturnValue(false);
mockConfig.EXTRACTION_SERVICE_URL = undefined;
mockConfig.OLLAMA_URL = undefined;
});
it('falls back to generic when no keyword matches', async () => {
const r = await generateContextMessage({ timerLabel: 'xyzzy random task', minutesBefore: 5 });
expect(r.source).toBe('generic');
expect(r.message).toContain('xyzzy random task');
expect(r.message).toContain('get ready');
});
it('formats minutes below 60 as "Nm"', async () => {
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 15 });
expect(r.message).toContain('15m');
});
it('formats minutes >= 60 as "Nh Mm"', async () => {
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 90 });
expect(r.message).toContain('1h 30m');
});
});
describe('generateContextMessage — LLM path', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockIsFeatureEnabled.mockReturnValue(true);
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
mockConfig.EXTRACTION_SERVICE_URL = undefined;
mockConfig.OLLAMA_URL = undefined;
});
it('does NOT call LLM when flag is off', async () => {
mockIsFeatureEnabled.mockReturnValue(false);
mockConfig.EXTRACTION_SERVICE_URL = 'http://fake';
mockConfig.OLLAMA_URL = 'http://fake';
const r = await generateContextMessage({ timerLabel: 'meeting', minutesBefore: 10 });
expect(fetchSpy).not.toHaveBeenCalled();
expect(r.source).toBe('keyword');
});
it('uses extraction-service when available', async () => {
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ result: 'AI-generated prep message' }), { status: 200 }),
);
const r = await generateContextMessage({ timerLabel: 'standup', minutesBefore: 10 });
expect(r.source).toBe('llm');
expect(r.message).toBe('AI-generated prep message');
});
it('falls back to Ollama when extraction-service returns null', async () => {
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
mockConfig.OLLAMA_URL = 'http://ollama';
fetchSpy
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })) // extraction returns null
.mockResolvedValueOnce(new Response(JSON.stringify({ response: 'Ollama enriched text' }), { status: 200 }));
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 10 });
expect(r.source).toBe('llm');
expect(r.message).toBe('Ollama enriched text');
});
it('falls through to keyword when LLM errors', async () => {
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
mockConfig.OLLAMA_URL = 'http://ollama';
fetchSpy.mockRejectedValue(new Error('network'));
const r = await generateContextMessage({ timerLabel: 'presentation', minutesBefore: 30 });
expect(r.source).toBe('keyword');
expect(r.message).toContain('slides');
});
it('rejects very short Ollama responses', async () => {
mockConfig.OLLAMA_URL = 'http://ollama';
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ response: 'hi' }), { status: 200 }),
);
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 5 });
expect(r.source).toBe('generic');
});
it('falls through when extraction-service returns non-200', async () => {
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 }));
const r = await generateContextMessage({ timerLabel: 'focus time', minutesBefore: 25 });
expect(r.source).toBe('keyword');
});
});
describe('prompt construction', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockIsFeatureEnabled.mockReturnValue(true);
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('includes all optional context fields in the prompt', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ result: 'ok' }), { status: 200 }),
);
await generateContextMessage({
timerLabel: 'team sync',
category: 'work',
urgency: 'high',
minutesBefore: 10,
timeOfDay: 'morning',
recentTimerLabels: ['standup', 'review'],
});
const call = fetchSpy.mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.input).toContain('team sync');
expect(body.input).toContain('work');
expect(body.input).toContain('high');
expect(body.input).toContain('morning');
expect(body.input).toContain('standup');
});
});

View File

@ -24,7 +24,7 @@
| ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) |
| **TODO-007** | medium | A.3 | `learning_ai_common_plat/services/mcp-server/` | Add integration tests for 5 new ChronoMind MCP tools (reschedule, availability, routine start, agent-actions list, agent-actions approve) |
| **TODO-008** | medium | B | `backend/src/lib/telemetry-events.ts`, `web/src/lib/telemetry-events.ts` | Wire `trackEvent()` / `bufferEvent()` calls into routes and components using the defined telemetry constants. See in-code comments for specific call sites |
| **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.ts`, `web/src/lib/context-messages.ts` | Add unit tests for `generateContextMessage()` (backend) and `fetchEnrichedMessage()` (web). Test keyword fallback, LLM enrichment, generic fallback, and error paths |
| **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.test.ts`, `web/src/lib/context-messages.test.ts` | Unit tests for `generateContextMessage()` (13 tests: keyword, generic, LLM flag-off, extraction-service, Ollama cascade, error paths, prompt construction) + `fetchEnrichedMessage()` (5 tests: LLM success, keyword fallback on 500/throw, generic fallback, payload shape) |
| ✅ **TODO-010** | low | cleanup | `backend/src/modules/timers/routes.ts`, `routines/routes.ts`, `households/routes.ts`, `webhooks/routes.ts`, `shared-timers/routes.ts` | ~~Replace hardcoded PRODUCT_ID~~ — imports from `product-config.js` in all 5 files (d74c80a) |
| ✅ **TODO-011** | low | cleanup | `web/src/app/error.tsx` | ~~Wire error boundary to telemetry~~`trackEvent('error', 'app', 'error_boundary', ...)` (9e63418) |

View File

@ -1,10 +1,11 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
getContextMessage,
getAllContextMessages,
getWarningMessage,
hasContextMatch,
getContextRules,
fetchEnrichedMessage,
} from './context-messages';
describe('context-messages', () => {
@ -125,3 +126,76 @@ describe('context-messages', () => {
});
});
});
describe('fetchEnrichedMessage', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('returns LLM-sourced result when backend responds 200', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'AI-generated prep', source: 'llm' }), { status: 200 }),
);
const r = await fetchEnrichedMessage({ timerLabel: 'meeting', minutesBefore: 10 });
expect(r.source).toBe('llm');
expect(r.message).toBe('AI-generated prep');
});
it('falls back to keyword rule when backend returns non-200', async () => {
fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 }));
const r = await fetchEnrichedMessage({ timerLabel: 'Team meeting', minutesBefore: 10 });
expect(r.source).toBe('keyword');
expect(r.message).toBe('Review your agenda');
});
it('falls back to keyword rule when backend throws', async () => {
fetchSpy.mockRejectedValueOnce(new Error('network fail'));
const r = await fetchEnrichedMessage({ timerLabel: 'Flight to LAX', minutesBefore: 120 });
expect(r.source).toBe('keyword');
expect(r.message).toBe('Check in online');
});
it('falls back to generic when backend fails and no keyword matches', async () => {
fetchSpy.mockRejectedValueOnce(new Error('timeout'));
const r = await fetchEnrichedMessage({ timerLabel: 'xyzzy plinko', minutesBefore: 5 });
expect(r.source).toBe('generic');
expect(r.message).toContain('xyzzy plinko');
expect(r.message).toContain('get ready');
});
it('POSTs params to /api/context-message', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ message: 'ok', source: 'llm' }), { status: 200 }),
);
await fetchEnrichedMessage({
timerLabel: 'team sync',
category: 'work',
urgency: 'high',
minutesBefore: 10,
timeOfDay: 'morning',
recentTimerLabels: ['standup'],
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/context-message');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string);
expect(body.timerLabel).toBe('team sync');
expect(body.category).toBe('work');
expect(body.urgency).toBe('high');
expect(body.timeOfDay).toBe('morning');
expect(body.recentTimerLabels).toEqual(['standup']);
});
});