Skip to main content
Glama
whoop.test.ts12.1 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { WhoopClient } from '../../src/clients/whoop.js'; // Mock the redis module vi.mock('../../src/utils/redis.js', () => ({ getWhoopAccessToken: vi.fn().mockResolvedValue(null), getWhoopRefreshToken: vi.fn().mockResolvedValue(null), storeWhoopTokens: vi.fn().mockResolvedValue(true), })); import { getWhoopAccessToken, getWhoopRefreshToken, storeWhoopTokens } from '../../src/utils/redis.js'; describe('WhoopClient', () => { let client: WhoopClient; const mockFetch = vi.fn(); const defaultConfig = { accessToken: 'test-access-token', refreshToken: 'test-refresh-token', clientId: 'test-client-id', clientSecret: 'test-client-secret', }; beforeEach(() => { client = new WhoopClient(defaultConfig); // Set token expiry to far in the future to avoid refresh (client as any).tokenExpiresAt = Date.now() + 3600000; vi.stubGlobal('fetch', mockFetch); // Reset redis mocks vi.mocked(getWhoopAccessToken).mockResolvedValue(null); vi.mocked(getWhoopRefreshToken).mockResolvedValue(null); vi.mocked(storeWhoopTokens).mockResolvedValue(true); }); afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); describe('getRecoveries', () => { const mockRecoveries = { records: [ { cycle_id: 1, sleep_id: 101, user_id: 1, created_at: '2024-12-15T08:00:00Z', updated_at: '2024-12-15T08:00:00Z', score_state: 'SCORED', score: { user_calibrating: false, recovery_score: 85, resting_heart_rate: 55, hrv_rmssd_milli: 65, }, }, ], }; const mockSleeps = { records: [ { id: 101, user_id: 1, created_at: '2024-12-15T06:00:00Z', updated_at: '2024-12-15T06:00:00Z', start: '2024-12-14T22:00:00Z', end: '2024-12-15T06:00:00Z', timezone_offset: '-05:00', nap: false, score_state: 'SCORED', score: { stage_summary: { total_in_bed_time_milli: 28800000, total_awake_time_milli: 1800000, total_no_data_time_milli: 0, total_light_sleep_time_milli: 10800000, total_slow_wave_sleep_time_milli: 7200000, total_rem_sleep_time_milli: 9000000, sleep_cycle_count: 4, disturbance_count: 2, }, sleep_needed: { baseline_milli: 28800000, need_from_sleep_debt_milli: 0, need_from_recent_strain_milli: 1800000, need_from_recent_nap_milli: 0, }, sleep_performance_percentage: 95, }, }, ], }; it('should fetch and transform recovery data', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockRecoveries), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockSleeps), }); const result = await client.getRecoveries('2024-12-15', '2024-12-15'); expect(result).toHaveLength(1); expect(result[0].recovery_score).toBe(85); expect(result[0].hrv_rmssd).toBe(65); expect(result[0].resting_heart_rate).toBe(55); expect(result[0].sleep_performance_percentage).toBe(95); expect(result[0].sleep_duration_hours).toBeCloseTo(7.5, 1); // (10800000 + 7200000 + 9000000) / 3600000 }); it('should include authorization header', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }); await client.getRecoveries('2024-12-15', '2024-12-15'); const callOptions = mockFetch.mock.calls[0][1] as RequestInit; const auth = (callOptions.headers as Record<string, string>).Authorization; expect(auth).toBe('Bearer test-access-token'); }); it('should filter out unscored recoveries', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [ { ...mockRecoveries.records[0], score_state: 'PENDING' }, mockRecoveries.records[0], ], }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockSleeps), }); const result = await client.getRecoveries('2024-12-15', '2024-12-15'); expect(result).toHaveLength(1); }); }); describe('getTodayRecovery', () => { it('should return today\'s recovery', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [{ cycle_id: 1, sleep_id: 101, user_id: 1, created_at: new Date().toISOString().split('T')[0] + 'T08:00:00Z', score_state: 'SCORED', score: { recovery_score: 75, resting_heart_rate: 52, hrv_rmssd_milli: 70, }, }], }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }); const result = await client.getTodayRecovery(); expect(result?.recovery_score).toBe(75); }); it('should return null when no recovery data', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }); const result = await client.getTodayRecovery(); expect(result).toBeNull(); }); }); describe('getStrainData', () => { const mockCycles = { records: [ { id: 1, user_id: 1, created_at: '2024-12-15T00:00:00Z', updated_at: '2024-12-15T23:59:59Z', start: '2024-12-15T00:00:00Z', end: '2024-12-15T23:59:59Z', timezone_offset: '-05:00', score_state: 'SCORED', score: { strain: 15.5, kilojoule: 12000, average_heart_rate: 75, max_heart_rate: 185, }, }, ], }; const mockWorkouts = { records: [ { id: 1001, user_id: 1, created_at: '2024-12-15T10:00:00Z', updated_at: '2024-12-15T11:00:00Z', start: '2024-12-15T10:00:00Z', end: '2024-12-15T11:00:00Z', timezone_offset: '-05:00', sport_id: 1, // Cycling score_state: 'SCORED', score: { strain: 12.5, average_heart_rate: 155, max_heart_rate: 180, kilojoule: 2500, percent_recorded: 100, }, }, ], }; it('should fetch and transform strain data', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockCycles), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockWorkouts), }); const result = await client.getStrainData('2024-12-15', '2024-12-15'); expect(result).toHaveLength(1); expect(result[0].strain_score).toBe(15.5); expect(result[0].calories).toBeCloseTo(2868, 0); // 12000 / 4.184 expect(result[0].activities).toHaveLength(1); expect(result[0].activities[0].activity_type).toBe('Cycling'); }); it('should filter out unscored cycles', async () => { mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [ { ...mockCycles.records[0], score_state: 'PENDING' }, mockCycles.records[0], ], }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockWorkouts), }); const result = await client.getStrainData('2024-12-15', '2024-12-15'); expect(result).toHaveLength(1); }); }); describe('getWorkouts', () => { const mockWorkouts = { records: [ { id: 1001, user_id: 1, created_at: '2024-12-15T10:00:00Z', updated_at: '2024-12-15T11:00:00Z', start: '2024-12-15T10:00:00Z', end: '2024-12-15T11:00:00Z', timezone_offset: '-05:00', sport_id: 0, // Running score_state: 'SCORED', score: { strain: 10.2, average_heart_rate: 145, max_heart_rate: 165, kilojoule: 1800, percent_recorded: 100, }, }, ], }; it('should fetch and transform workouts', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockWorkouts), }); const result = await client.getWorkouts('2024-12-15', '2024-12-15'); expect(result).toHaveLength(1); expect(result[0].id).toBe('1001'); expect(result[0].activity_type).toBe('Running'); expect(result[0].strain_score).toBe(10.2); expect(result[0].average_heart_rate).toBe(145); }); }); describe('token refresh', () => { it('should use cached token from Redis', async () => { vi.mocked(getWhoopAccessToken).mockResolvedValueOnce({ token: 'cached-access-token', expiresAt: Date.now() + 3600000, }); // Reset token to force checking Redis (client as any).tokenExpiresAt = 0; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }); await client.getWorkouts('2024-12-15', '2024-12-15'); const callOptions = mockFetch.mock.calls[0][1] as RequestInit; const auth = (callOptions.headers as Record<string, string>).Authorization; expect(auth).toBe('Bearer cached-access-token'); }); it('should refresh token and store in Redis', async () => { // Force token expiry (client as any).tokenExpiresAt = 0; vi.mocked(getWhoopAccessToken).mockResolvedValue(null); vi.mocked(getWhoopRefreshToken).mockResolvedValue('stored-refresh-token'); mockFetch .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', expires_in: 3600, }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ records: [] }), }); await client.getWorkouts('2024-12-15', '2024-12-15'); expect(storeWhoopTokens).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token', })); }); it('should throw error when token refresh fails', async () => { // Force token expiry by setting tokenExpiresAt to past (client as any).tokenExpiresAt = 0; vi.mocked(getWhoopAccessToken).mockResolvedValue(null); mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', }); await expect(client.getRecoveries('2024-12-15', '2024-12-15')) .rejects.toThrow('Whoop token refresh failed'); }); }); describe('API error handling', () => { it('should throw error on API failure', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', }); await expect(client.getWorkouts('2024-12-15', '2024-12-15')) .rejects.toThrow('500 Internal Server Error'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gesteves/domestique'

If you have feedback or need assistance with the MCP directory API, please join our Discord server