🏡 index : quine-global/quintodrome.git

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { createDecisionService, decodeJwtExp } from './decisionService'

// Helper: create a fake JWT with a given exp (seconds since epoch)
function fakeJwt(expSeconds) {
  const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
  const payload = btoa(JSON.stringify({ exp: expSeconds }))
  return `${header}.${payload}.fake-signature`
}

// Helper: create a fake JWT with no exp claim
function fakeJwtNoExp() {
  const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
  const payload = btoa(JSON.stringify({ sub: 'test' }))
  return `${header}.${payload}.fake-signature`
}

describe('decodeJwtExp', () => {
  it('extracts exp from a valid JWT', () => {
    const exp = 1700000000
    expect(decodeJwtExp(fakeJwt(exp))).toBe(exp)
  })

  it('returns null for JWT without exp claim', () => {
    expect(decodeJwtExp(fakeJwtNoExp())).toBeNull()
  })

  it('returns null for non-JWT string', () => {
    expect(decodeJwtExp('not-a-jwt')).toBeNull()
  })

  it('returns null for empty string', () => {
    expect(decodeJwtExp('')).toBeNull()
  })

  it('returns null for null/undefined', () => {
    expect(decodeJwtExp(null)).toBeNull()
    expect(decodeJwtExp(undefined)).toBeNull()
  })
})

describe('decisionService', () => {
  let service
  let mockFetchFn

  const fakeProfile = {
    name: 'NavidromeUI',
    platform: 'test',
    directPlayProfiles: [],
    transcodingProfiles: [],
    codecProfiles: [],
  }

  // Token that expires 1 hour from "now" (will be relative to fake timers)
  function makeFakeDecision(expiresInMs = 3600 * 1000) {
    const expSeconds = Math.floor((Date.now() + expiresInMs) / 1000)
    return {
      canDirectPlay: true,
      canTranscode: false,
      transcodeParams: fakeJwt(expSeconds),
      sourceStream: { codec: 'mp3', container: 'mp3' },
    }
  }

  beforeEach(() => {
    localStorage.setItem('username', 'testuser')
    localStorage.setItem('subsonic-token', 'testtoken')
    localStorage.setItem('subsonic-salt', 'testsalt')
    mockFetchFn = vi.fn().mockImplementation(() => {
      return Promise.resolve(makeFakeDecision())
    })
    service = createDecisionService(mockFetchFn)
  })

  afterEach(() => {
    vi.restoreAllMocks()
    localStorage.clear()
  })

  describe('getDecision', () => {
    it('fetches and caches a decision', async () => {
      const result = await service.getDecision('song-1', fakeProfile)
      expect(result.canDirectPlay).toBe(true)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)
      expect(mockFetchFn).toHaveBeenCalledWith('song-1', fakeProfile)

      // Second call uses cache
      const result2 = await service.getDecision('song-1', fakeProfile)
      expect(result2).toEqual(result)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)
    })

    it('re-fetches after token expires', async () => {
      vi.useFakeTimers()

      // Token expires in 1 hour
      mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)

      // Advance past expiration
      vi.advanceTimersByTime(3600 * 1000 + 1000)
      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(2)
      vi.useRealTimers()
    })

    it('does not re-fetch before token expires', async () => {
      vi.useFakeTimers()

      // Token expires in 1 hour
      mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
      await service.getDecision('song-1', fakeProfile)

      // 30 minutes later — still fresh
      vi.advanceTimersByTime(1800 * 1000)
      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)
      vi.useRealTimers()
    })

    it('re-fetches immediately when token has no exp claim', async () => {
      const noExpDecision = {
        canDirectPlay: true,
        canTranscode: false,
        transcodeParams: fakeJwtNoExp(),
        sourceStream: { codec: 'mp3', container: 'mp3' },
      }
      mockFetchFn.mockResolvedValue(noExpDecision)
      await service.getDecision('song-1', fakeProfile)

      // Should re-fetch because token has no exp
      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(2)
    })

    it('caches different songs independently', async () => {
      await service.getDecision('song-1', fakeProfile)
      await service.getDecision('song-2', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(2)
    })
  })

  describe('getCachedDecision', () => {
    it('returns null when song is not cached', () => {
      expect(service.getCachedDecision('song-1')).toBeNull()
    })

    it('returns cached decision after getDecision', async () => {
      await service.getDecision('song-1', fakeProfile)
      const cached = service.getCachedDecision('song-1')
      expect(cached).not.toBeNull()
      expect(cached.canDirectPlay).toBe(true)
    })

    it('returns null after cache is invalidated', async () => {
      await service.getDecision('song-1', fakeProfile)
      service.invalidateAll()
      expect(service.getCachedDecision('song-1')).toBeNull()
    })

    it('returns null after token expires', async () => {
      vi.useFakeTimers()
      mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000))
      await service.getDecision('song-1', fakeProfile)

      vi.advanceTimersByTime(3600 * 1000 + 1000)
      expect(service.getCachedDecision('song-1')).toBeNull()
      vi.useRealTimers()
    })
  })

  describe('prefetchDecisions', () => {
    it('fetches decisions for uncached songs', async () => {
      await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(2)
    })

    it('skips already cached songs', async () => {
      await service.getDecision('song-1', fakeProfile)
      mockFetchFn.mockClear()

      await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)
      expect(mockFetchFn).toHaveBeenCalledWith('song-2', fakeProfile)
    })

    it('silently ignores fetch errors', async () => {
      mockFetchFn.mockRejectedValue(new Error('network error'))
      await expect(
        service.prefetchDecisions(['song-1'], fakeProfile),
      ).resolves.not.toThrow()
    })
  })

  describe('invalidateAll', () => {
    it('clears cache so next getDecision re-fetches', async () => {
      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(1)

      service.invalidateAll()

      await service.getDecision('song-1', fakeProfile)
      expect(mockFetchFn).toHaveBeenCalledTimes(2)
    })
  })

  describe('resolveStreamUrl', () => {
    it('fetches decision and returns built URL', async () => {
      service.setProfile(fakeProfile)
      const url = await service.resolveStreamUrl('song-1')
      expect(url).toContain('getTranscodeStream')
      expect(url).toContain('mediaId=song-1')
      expect(mockFetchFn).toHaveBeenCalledTimes(1)
    })

    it('falls back to stream URL when decision has no transcodeParams', async () => {
      service.setProfile(fakeProfile)
      mockFetchFn.mockResolvedValue({
        canDirectPlay: true,
        canTranscode: false,
      })
      const url = await service.resolveStreamUrl('song-1')
      expect(url).toContain('stream')
      expect(url).not.toContain('getTranscodeStream')
    })

    it('falls back to stream URL when decision is null', async () => {
      service.setProfile(fakeProfile)
      mockFetchFn.mockResolvedValue(null)
      const url = await service.resolveStreamUrl('song-1')
      expect(url).toContain('stream')
      expect(url).not.toContain('getTranscodeStream')
    })
  })

  describe('buildStreamUrl', () => {
    it('builds URL with required parameters', () => {
      const url = service.buildStreamUrl('song-1', 'jwt-token-123')
      expect(url).toContain('getTranscodeStream')
      expect(url).toContain('mediaId=song-1')
      expect(url).toContain('mediaType=song')
      expect(url).toContain('transcodeParams=jwt-token-123')
    })

    it('includes offset when provided', () => {
      const url = service.buildStreamUrl('song-1', 'jwt-token-123', 30)
      expect(url).toContain('offset=30')
    })

    it('omits offset when not provided', () => {
      const url = service.buildStreamUrl('song-1', 'jwt-token-123')
      expect(url).not.toContain('offset')
    })
  })
})