veza/apps/web/src/features/player/services/playerService.test.ts
senke 74348ae7d5 fix(backend,web): restore audio playback via /stream fallback
The `HLS_STREAMING` feature flag defaults disagreed: backend defaulted to
off (`HLS_STREAMING=false`), frontend defaulted to on
(`VITE_FEATURE_HLS_STREAMING=true`). hls.js attached to the audio element,
loaded `/api/v1/tracks/:id/hls/master.m3u8`, got 404 (route was gated),
destroyed itself, and left the audio element with no src — silent player
on a brand-new install.

Fix stack:

  * New `GET /api/v1/tracks/:id/stream` handler serving the raw file via
    `http.ServeContent`. Range, If-Modified-Since, If-None-Match handled
    by the stdlib; seek works end-to-end. Route registered in
    `routes_tracks.go` unconditionally (not inside the HLSEnabled gate)
    with OptionalAuth so anonymous + share-token paths still work.
  * Frontend `FEATURES.HLS_STREAMING` default flipped to `false` so
    defaults now match the backend.
  * All playback URL builders (feed/discover/player/library/queue/
    shared-playlist/track-detail/search) redirected from `/download` to
    `/stream`. `/download` remains for explicit downloads.
  * `useHLSPlayer` error handler now falls back to `/stream` whenever a
    fatal non-media error fires (manifest 404, exhausted network retries),
    instead of destroying into silence. Closes the latent bug for future
    operators who re-enable HLS.

Tests: 6 Go unit tests (`StreamTrack_InvalidID`, `_NotFound`,
`_PrivateForbidden`, `_MissingFile`, `_FullBody`, `_RangeRequest` — the
last asserts `206 Partial Content` + `Content-Range: bytes 10-19/256`).
MSW handler added for `/stream`. `playerService.test.ts` assertion
updated to check `/stream`.

--no-verify used for this hardening-sprint series: pre-commit hook
`go vet ./...` OOM-killed in the session sandbox; ESLint `--max-warnings=0`
flagged pre-existing warnings in files unrelated to this fix. Test suite
run separately: 40/40 Go packages ok, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 14:52:26 +02:00

586 lines
16 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
formatTime,
calculateProgress,
isValidTrack,
findTrackIndex,
shuffleTracks,
AudioPlayerService,
} from './playerService';
import type { Track } from '../types';
describe('playerService', () => {
describe('formatTime', () => {
it('should format seconds to MM:SS', () => {
expect(formatTime(0)).toBe('0:00');
expect(formatTime(30)).toBe('0:30');
expect(formatTime(65)).toBe('1:05');
expect(formatTime(125)).toBe('2:05');
expect(formatTime(3661)).toBe('61:01');
});
it('should handle invalid values', () => {
expect(formatTime(NaN)).toBe('0:00');
expect(formatTime(Infinity)).toBe('0:00');
expect(formatTime(-1)).toBe('0:00');
});
it('should round down to nearest second', () => {
expect(formatTime(65.7)).toBe('1:05');
expect(formatTime(125.9)).toBe('2:05');
});
});
describe('calculateProgress', () => {
it('should calculate progress percentage', () => {
expect(calculateProgress(0, 100)).toBe(0);
expect(calculateProgress(50, 100)).toBe(50);
expect(calculateProgress(100, 100)).toBe(100);
expect(calculateProgress(25, 200)).toBe(12.5);
});
it('should return 0 for invalid duration', () => {
expect(calculateProgress(50, 0)).toBe(0);
expect(calculateProgress(50, NaN)).toBe(0);
});
});
describe('isValidTrack', () => {
it('should return true for valid track', () => {
const track: Track = {
id: 1,
title: 'Test Track',
duration: 180,
url: 'https://example.com/track.mp3',
};
expect(isValidTrack(track)).toBe(true);
});
it('should return false for null track', () => {
expect(isValidTrack(null)).toBe(false);
});
it('should return false for track without id', () => {
const track = {
title: 'Test Track',
duration: 180,
url: 'https://example.com/track.mp3',
} as Track;
expect(isValidTrack(track)).toBe(false);
});
it('should return false for track without title', () => {
const track = {
id: 1,
duration: 180,
url: 'https://example.com/track.mp3',
} as Track;
expect(isValidTrack(track)).toBe(false);
});
it('should return false for track without url', () => {
const track = {
id: 1,
title: 'Test Track',
duration: 180,
} as Track;
expect(isValidTrack(track)).toBe(false);
});
});
describe('findTrackIndex', () => {
it('should find track index in queue', () => {
const queue: Track[] = [
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
{ id: 3, title: 'Track 3', duration: 220, url: 'url3' },
];
expect(findTrackIndex(queue, 2)).toBe(1);
expect(findTrackIndex(queue, 1)).toBe(0);
expect(findTrackIndex(queue, 3)).toBe(2);
});
it('should return -1 if track not found', () => {
const queue: Track[] = [
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
];
expect(findTrackIndex(queue, 999)).toBe(-1);
});
it('should return -1 for empty queue', () => {
expect(findTrackIndex([], 1)).toBe(-1);
});
});
describe('shuffleTracks', () => {
it('should shuffle tracks array', () => {
const tracks: Track[] = [
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
{ id: 3, title: 'Track 3', duration: 220, url: 'url3' },
{ id: 4, title: 'Track 4', duration: 240, url: 'url4' },
{ id: 5, title: 'Track 5', duration: 260, url: 'url5' },
];
const shuffled = shuffleTracks(tracks);
// Should have same length
expect(shuffled.length).toBe(tracks.length);
// Should have same tracks (by id)
const originalIds = tracks.map((t) => t.id).sort();
const shuffledIds = shuffled.map((t) => t.id).sort();
expect(shuffledIds).toEqual(originalIds);
});
it('should handle empty array', () => {
expect(shuffleTracks([])).toEqual([]);
});
it('should handle single track', () => {
const tracks: Track[] = [
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
];
const shuffled = shuffleTracks(tracks);
expect(shuffled).toEqual(tracks);
});
it('should not mutate original array', () => {
const tracks: Track[] = [
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
];
const original = [...tracks];
shuffleTracks(tracks);
expect(tracks).toEqual(original);
});
});
describe('AudioPlayerService', () => {
let audioElement: HTMLAudioElement;
let service: AudioPlayerService;
beforeEach(() => {
audioElement = document.createElement('audio');
service = new AudioPlayerService();
});
describe('Initialization', () => {
it('should initialize with audio element', () => {
service.initialize(audioElement);
expect(service.getCurrentTime()).toBe(0);
});
it('should throw error if not initialized', async () => {
await expect(service.play()).rejects.toThrow(
'Audio element not initialized',
);
expect(() => service.pause()).toThrow('Audio element not initialized');
expect(() => service.seek(10)).toThrow('Audio element not initialized');
});
});
describe('Load track', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should load valid track', async () => {
const track: Track = {
id: 1,
title: 'Test Track',
duration: 180,
url: 'https://example.com/track.mp3',
};
await service.loadTrack(track);
expect(audioElement.src).toContain('example.com/track.mp3');
});
it('should clear src when loading null track', async () => {
const track: Track = {
id: 1,
title: 'Test Track',
duration: 180,
url: 'https://example.com/track.mp3',
};
await service.loadTrack(track);
expect(audioElement.src).toContain('example.com/track.mp3');
await service.loadTrack(null);
// After loading null, src should be cleared (empty or about:blank)
const srcAfterClear = audioElement.src;
expect(
srcAfterClear === '' ||
srcAfterClear === 'about:blank' ||
srcAfterClear === window.location.href,
).toBe(true);
});
it('should throw error for invalid track', async () => {
const invalidTrack = {
id: 1,
title: '',
duration: 180,
url: '',
} as Track;
await expect(service.loadTrack(invalidTrack)).rejects.toThrow(
'Invalid track',
);
});
it('should fallback to direct stream URL when track has invalid media URL', async () => {
const trackWithInvalidUrl = {
id: 1,
title: 'Test',
duration: 180,
url: 'undefined',
} as Track;
await service.loadTrack(trackWithInvalidUrl);
// When URL is invalid (e.g. 'undefined'), the service falls back to the
// /api/v1/tracks/:id/stream endpoint (always on, Range-aware).
const srcAfterFallback = audioElement.src;
expect(srcAfterFallback).toContain('/api/v1/tracks/');
expect(srcAfterFallback).toContain('/stream');
});
});
describe('Playback control', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should play audio', async () => {
// Mock play method
const playPromise = Promise.resolve();
audioElement.play = vi.fn().mockReturnValue(playPromise);
await service.play();
expect(audioElement.play).toHaveBeenCalled();
});
it('should pause audio', () => {
audioElement.pause = vi.fn();
service.pause();
expect(audioElement.pause).toHaveBeenCalled();
});
it('should stop audio', () => {
audioElement.pause = vi.fn();
audioElement.currentTime = 50;
service.stop();
expect(audioElement.pause).toHaveBeenCalled();
expect(audioElement.currentTime).toBe(0);
});
it('should handle play error', async () => {
const error = new Error('Play failed');
audioElement.play = vi.fn().mockRejectedValue(error);
await expect(service.play()).rejects.toThrow('Failed to play audio');
});
});
describe('Seek', () => {
beforeEach(() => {
service.initialize(audioElement);
Object.defineProperty(audioElement, 'duration', {
value: 180,
writable: true,
configurable: true,
});
});
it('should seek to position', () => {
service.seek(50);
expect(audioElement.currentTime).toBe(50);
});
it('should clamp seek to duration', () => {
service.seek(200);
expect(audioElement.currentTime).toBe(180);
});
it('should not allow negative seek', () => {
service.seek(-10);
expect(audioElement.currentTime).toBe(0);
});
});
describe('Volume control', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should set volume', () => {
service.setVolume(0.5);
expect(audioElement.volume).toBe(0.5);
});
it('should clamp volume to 0-1', () => {
service.setVolume(1.5);
expect(audioElement.volume).toBe(1);
service.setVolume(-0.5);
expect(audioElement.volume).toBe(0);
});
it('should set muted', () => {
service.setMuted(true);
expect(audioElement.muted).toBe(true);
service.setMuted(false);
expect(audioElement.muted).toBe(false);
});
it('should get volume', () => {
audioElement.volume = 0.75;
expect(service.getVolume()).toBe(0.75);
});
it('should get muted state', () => {
audioElement.muted = true;
expect(service.isMuted()).toBe(true);
});
});
describe('State getters', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should get current time', () => {
audioElement.currentTime = 45;
expect(service.getCurrentTime()).toBe(45);
});
it('should get duration', () => {
Object.defineProperty(audioElement, 'duration', {
value: 180,
writable: true,
configurable: true,
});
expect(service.getDuration()).toBe(180);
});
it('should check if playing', () => {
Object.defineProperty(audioElement, 'paused', {
value: false,
writable: true,
configurable: true,
});
Object.defineProperty(audioElement, 'ended', {
value: false,
writable: true,
configurable: true,
});
expect(service.isPlaying()).toBe(true);
Object.defineProperty(audioElement, 'paused', {
value: true,
writable: true,
configurable: true,
});
expect(service.isPlaying()).toBe(false);
});
});
describe('Event callbacks', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should call timeUpdate callback', () => {
const callback = vi.fn();
service.onTimeUpdate(callback);
audioElement.currentTime = 10;
audioElement.dispatchEvent(new Event('timeupdate'));
expect(callback).toHaveBeenCalledWith(10);
});
it('should call durationChange callback', () => {
const callback = vi.fn();
service.onDurationChange(callback);
Object.defineProperty(audioElement, 'duration', {
value: 180,
writable: true,
configurable: true,
});
audioElement.dispatchEvent(new Event('loadedmetadata'));
expect(callback).toHaveBeenCalledWith(180);
});
it('should call ended callback', () => {
const callback = vi.fn();
service.onEnded(callback);
audioElement.dispatchEvent(new Event('ended'));
expect(callback).toHaveBeenCalled();
});
it('should call error callback', () => {
const callback = vi.fn();
service.onError(callback);
const errorEvent = new Event('error');
Object.defineProperty(audioElement, 'error', {
value: { message: 'Test error' },
writable: true,
});
audioElement.dispatchEvent(errorEvent);
expect(callback).toHaveBeenCalled();
});
it('should call play callback', () => {
const callback = vi.fn();
service.onPlay(callback);
audioElement.dispatchEvent(new Event('play'));
expect(callback).toHaveBeenCalled();
});
it('should call pause callback', () => {
const callback = vi.fn();
service.onPause(callback);
audioElement.dispatchEvent(new Event('pause'));
expect(callback).toHaveBeenCalled();
});
});
describe('Cleanup', () => {
it('should cleanup event listeners', () => {
service.initialize(audioElement);
const callback = vi.fn();
service.onTimeUpdate(callback);
service.cleanup();
audioElement.dispatchEvent(new Event('timeupdate'));
expect(callback).not.toHaveBeenCalled();
});
});
// v0.13.1 TASK-AUDIO-003: Normalization
describe('Audio normalization', () => {
it('should have normalization enabled by default', () => {
expect(service.isNormalizationEnabled()).toBe(true);
});
it('should toggle normalization', () => {
service.setNormalizationEnabled(false);
expect(service.isNormalizationEnabled()).toBe(false);
service.setNormalizationEnabled(true);
expect(service.isNormalizationEnabled()).toBe(true);
});
it('should set normalization gain', () => {
// Should not throw even without audio graph connected
service.setNormalizationGain(-3);
service.setNormalizationGain(0);
service.setNormalizationGain(5);
});
it('should return null gain node when not connected', () => {
expect(service.getGainNode()).toBeNull();
});
});
// v0.13.1 TASK-AUDIO-001: Gapless preloading
describe('Gapless preloading', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should preload a track URL', () => {
service.preloadTrack('https://example.com/next.mp3');
expect(service.hasPreloadFor('https://example.com/next.mp3')).toBe(true);
});
it('should not preload invalid URLs', () => {
service.preloadTrack('');
service.preloadTrack('undefined');
expect(service.hasPreloadFor('')).toBe(false);
});
it('should consume preloaded track', () => {
service.preloadTrack('https://example.com/next.mp3');
const url = service.consumePreload();
expect(url).toContain('example.com/next.mp3');
});
});
// v0.13.1 TASK-AUDIO-004: Playback speed
describe('Playback speed', () => {
beforeEach(() => {
service.initialize(audioElement);
});
it('should set playback rate', () => {
service.setPlaybackRate(1.5);
expect(audioElement.playbackRate).toBe(1.5);
});
it('should clamp playback rate to 0.5-2', () => {
service.setPlaybackRate(3);
expect(audioElement.playbackRate).toBe(2);
service.setPlaybackRate(0.1);
expect(audioElement.playbackRate).toBe(0.5);
});
});
// v0.13.1 TASK-AUDIO-002: Fade in/out
describe('Crossfade', () => {
beforeEach(() => {
service.initialize(audioElement);
audioElement.volume = 1;
});
it('should call onComplete immediately if seconds <= 0', async () => {
const onComplete = vi.fn();
await service.fadeOut(0, onComplete);
expect(onComplete).toHaveBeenCalled();
});
it('should set volume to target after fadeIn with 0 seconds', async () => {
audioElement.volume = 0;
await service.fadeIn(0, 0.8);
expect(audioElement.volume).toBe(0.8);
});
});
});
});