import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { db, schema } from '../src/db'; import { applyPollResult } from '../src/services/nowPlaying'; import { bearer, createUser, follow, useTestApp } from './helpers'; const app = useTestApp(); async function play(userId: string, title: string, isPlaying = true) { await applyPollResult(userId, 'lastfm', { track: { title, artist: 'Artist' }, isPlaying, progressMs: null }); } describe('follows and profiles', () => { it('follow/unfollow updates counts and isFollowing', async () => { const alice = await createUser(app(), 'alice'); await createUser(app(), 'bob'); await follow(app(), alice.token, 'bob'); let profile = (await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json(); expect(profile).toMatchObject({ followerCount: 1, isFollowing: true, isPinned: false, isSelf: false }); await app().inject({ method: 'DELETE', url: '/users/bob/follow', headers: bearer(alice.token) }); profile = (await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json(); expect(profile).toMatchObject({ followerCount: 0, isFollowing: false }); }); it("can't follow yourself", async () => { const alice = await createUser(app(), 'alice'); const res = await app().inject({ method: 'POST', url: '/users/alice/follow', headers: bearer(alice.token), payload: {} }); expect(res.statusCode).toBe(400); }); it('profiles are public but follow needs auth', async () => { await createUser(app(), 'bob'); expect((await app().inject({ url: '/users/bob' })).json()).toMatchObject({ isFollowing: false, isSelf: false }); expect((await app().inject({ method: 'POST', url: '/users/bob/follow', payload: {} })).statusCode).toBe(401); }); it('searches by username prefix and display name, escaping LIKE wildcards', async () => { const alice = await createUser(app(), 'alice'); await createUser(app(), 'bobby'); await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { displayName: 'Alice Cooper' } }); const names = async (q: string) => (await app().inject({ url: `/users/search?q=${encodeURIComponent(q)}` })).json().map((u: { username: string }) => u.username); expect(await names('bob')).toEqual(['bobby']); expect(await names('cooper')).toEqual(['alice']); expect(await names('%%')).toEqual([]); }); }); describe('pinned friends', () => { async function setup() { const alice = await createUser(app(), 'alice'); const friends: Record = {}; for (const name of ['bob', 'carol', 'dave', 'erin']) { friends[name] = (await createUser(app(), name)).user.id; await follow(app(), alice.token, name); } return { alice, friends }; } const pin = (token: string, name: string, method: 'PUT' | 'DELETE' = 'PUT') => app().inject({ method, url: `/users/${name}/pin`, headers: bearer(token), ...(method === 'PUT' ? { payload: {} } : {}) }); const feedOrder = async (token: string) => (await app().inject({ url: '/feed', headers: bearer(token) })) .json() .map((n: { user: { username: string }; pinnedAt: string | null }) => `${n.pinnedAt ? '*' : ''}${n.user.username}`); it('orders the feed: pinned (by pin time), then live, then recent', async () => { const { alice, friends } = await setup(); await play(friends.bob!, 'B'); await play(friends.bob!, 'B', false); // bob played earlier, now stopped await play(friends.carol!, 'C'); await play(friends.dave!, 'D'); await play(friends.erin!, 'E'); expect(await feedOrder(alice.token)).toEqual(['erin', 'dave', 'carol', 'bob']); expect((await pin(alice.token, 'bob')).statusCode).toBe(200); expect((await pin(alice.token, 'dave')).statusCode).toBe(200); expect(await feedOrder(alice.token)).toEqual(['*bob', '*dave', 'erin', 'carol']); }); it('enforces the plan limit with 402, and plus raises it', async () => { const { alice } = await setup(); for (const name of ['bob', 'carol', 'dave']) expect((await pin(alice.token, name)).statusCode).toBe(200); const fourth = await pin(alice.token, 'erin'); expect(fourth.statusCode).toBe(402); expect(fourth.json().error).toBe('You can pin up to 3 friends on your plan'); await db.update(schema.users).set({ plan: 'plus' }).where(eq(schema.users.id, alice.user.id)); expect((await pin(alice.token, 'erin')).statusCode).toBe(200); }); it('pinning is idempotent and does not count twice', async () => { const { alice } = await setup(); for (let i = 0; i < 4; i++) expect((await pin(alice.token, 'bob')).statusCode).toBe(200); expect((await pin(alice.token, 'carol')).statusCode).toBe(200); }); it('requires following, and unfollowing drops the pin', async () => { const { alice } = await setup(); await createUser(app(), 'stranger'); expect((await pin(alice.token, 'stranger')).statusCode).toBe(400); await pin(alice.token, 'bob'); await app().inject({ method: 'DELETE', url: '/users/bob/follow', headers: bearer(alice.token) }); await follow(app(), alice.token, 'bob'); expect((await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json().isPinned).toBe(false); }); it('unpin frees a slot', async () => { const { alice } = await setup(); for (const name of ['bob', 'carol', 'dave']) await pin(alice.token, name); await pin(alice.token, 'bob', 'DELETE'); expect((await pin(alice.token, 'erin')).statusCode).toBe(200); }); });