Ctrl K
ComunidadGuías…

web/src/utils/axios.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// Mock offlineStorage before importing axios (vi.mock is hoisted)
vi.mock('@/utils/offlineStorage', () => ({
  addToPendingSync: vi.fn().mockResolvedValue(undefined),
  syncWithServer: vi.fn().mockResolvedValue(undefined),
  getPendingSync: vi.fn().mockResolvedValue([]),
}))

// Mock subscription store — the 402 interceptor calls useSubscriptionStore()
// dynamically; without this Pinia throws because no app.use(pinia) exists in tests
vi.mock('@/stores/subscription', () => ({
  useSubscriptionStore: vi.fn(() => ({
    setExpired: vi.fn(),
  })),
}))

import instance, { getPendingSyncCount, triggerSync } from './axios'
import { addToPendingSync, syncWithServer, getPendingSync } from '@/utils/offlineStorage'

// Helper: build a mock axios adapter that injects specific responses in sequence.
// Each call to the adapter consumes the next entry; the last entry repeats.
const makeSequentialAdapter = (...responses) => {
  let index = 0
  return async (config) => {
    const r = responses[Math.min(index++, responses.length - 1)]
    if (r.status >= 400) {
      const err = Object.assign(new Error(`Request failed with status code ${r.status}`), {
        response: { status: r.status, data: r.data ?? {}, config },
        config,
      })
      throw err
    }
    return { data: r.data ?? {}, status: r.status ?? 200, headers: {}, config }
  }
}

describe('axios utility', () => {
  let originalAdapter

  beforeEach(() => {
    originalAdapter = instance.defaults.adapter
    localStorage.clear()
    delete instance.defaults.headers.common['Authorization']
    vi.clearAllMocks()
  })

  afterEach(() => {
    instance.defaults.adapter = originalAdapter
  })

  // =========================================================================
  // REQUEST INTERCEPTOR — JWT injection
  // =========================================================================

  describe('Request interceptor', () => {
    it('adds Authorization header when token is in localStorage', async () => {
      localStorage.setItem('token', 'my-jwt-token')

      let capturedConfig
      instance.defaults.adapter = async (config) => {
        capturedConfig = config
        return { data: {}, status: 200, headers: {}, config }
      }

      await instance.get('/api/test')

      expect(capturedConfig.headers.Authorization).toBe('Bearer my-jwt-token')
    })

    it('does not add Authorization header when no token in localStorage', async () => {
      let capturedConfig
      instance.defaults.adapter = async (config) => {
        capturedConfig = config
        return { data: {}, status: 200, headers: {}, config }
      }

      await instance.get('/api/test')

      expect(capturedConfig.headers.Authorization).toBeUndefined()
    })

    it('uses latest token from localStorage on each request', async () => {
      localStorage.setItem('token', 'token-v1')

      const configs = []
      instance.defaults.adapter = async (config) => {
        configs.push(config)
        return { data: {}, status: 200, headers: {}, config }
      }

      await instance.get('/api/first')
      localStorage.setItem('token', 'token-v2')
      await instance.get('/api/second')

      expect(configs[0].headers.Authorization).toBe('Bearer token-v1')
      expect(configs[1].headers.Authorization).toBe('Bearer token-v2')
    })
  })

  // =========================================================================
  // RESPONSE INTERCEPTOR — 402 subscription expired
  // =========================================================================

  describe('Response interceptor — 402', () => {
    it('dispatches subscription-expired custom event on 402', async () => {
      instance.defaults.adapter = makeSequentialAdapter({ status: 402, data: { message: 'Subscription required' } })

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.get('/api/protected').catch(() => {})

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'subscription-expired'
      )
      expect(dispatched).toBeDefined()
      expect(dispatched[0].detail.message).toBe('Subscription required')

      dispatchSpy.mockRestore()
    })

    it('still rejects the promise on 402 after dispatching event', async () => {
      instance.defaults.adapter = makeSequentialAdapter({ status: 402 })

      await expect(instance.get('/api/protected')).rejects.toMatchObject({
        response: { status: 402 },
      })
    })
  })

  // =========================================================================
  // RESPONSE INTERCEPTOR — 403 protected_user
  // =========================================================================

  describe('Response interceptor — 403 protected_user', () => {
    it('dispatches protected-user-write custom event on 403 protected_user', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 403,
        data: {
          error: 'protected_user',
          message: 'This account is protected and cannot be modified.',
          documentation_url: '/docs/protected-users',
        },
      })

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.put('/api/admin/users/1').catch(() => {})

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'protected-user-write'
      )
      expect(dispatched).toBeDefined()
      expect(dispatched[0].detail.message).toBe('This account is protected and cannot be modified.')
      expect(dispatched[0].detail.documentationUrl).toBe('/docs/protected-users')
      expect(dispatched[0].detail.type).toBe('warning')

      dispatchSpy.mockRestore()
    })

    it('still rejects the promise on 403 protected_user after dispatching event', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 403,
        data: { error: 'protected_user', message: 'Protected.' },
      })

      await expect(instance.put('/api/admin/users/1')).rejects.toMatchObject({
        response: { status: 403 },
      })
    })

    it('does NOT dispatch protected-user-write for a generic 403 (no error code)', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 403,
        data: { message: 'Forbidden' },
      })

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.put('/api/admin/users/1').catch(() => {})

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'protected-user-write'
      )
      expect(dispatched).toBeUndefined()

      dispatchSpy.mockRestore()
    })
  })

  // =========================================================================
  // RESPONSE INTERCEPTOR — 503 protected_invariant_degraded
  // =========================================================================

  describe('Response interceptor — 503 protected_invariant_degraded', () => {
    it('dispatches admin-degraded custom event on 503 protected_invariant_degraded', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 503,
        data: {
          error: 'protected_invariant_degraded',
          message: 'Admin operations temporarily unavailable.',
        },
      })

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.put('/api/admin/users/1').catch(() => {})

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'admin-degraded'
      )
      expect(dispatched).toBeDefined()
      expect(dispatched[0].detail.message).toBe(
        'Admin user actions are temporarily unavailable. Operator: see logs.'
      )
      expect(dispatched[0].detail.type).toBe('error')

      dispatchSpy.mockRestore()
    })

    it('still rejects the promise on 503 protected_invariant_degraded after dispatching event', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 503,
        data: { error: 'protected_invariant_degraded', message: 'Degraded.' },
      })

      await expect(instance.put('/api/admin/users/1')).rejects.toMatchObject({
        response: { status: 503 },
      })
    })

    it('does NOT dispatch admin-degraded for a generic 503 (no error code)', async () => {
      instance.defaults.adapter = makeSequentialAdapter({
        status: 503,
        data: { message: 'Service Unavailable' },
      })

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.put('/api/admin/users/1').catch(() => {})

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'admin-degraded'
      )
      expect(dispatched).toBeUndefined()

      dispatchSpy.mockRestore()
    })
  })

  // =========================================================================
  // RESPONSE INTERCEPTOR — network errors / offline
  // =========================================================================

  describe('Response interceptor — offline handling', () => {
    it('saves POST to workout endpoint offline when network error occurs', async () => {
      instance.defaults.adapter = async (config) => {
        const err = Object.assign(new Error('Network Error'), { code: 'ERR_NETWORK', config })
        throw err
      }

      const response = await instance.post('/api/workouts', { name: 'Test Workout' })

      expect(addToPendingSync).toHaveBeenCalledOnce()
      expect(response.status).toBe(202)
      expect(response.data.offline).toBe(true)
    })

    it('dispatches offline-save event when workout saved offline', async () => {
      instance.defaults.adapter = async (config) => {
        const err = Object.assign(new Error('Network Error'), { code: 'ERR_NETWORK', config })
        throw err
      }

      const dispatchSpy = vi.spyOn(window, 'dispatchEvent')

      await instance.post('/api/workouts', { name: 'Test Workout' })

      const dispatched = dispatchSpy.mock.calls.find(
        ([event]) => event instanceof CustomEvent && event.type === 'offline-save'
      )
      expect(dispatched).toBeDefined()

      dispatchSpy.mockRestore()
    })

    it('propagates network errors for non-workout endpoints', async () => {
      instance.defaults.adapter = async (config) => {
        const err = Object.assign(new Error('Network Error'), { code: 'ERR_NETWORK', config })
        throw err
      }

      await expect(instance.get('/api/movements')).rejects.toMatchObject({ message: 'Network Error' })
    })
  })

  // =========================================================================
  // UTILITY EXPORTS
  // =========================================================================

  describe('getPendingSyncCount', () => {
    it('returns length of pending sync queue', async () => {
      getPendingSync.mockResolvedValue([{ id: 1 }, { id: 2 }])

      const count = await getPendingSyncCount()

      expect(count).toBe(2)
    })

    it('returns 0 when queue is empty', async () => {
      getPendingSync.mockResolvedValue([])

      const count = await getPendingSyncCount()

      expect(count).toBe(0)
    })

    it('returns 0 on error', async () => {
      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
      getPendingSync.mockRejectedValue(new Error('Storage error'))

      const count = await getPendingSyncCount()

      expect(count).toBe(0)
      consoleSpy.mockRestore()
    })
  })

  describe('triggerSync', () => {
    it('calls syncWithServer and returns true on success', async () => {
      const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
      syncWithServer.mockResolvedValue(undefined)

      const result = await triggerSync()

      expect(syncWithServer).toHaveBeenCalledOnce()
      expect(result).toBe(true)
      consoleSpy.mockRestore()
    })

    it('returns false on sync failure', async () => {
      const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
      syncWithServer.mockRejectedValue(new Error('Sync failed'))

      const result = await triggerSync()

      expect(result).toBe(false)
      consoleSpy.mockRestore()
    })
  })
})