Files
musicshare/apps/api/test/pause.test.ts
T
chrisandClaude Opus 5.5 70fc7efbbe Add phone playback reporting and pause sharing to the API
Phones that read system media sessions report playback themselves:
- POST /me/devices issues a per-phone token that can only call
  POST /devices/now-playing; revocable, hashed at rest, 10 per user
- Reports name the app ("via Spotify"); unknown apps use the OS label
- Push-reported playback expires after 9 minutes of silence, since
  phones send a heartbeat every 4 minutes while playing

Users can pause sharing (PUT /me/sharing: 1h, 8h, indefinitely). While
paused, connections aren't polled, phone reports are dropped, nothing is
added to history, and the current track switches to "played … ago".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-03 17:37:30 -04:00

82 lines
3.8 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { redis } from '../src/lib/redis';
import { applyPollResult } from '../src/services/nowPlaying';
import { upsertConnection } from '../src/services/connections';
import { bearer, createUser, useTestApp } from './helpers';
const app = useTestApp();
const setSharing = (token: string, pause: string | null) =>
app().inject({ method: 'PUT', url: '/me/sharing', headers: bearer(token), payload: { pause } });
async function deviceFor(token: string) {
const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel' } });
return res.json().token as string;
}
const report = (deviceToken: string, title: string) =>
app().inject({
method: 'POST',
url: '/devices/now-playing',
headers: bearer(deviceToken),
payload: { app: 'com.spotify.music', state: 'playing', track: { title, artist: 'New Order' } },
});
describe('pausing sharing', () => {
it.each([
['1h', 3600_000],
['8h', 8 * 3600_000],
])('pause %s sets a resume time', async (pause, ms) => {
const { token } = await createUser(app(), 'alice');
const me = (await setSharing(token, pause)).json();
const until = new Date(me.sharingPausedUntil).getTime();
expect(Math.abs(until - (Date.now() + ms))).toBeLessThan(5000);
});
it('turns what was playing into "played … ago" immediately', async () => {
const { token, user } = await createUser(app(), 'alice');
await applyPollResult(user.id, 'spotify', { track: { title: 'Ceremony', artist: 'New Order' }, isPlaying: true, progressMs: 0 });
await setSharing(token, 'indefinitely');
expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false);
});
it('drops phone reports and records no history while paused, then shares again on resume', async () => {
const { token } = await createUser(app(), 'alice');
const deviceToken = await deviceFor(token);
await setSharing(token, 'indefinitely');
expect((await report(deviceToken, 'Temptation')).statusCode).toBe(204);
let profile = (await app().inject({ url: '/users/alice' })).json();
expect(profile.nowPlaying).toBeNull();
expect(profile.recent).toEqual([]);
expect((await setSharing(token, null)).json().sharingPausedUntil).toBeNull();
await report(deviceToken, 'Temptation');
profile = (await app().inject({ url: '/users/alice' })).json();
expect(profile.nowPlaying).toMatchObject({ track: { title: 'Temptation' }, isPlaying: true });
});
it('resuming polls connections right away', async () => {
const { token, user } = await createUser(app(), 'alice');
const conn = await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: 'x', displayName: null });
await setSharing(token, 'indefinitely');
await redis.zadd('poll:due', Date.now() + 600_000, conn.id);
await setSharing(token, null);
expect(Number(await redis.zscore('poll:due', conn.id))).toBeLessThanOrEqual(Date.now());
});
it('an expired pause counts as sharing', async () => {
const { token, user } = await createUser(app(), 'alice');
await db.update(schema.users).set({ sharingPausedUntil: new Date(Date.now() - 1000) }).where(eq(schema.users.id, user.id));
expect((await app().inject({ url: '/me', headers: bearer(token) })).json().sharingPausedUntil).toBeNull();
await report(await deviceFor(token), 'Blue Monday');
expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(true);
});
it('rejects unknown durations', async () => {
const { token } = await createUser(app(), 'alice');
expect((await setSharing(token, 'forever')).statusCode).toBe(400);
});
});