import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { db, schema } from '../src/db'; import { DEVICE_STALE_MS } from '../src/services/nowPlaying'; import { bearer, createUser, follow, useTestApp } from './helpers'; const app = useTestApp(); async function registerDevice(token: string) { const res = await app().inject({ method: 'POST', url: '/me/devices', headers: bearer(token), payload: { platform: 'android', name: 'Pixel 9' } }); expect(res.statusCode).toBe(200); return res.json() as { device: { id: string }; token: string }; } const report = (deviceToken: string, payload: object) => app().inject({ method: 'POST', url: '/devices/now-playing', headers: bearer(deviceToken), payload }); const spotify = (state: 'playing' | 'paused' | 'stopped', title = 'Atmosphere') => ({ app: 'com.spotify.music', state, track: state === 'stopped' ? undefined : { title, artist: 'Joy Division', album: 'Closer', durationMs: 250_000 }, positionMs: 1000, }); describe('device playback reporting', () => { it('registers a device and returns a one-time token, storing only its hash', async () => { const { token } = await createUser(app(), 'alice'); const { device, token: deviceToken } = await registerDevice(token); expect(deviceToken).toMatch(/^dev_/); const [row] = await db.select().from(schema.devices).where(eq(schema.devices.id, device.id)); expect(row!.tokenHash).not.toContain(deviceToken); expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(1); }); it('reports playback as "via Spotify" and friends see it', async () => { const alice = await createUser(app(), 'alice'); const bob = await createUser(app(), 'bob'); await follow(app(), bob.token, 'alice'); const { token: deviceToken } = await registerDevice(alice.token); expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204); const [item] = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json(); expect(item).toMatchObject({ user: { username: 'alice' }, track: { title: 'Atmosphere', artist: 'Joy Division', album: 'Closer', durationMs: 250_000 }, isPlaying: true, source: 'android_media_session', sourceApp: 'Spotify', }); }); it('uses the OS app name for apps it does not know', async () => { const { token, user } = await createUser(app(), 'alice'); const { token: deviceToken } = await registerDevice(token); await report(deviceToken, { ...spotify('playing'), app: 'com.example.player', appName: 'Example Player' }); const [np] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, user.id)); expect(np!.sourceApp).toBe('Example Player'); }); it('pause and stop end "listening now"', async () => { const { token } = await createUser(app(), 'alice'); const { token: deviceToken } = await registerDevice(token); const isPlaying = async () => (await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying; await report(deviceToken, spotify('playing')); expect(await isPlaying()).toBe(true); await report(deviceToken, spotify('paused')); expect(await isPlaying()).toBe(false); await report(deviceToken, spotify('playing')); await report(deviceToken, { app: 'com.spotify.music', state: 'stopped' }); expect(await isPlaying()).toBe(false); }); it('stops showing "listening now" if the phone goes quiet', async () => { const { token, user } = await createUser(app(), 'alice'); const { token: deviceToken } = await registerDevice(token); await report(deviceToken, spotify('playing')); await db .update(schema.nowPlaying) .set({ updatedAt: new Date(Date.now() - DEVICE_STALE_MS - 1000) }) .where(eq(schema.nowPlaying.userId, user.id)); expect((await app().inject({ url: '/users/alice' })).json().nowPlaying.isPlaying).toBe(false); }); it('device tokens cannot be used as a login, and user tokens cannot report', async () => { const { token } = await createUser(app(), 'alice'); const { token: deviceToken } = await registerDevice(token); expect((await app().inject({ url: '/me', headers: bearer(deviceToken) })).statusCode).toBe(401); expect((await report(token, spotify('playing'))).statusCode).toBe(401); expect((await report('dev_bogus', spotify('playing'))).statusCode).toBe(401); }); it('revoking a device stops its token working', async () => { const { token } = await createUser(app(), 'alice'); const { device, token: deviceToken } = await registerDevice(token); await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(token) }); expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(401); }); it("can't revoke someone else's device", async () => { const alice = await createUser(app(), 'alice'); const mallory = await createUser(app(), 'mallory'); const { device, token: deviceToken } = await registerDevice(alice.token); await app().inject({ method: 'DELETE', url: `/me/devices/${device.id}`, headers: bearer(mallory.token) }); expect((await report(deviceToken, spotify('playing'))).statusCode).toBe(204); }); it('keeps only the 10 newest devices per user', async () => { const { token } = await createUser(app(), 'alice'); const first = await registerDevice(token); for (let i = 0; i < 10; i++) await registerDevice(token); expect((await app().inject({ url: '/me/devices', headers: bearer(token) })).json()).toHaveLength(10); expect((await report(first.token, spotify('playing'))).statusCode).toBe(401); }); it('rejects malformed reports', async () => { const { token } = await createUser(app(), 'alice'); const { token: deviceToken } = await registerDevice(token); expect((await report(deviceToken, { app: 'com.spotify.music', state: 'playing' })).statusCode).toBe(400); expect((await report(deviceToken, { ...spotify('playing'), track: { title: '', artist: 'x' } })).statusCode).toBe(400); }); });