import { eq } from 'drizzle-orm'; import { afterEach, describe, expect, it } from 'vitest'; import { db, schema } from '../src/db'; import { createRedis, nowPlayingChannel } from '../src/lib/redis'; import { reportedPosition } from '../src/routes/devices'; import { applyPollResult } from '../src/services/nowPlaying'; import { nextPollDelay } from '../src/services/scheduler'; import { createUser, useTestApp } from './helpers'; const app = useTestApp(); const sub = createRedis(); afterEach(() => sub.unsubscribe()); const playing = (progressMs: number | null, durationMs: number | null = 200_000) => ({ track: { title: 'Windowlicker', artist: 'Aphex Twin', durationMs }, isPlaying: true, progressMs, }); describe('polling at the end of a track', () => { it('polls just after the song ends when that comes before the next regular poll', () => { expect(nextPollDelay(60_000, playing(180_000))).toBe(22_000); // 20s left + 2s margin }); it('keeps the regular interval when the song ends later', () => { expect(nextPollDelay(10_000, playing(60_000))).toBe(10_000); }); it('never polls more than every 5 seconds', () => { expect(nextPollDelay(60_000, playing(199_500))).toBe(5_000); }); it('keeps the interval without a position, a length, or while paused', () => { expect(nextPollDelay(60_000, playing(null))).toBe(60_000); expect(nextPollDelay(60_000, playing(1000, null))).toBe(60_000); expect(nextPollDelay(60_000, { ...playing(180_000), isPlaying: false })).toBe(60_000); expect(nextPollDelay(60_000, null)).toBe(60_000); }); }); describe('phone-reported positions', () => { const now = Date.parse('2026-10-06T12:00:00Z'); const report = (positionAt: number | null, state: 'playing' | 'paused' = 'playing') => ({ app: 'com.spotify.music', state, track: { title: 'X', artist: 'Y', durationMs: 200_000 }, positionMs: 30_000, positionAt, }); it('advances by the time since the phone read it', () => { expect(reportedPosition(report(now - 4_000), now)).toBe(34_000); }); it("doesn't advance while paused, or trust a wildly wrong clock", () => { expect(reportedPosition(report(now - 4_000, 'paused'), now)).toBe(30_000); expect(reportedPosition(report(now - 3 * 3600_000), now)).toBe(30_000); expect(reportedPosition(report(null), now)).toBe(30_000); }); }); describe('seeking', () => { it('tells watchers when the position jumps, but not as it ticks along', async () => { const { user } = await createUser(app(), 'alice'); await applyPollResult(user.id, 'plex', playing(0)); // Pretend the last poll was 10 seconds ago. await db .update(schema.nowPlaying) .set({ updatedAt: new Date(Date.now() - 10_000) }) .where(eq(schema.nowPlaying.userId, user.id)); const messages: string[] = []; await sub.subscribe(nowPlayingChannel(user.id)); sub.on('message', (_channel, msg) => messages.push(msg)); await applyPollResult(user.id, 'plex', playing(10_500)); // right where it should be await new Promise((r) => setTimeout(r, 150)); expect(messages).toHaveLength(0); await applyPollResult(user.id, 'plex', playing(120_000)); // skipped ahead await new Promise((r) => setTimeout(r, 150)); expect(messages).toHaveLength(1); expect(JSON.parse(messages[0]!)).toMatchObject({ progressMs: 120_000 }); }); });