import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PlanningTools } from '../../src/tools/planning.js';
import { IntervalsClient } from '../../src/clients/intervals.js';
import { TrainerRoadClient } from '../../src/clients/trainerroad.js';
import type { PlannedWorkout } from '../../src/types/index.js';
vi.mock('../../src/clients/intervals.js');
vi.mock('../../src/clients/trainerroad.js');
describe('PlanningTools sync operations', () => {
let tools: PlanningTools;
let mockIntervalsClient: IntervalsClient;
let mockTrainerRoadClient: TrainerRoadClient;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-12-15T12:00:00Z'));
mockIntervalsClient = new IntervalsClient({ apiKey: 'test', athleteId: 'test' });
mockTrainerRoadClient = new TrainerRoadClient({ calendarUrl: 'https://test.com' });
// Mock getAthleteTimezone to return UTC
vi.mocked(mockIntervalsClient.getAthleteTimezone).mockResolvedValue('UTC');
tools = new PlanningTools(mockIntervalsClient, mockTrainerRoadClient);
});
afterEach(() => {
vi.useRealTimers();
});
describe('createRunWorkout', () => {
it('should create workout with all required fields', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 123,
uid: 'uid-123',
name: 'Test Run',
start_date_local: '2024-12-16',
type: 'Run',
category: 'WORKOUT',
});
const result = await tools.createRunWorkout({
scheduled_for: '2024-12-16',
name: 'Test Run',
workout_doc: 'Warmup\n- 10m Z2 Pace',
});
expect(result.id).toBe(123);
expect(result.uid).toBe('uid-123');
expect(result.name).toBe('Test Run');
expect(result.intervals_icu_url).toContain('2024-12-16');
});
it('should create workout with optional fields', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 124,
uid: 'uid-124',
name: 'Interval Run',
start_date_local: '2024-12-17',
type: 'Run',
category: 'WORKOUT',
});
const result = await tools.createRunWorkout({
scheduled_for: '2024-12-17',
name: 'Interval Run',
description: 'RPE-based intervals',
workout_doc: 'Main Set 5x\n- 3m Z4 Pace',
trainerroad_uid: 'tr-789',
});
expect(result.id).toBe(124);
// Verify createEvent was called with correct parameters
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Interval Run',
type: 'Run',
category: 'WORKOUT',
tags: ['domestique'],
external_id: 'tr-789',
})
);
});
it('should put description before workout_doc', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 128,
uid: 'uid-128',
name: 'Ordered Run',
start_date_local: '2024-12-22',
type: 'Run',
category: 'WORKOUT',
});
await tools.createRunWorkout({
scheduled_for: '2024-12-22',
name: 'Ordered Run',
description: 'Notes about the run',
workout_doc: 'Warmup\n- 10m Z2 Pace',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Notes about the run\n\nWarmup\n- 10m Z2 Pace',
})
);
});
it('should add midnight time when only date is provided', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 129,
uid: 'uid-129',
name: 'Date Only Run',
start_date_local: '2024-12-23T00:00:00',
type: 'Run',
category: 'WORKOUT',
});
await tools.createRunWorkout({
scheduled_for: '2024-12-23',
name: 'Date Only Run',
workout_doc: '- 10m Z2 Pace',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
start_date_local: '2024-12-23T00:00:00',
})
);
});
it('should preserve time when full datetime is provided', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 130,
uid: 'uid-130',
name: 'Datetime Run',
start_date_local: '2024-12-24T14:30:00',
type: 'Run',
category: 'WORKOUT',
});
await tools.createRunWorkout({
scheduled_for: '2024-12-24T14:30:00',
name: 'Datetime Run',
workout_doc: '- 10m Z2 Pace',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
start_date_local: '2024-12-24T14:30:00',
})
);
});
it('should include domestique tag automatically', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 125,
uid: 'uid-125',
name: 'Tagged Run',
start_date_local: '2024-12-18',
type: 'Run',
category: 'WORKOUT',
});
await tools.createRunWorkout({
scheduled_for: '2024-12-18',
name: 'Tagged Run',
workout_doc: '- 30m Z2 Pace',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['domestique'],
})
);
});
it('should store trainerroad_uid in external_id', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 126,
uid: 'uid-126',
name: 'Synced Run',
start_date_local: '2024-12-19',
type: 'Run',
category: 'WORKOUT',
});
await tools.createRunWorkout({
scheduled_for: '2024-12-19',
name: 'Synced Run',
workout_doc: '- 20m Z3 Pace',
trainerroad_uid: 'tr-abc-123',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
external_id: 'tr-abc-123',
})
);
});
it('should handle API errors gracefully', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockRejectedValue(
new Error('API request failed: 500')
);
await expect(
tools.createRunWorkout({
scheduled_for: '2024-12-20',
name: 'Failed Run',
workout_doc: '- 10m Z1 Pace',
})
).rejects.toThrow('API request failed');
});
it('should return correct response structure', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 127,
uid: 'uid-127',
name: 'Structure Test',
start_date_local: '2024-12-21',
type: 'Run',
category: 'WORKOUT',
});
const result = await tools.createRunWorkout({
scheduled_for: '2024-12-21',
name: 'Structure Test',
workout_doc: '- 15m Z2 Pace',
});
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('uid');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('scheduled_for');
expect(result).toHaveProperty('intervals_icu_url');
});
});
describe('createCyclingWorkout', () => {
it('should create cycling workout with all required fields', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 200,
uid: 'uid-200',
name: 'Sweet Spot Intervals',
start_date_local: '2024-12-16',
type: 'Ride',
category: 'WORKOUT',
});
const result = await tools.createCyclingWorkout({
scheduled_for: '2024-12-16',
name: 'Sweet Spot Intervals',
workout_doc: 'Warmup\n- 10m ramp 50-75% 90rpm\n\nMain Set 3x\n- 15m 88-92% 85rpm\n- 5m 55% 90rpm\n\nCooldown\n- 10m ramp 55-40% 85rpm',
});
expect(result.id).toBe(200);
expect(result.uid).toBe('uid-200');
expect(result.name).toBe('Sweet Spot Intervals');
expect(result.intervals_icu_url).toContain('2024-12-16');
});
it('should create cycling workout with optional description', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 201,
uid: 'uid-201',
name: 'VO2 Max Workout',
start_date_local: '2024-12-17',
type: 'Ride',
category: 'WORKOUT',
});
const result = await tools.createCyclingWorkout({
scheduled_for: '2024-12-17',
name: 'VO2 Max Workout',
description: 'High intensity intervals for VO2 max development',
workout_doc: 'Main Set 5x\n- 3m 120% 100rpm\n- 2m 50% 85rpm',
});
expect(result.id).toBe(201);
// Verify createEvent was called with correct parameters
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
name: 'VO2 Max Workout',
type: 'Ride',
category: 'WORKOUT',
tags: ['domestique'],
})
);
});
it('should put description before workout_doc', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 202,
uid: 'uid-202',
name: 'Ordered Ride',
start_date_local: '2024-12-22',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-22',
name: 'Ordered Ride',
description: 'Notes about the workout',
workout_doc: 'Warmup\n- 10m 75% 90rpm',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Notes about the workout\n\nWarmup\n- 10m 75% 90rpm',
})
);
});
it('should add midnight time when only date is provided', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 203,
uid: 'uid-203',
name: 'Date Only Ride',
start_date_local: '2024-12-23T00:00:00',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-23',
name: 'Date Only Ride',
workout_doc: '- 60m 75% 90rpm',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
start_date_local: '2024-12-23T00:00:00',
})
);
});
it('should preserve time when full datetime is provided', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 204,
uid: 'uid-204',
name: 'Datetime Ride',
start_date_local: '2024-12-24T14:30:00',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-24T14:30:00',
name: 'Datetime Ride',
workout_doc: '- 60m 75% 90rpm',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
start_date_local: '2024-12-24T14:30:00',
})
);
});
it('should include domestique tag automatically', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 205,
uid: 'uid-205',
name: 'Tagged Ride',
start_date_local: '2024-12-18',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-18',
name: 'Tagged Ride',
workout_doc: '- 90m 65-75% 85-95rpm',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
tags: ['domestique'],
})
);
});
it('should use Ride as the workout type', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 206,
uid: 'uid-206',
name: 'Ride Type Test',
start_date_local: '2024-12-19',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-19',
name: 'Ride Type Test',
workout_doc: '- 30m 100% 90rpm',
});
expect(mockIntervalsClient.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'Ride',
})
);
});
it('should not include external_id (unlike run workouts)', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 207,
uid: 'uid-207',
name: 'No External ID',
start_date_local: '2024-12-20',
type: 'Ride',
category: 'WORKOUT',
});
await tools.createCyclingWorkout({
scheduled_for: '2024-12-20',
name: 'No External ID',
workout_doc: '- 45m 80% 90rpm',
});
const callArgs = vi.mocked(mockIntervalsClient.createEvent).mock.calls[0][0];
expect(callArgs.external_id).toBeUndefined();
});
it('should handle API errors gracefully', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockRejectedValue(
new Error('API request failed: 500')
);
await expect(
tools.createCyclingWorkout({
scheduled_for: '2024-12-20',
name: 'Failed Ride',
workout_doc: '- 10m 50% 90rpm',
})
).rejects.toThrow('API request failed');
});
it('should return correct response structure', async () => {
vi.mocked(mockIntervalsClient.createEvent).mockResolvedValue({
id: 208,
uid: 'uid-208',
name: 'Structure Test',
start_date_local: '2024-12-21',
type: 'Ride',
category: 'WORKOUT',
});
const result = await tools.createCyclingWorkout({
scheduled_for: '2024-12-21',
name: 'Structure Test',
workout_doc: '- 15m 88% 85rpm',
});
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('uid');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('scheduled_for');
expect(result).toHaveProperty('intervals_icu_url');
});
});
describe('deleteWorkout', () => {
it('should delete workout with domestique tag successfully', async () => {
vi.mocked(mockIntervalsClient.getEvent).mockResolvedValue({
id: 123,
uid: 'uid-123',
name: 'Test Run',
start_date_local: '2024-12-16',
type: 'Run',
category: 'WORKOUT',
tags: ['domestique'],
});
vi.mocked(mockIntervalsClient.deleteEvent).mockResolvedValue(undefined);
const result = await tools.deleteWorkout('123');
expect(result.deleted).toBe(true);
expect(result.message).toContain('Test Run');
expect(mockIntervalsClient.deleteEvent).toHaveBeenCalledWith('123');
});
it('should refuse to delete workout without domestique tag', async () => {
vi.mocked(mockIntervalsClient.getEvent).mockResolvedValue({
id: 124,
uid: 'uid-124',
name: 'User Created Run',
start_date_local: '2024-12-17',
type: 'Run',
category: 'WORKOUT',
tags: ['manual'],
});
await expect(tools.deleteWorkout('124')).rejects.toThrow('not created by Domestique');
expect(mockIntervalsClient.deleteEvent).not.toHaveBeenCalled();
});
it('should refuse to delete workout with no tags', async () => {
vi.mocked(mockIntervalsClient.getEvent).mockResolvedValue({
id: 125,
uid: 'uid-125',
name: 'No Tags Run',
start_date_local: '2024-12-18',
type: 'Run',
category: 'WORKOUT',
});
await expect(tools.deleteWorkout('125')).rejects.toThrow('not created by Domestique');
});
it('should handle non-existent event (404)', async () => {
vi.mocked(mockIntervalsClient.getEvent).mockRejectedValue(
new Error('API request failed: 404')
);
await expect(tools.deleteWorkout('999')).rejects.toThrow('API request failed');
});
it('should return correct response structure', async () => {
vi.mocked(mockIntervalsClient.getEvent).mockResolvedValue({
id: 126,
uid: 'uid-126',
name: 'Deleted Run',
start_date_local: '2024-12-19',
type: 'Run',
category: 'WORKOUT',
tags: ['domestique'],
});
vi.mocked(mockIntervalsClient.deleteEvent).mockResolvedValue(undefined);
const result = await tools.deleteWorkout('126');
expect(result).toHaveProperty('deleted');
expect(result).toHaveProperty('message');
expect(typeof result.deleted).toBe('boolean');
expect(typeof result.message).toBe('string');
});
});
describe('syncTRRuns', () => {
const trRuns: PlannedWorkout[] = [
{
id: 'tr-1',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'Easy Run',
sport: 'Running',
source: 'trainerroad',
description: '30min Easy RPE4',
expected_tss: 30,
expected_duration: '30m',
},
{
id: 'tr-2',
scheduled_for: '2024-12-18T09:00:00Z',
name: 'Interval Run',
sport: 'Running',
source: 'trainerroad',
description: '5x3min Hard',
expected_tss: 65,
expected_duration: '45m',
},
];
it('should identify TR runs without matching ICU workout', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.tr_runs_found).toBe(2);
expect(result.runs_to_sync).toHaveLength(2);
expect(result.runs_to_sync[0].tr_uid).toBe('tr-1');
expect(result.runs_to_sync[1].tr_uid).toBe('tr-2');
});
it('should not include already synced runs', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 100,
uid: 'uid-100',
name: 'Easy Run',
external_id: 'tr-1', // Already synced
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
description: '30min Easy RPE4',
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_sync).toHaveLength(1);
expect(result.runs_to_sync[0].tr_uid).toBe('tr-2');
});
it('should identify orphaned Domestique workouts', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([trRuns[0]]);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 100,
uid: 'uid-100',
name: 'Easy Run',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
description: '30min Easy RPE4',
},
{
id: 101,
uid: 'uid-101',
name: 'Deleted TR Run',
external_id: 'tr-deleted', // TR workout no longer exists
tags: ['domestique'],
start_date_local: '2024-12-17T09:00:00',
},
]);
vi.mocked(mockIntervalsClient.deleteEvent).mockResolvedValue(undefined);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.orphans_deleted).toBe(1);
expect(result.deleted).toHaveLength(1);
expect(result.deleted[0].reason).toContain('no longer exists');
});
it('should return correct counts', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 100,
uid: 'uid-100',
name: 'Easy Run', // Match name to avoid change detection
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
description: '30min Easy RPE4',
},
{
id: 101,
uid: 'uid-101',
name: 'Orphan',
external_id: 'tr-gone',
tags: ['domestique'],
start_date_local: '2024-12-19T09:00:00',
},
]);
vi.mocked(mockIntervalsClient.deleteEvent).mockResolvedValue(undefined);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.tr_runs_found).toBe(2);
expect(result.runs_to_sync).toHaveLength(1); // tr-2 not synced
expect(result.orphans_deleted).toBe(1);
});
it('should handle missing TrainerRoad config', async () => {
const toolsWithoutTr = new PlanningTools(mockIntervalsClient, null);
const result = await toolsWithoutTr.syncTRRuns({ oldest: '2024-12-15' });
expect(result.errors).toContain('TrainerRoad is not configured');
expect(result.tr_runs_found).toBe(0);
});
it('should handle API errors gracefully', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockRejectedValue(
new Error('Network error')
);
await expect(tools.syncTRRuns({ oldest: '2024-12-15' })).rejects.toThrow('Network error');
});
it('should use correct date range defaults', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([]);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([]);
await tools.syncTRRuns({});
// Should default to today + 30 days (Dec 15 + 30 = Jan 14)
// Note: date-fns addDays may vary slightly, so we just verify the start date
// and that an end date ~30 days later is passed
expect(mockTrainerRoadClient.getPlannedWorkouts).toHaveBeenCalledWith(
'2024-12-15',
expect.stringMatching(/^2025-01-1[34]$/), // Jan 13 or 14
'UTC'
);
});
it('should return runs_to_sync with expected structure', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([trRuns[0]]);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_sync[0]).toHaveProperty('tr_uid');
expect(result.runs_to_sync[0]).toHaveProperty('tr_name');
expect(result.runs_to_sync[0]).toHaveProperty('scheduled_for');
expect(result.runs_to_sync[0].tr_uid).toBe('tr-1');
expect(result.runs_to_sync[0].tr_name).toBe('Easy Run');
});
it('should include TR workout description in runs_to_sync', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([trRuns[0]]);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_sync[0].tr_description).toBe('30min Easy RPE4');
});
it('should handle errors during orphan deletion', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue([]);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 101,
uid: 'uid-101',
name: 'Failed Delete',
external_id: 'tr-fail',
tags: ['domestique'],
},
]);
vi.mocked(mockIntervalsClient.deleteEvent).mockRejectedValue(new Error('Delete failed'));
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain('Failed to delete orphan');
expect(result.orphans_deleted).toBe(0);
});
});
describe('enhanced deduplication', () => {
it('should deduplicate by external_id match', async () => {
const trWorkouts: PlannedWorkout[] = [
{
id: 'tr-123',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'TR Run',
source: 'trainerroad',
},
];
const icuWorkouts: PlannedWorkout[] = [
{
id: 'icu-456',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'Different Name', // Different name but same external_id
external_id: 'tr-123',
source: 'intervals.icu',
},
];
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(icuWorkouts);
const result = await tools.getUpcomingWorkouts({ oldest: '2024-12-15' });
// Should only have 1 workout (deduplicated by external_id)
expect(result.workouts).toHaveLength(1);
expect(result.workouts[0].source).toBe('trainerroad');
});
it('should match TR id to ICU external_id', async () => {
const trWorkouts: PlannedWorkout[] = [
{
id: 'tr-abc',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'Original Name',
source: 'trainerroad',
},
];
const icuWorkouts: PlannedWorkout[] = [
{
id: 'icu-xyz',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'ICU Name',
external_id: 'tr-abc', // Matches TR id
source: 'intervals.icu',
},
];
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(icuWorkouts);
const result = await tools.getUpcomingWorkouts({ oldest: '2024-12-15' });
expect(result.workouts).toHaveLength(1);
});
it('should still fallback to name matching when no external_id', async () => {
const trWorkouts: PlannedWorkout[] = [
{
id: 'tr-1',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'Sweet Spot Base',
source: 'trainerroad',
},
];
const icuWorkouts: PlannedWorkout[] = [
{
id: 'icu-1',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'sweet spot base', // Same name, different case
source: 'intervals.icu',
},
];
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trWorkouts);
vi.mocked(mockIntervalsClient.getPlannedEvents).mockResolvedValue(icuWorkouts);
const result = await tools.getUpcomingWorkouts({ oldest: '2024-12-15' });
expect(result.workouts).toHaveLength(1);
});
});
// Note: Proactive hints are now generated at the tool wrapper level (withToolResponse)
// and added to _hints in the structuredContent, not in the raw tool response.
// Tests for hints are in tests/utils/hints/*.test.ts
describe('change detection in syncTRRuns', () => {
const baseTrRuns: PlannedWorkout[] = [
{
id: 'tr-1',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'Easy Run',
sport: 'Running',
source: 'trainerroad',
description: '30min Easy RPE4',
expected_tss: 30,
expected_duration: '30m',
},
];
it('should detect name change', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 100,
uid: 'uid-100',
name: 'Old Name', // Different from TR name
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
description: '30min Easy RPE4',
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update).toHaveLength(1);
expect(result.runs_to_update[0].changes).toContain('name');
expect(result.runs_to_update[0].icu_event_id).toBe('100');
expect(result.runs_to_update[0].icu_name).toBe('Old Name');
});
it('should detect date change', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 101,
uid: 'uid-101',
name: 'Easy Run',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-17T09:00:00', // Different date
description: '30min Easy RPE4',
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update).toHaveLength(1);
expect(result.runs_to_update[0].changes).toContain('date');
});
it('should detect description change', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 102,
uid: 'uid-102',
name: 'Easy Run',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
description: 'Completely different description', // Doesn't contain TR desc
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update).toHaveLength(1);
expect(result.runs_to_update[0].changes).toContain('description');
});
it('should not flag as changed when TR description is contained in ICU', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 103,
uid: 'uid-103',
name: 'Easy Run',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
// ICU has TR description + workout_doc appended
description: '30min Easy RPE4\n\nWarmup\n- 10m Z2 Pace',
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
// No changes should be detected
expect(result.runs_to_update).toHaveLength(0);
expect(result.runs_to_sync).toHaveLength(0);
});
it('should detect multiple changes at once', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 104,
uid: 'uid-104',
name: 'Old Name',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-20T09:00:00', // Different date
description: 'Old description', // Different description
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update).toHaveLength(1);
expect(result.runs_to_update[0].changes).toContain('name');
expect(result.runs_to_update[0].changes).toContain('date');
expect(result.runs_to_update[0].changes).toContain('description');
});
it('should not flag unchanged workouts', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 105,
uid: 'uid-105',
name: 'Easy Run', // Same name
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00', // Same date
description: '30min Easy RPE4', // Same description
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update).toHaveLength(0);
expect(result.runs_to_sync).toHaveLength(0);
});
it('should handle mixed scenario (create + update + delete)', async () => {
const trRuns: PlannedWorkout[] = [
{
id: 'tr-new',
scheduled_for: '2024-12-16T09:00:00Z',
name: 'New Run',
sport: 'Running',
source: 'trainerroad',
},
{
id: 'tr-changed',
scheduled_for: '2024-12-18T09:00:00Z',
name: 'Updated Run Name',
sport: 'Running',
source: 'trainerroad',
},
];
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
// Matches tr-changed but with old name
{
id: 200,
uid: 'uid-200',
name: 'Old Run Name',
external_id: 'tr-changed',
tags: ['domestique'],
start_date_local: '2024-12-18T09:00:00',
},
// Orphan - TR workout no longer exists
{
id: 201,
uid: 'uid-201',
name: 'Orphan Run',
external_id: 'tr-orphan',
tags: ['domestique'],
start_date_local: '2024-12-19T09:00:00',
},
]);
vi.mocked(mockIntervalsClient.deleteEvent).mockResolvedValue(undefined);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
// tr-new should be in runs_to_sync (new)
expect(result.runs_to_sync).toHaveLength(1);
expect(result.runs_to_sync[0].tr_uid).toBe('tr-new');
// tr-changed should be in runs_to_update (changed name)
expect(result.runs_to_update).toHaveLength(1);
expect(result.runs_to_update[0].tr_uid).toBe('tr-changed');
expect(result.runs_to_update[0].changes).toContain('name');
// tr-orphan should be deleted
expect(result.orphans_deleted).toBe(1);
expect(result.deleted[0].name).toBe('Orphan Run');
});
it('should return runs_to_update with correct structure', async () => {
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(baseTrRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 300,
uid: 'uid-300',
name: 'Old Name',
external_id: 'tr-1',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00',
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
expect(result.runs_to_update[0]).toHaveProperty('tr_uid');
expect(result.runs_to_update[0]).toHaveProperty('tr_name');
expect(result.runs_to_update[0]).toHaveProperty('scheduled_for');
expect(result.runs_to_update[0]).toHaveProperty('icu_event_id');
expect(result.runs_to_update[0]).toHaveProperty('icu_name');
expect(result.runs_to_update[0]).toHaveProperty('changes');
expect(result.runs_to_update[0].tr_description).toBe('30min Easy RPE4');
expect(result.runs_to_update[0].expected_tss).toBe(30);
expect(result.runs_to_update[0].expected_duration).toBe('30m');
});
it('should compare only date portion ignoring time', async () => {
const trRuns: PlannedWorkout[] = [
{
id: 'tr-time-test',
scheduled_for: '2024-12-16T14:30:00Z', // Different time
name: 'Time Test Run',
sport: 'Running',
source: 'trainerroad',
},
];
vi.mocked(mockTrainerRoadClient.getPlannedWorkouts).mockResolvedValue(trRuns);
vi.mocked(mockIntervalsClient.getEventsByTag).mockResolvedValue([
{
id: 400,
uid: 'uid-400',
name: 'Time Test Run',
external_id: 'tr-time-test',
tags: ['domestique'],
start_date_local: '2024-12-16T09:00:00', // Same date, different time
},
]);
const result = await tools.syncTRRuns({ oldest: '2024-12-15' });
// Should not detect a change since only the time is different
expect(result.runs_to_update).toHaveLength(0);
});
});
});