import { describe, expect, it } from 'vitest'; import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying'; import { bearer, createUser, follow, useTestApp } from './helpers'; const app = useTestApp(); const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null }); /** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */ async function setup() { const alice = await createUser(app(), 'alice'); const bob = await createUser(app(), 'bob'); const carol = await createUser(app(), 'carol'); await follow(app(), bob.token, 'alice'); await follow(app(), alice.token, 'bob'); await follow(app(), carol.token, 'alice'); await applyPollResult(alice.user.id, 'lastfm', song('Teardrop')); const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json(); return { alice, bob, carol, listenId: feed[0].listenId as string }; } const comment = (token: string, listenId: string, body: string) => app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } }); const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) }); /** Comment and reply notifications (setup's follows make some too). */ const notifications = async (token: string) => ((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter( (n) => n.type === 'comment' || n.type === 'reply', ); describe('comments', () => { it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => { const { alice, bob, carol, listenId } = await setup(); expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true }); await comment(alice.token, listenId, 'right?'); const res = await comment(carol.token, listenId, 'hi'); expect(res.statusCode).toBe(403); expect(res.json().error).toBe('Only people @alice follows can comment on their songs'); const forCarol = (await thread(carol.token, listenId)).json(); expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } }); expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([ ['what a tune', false], ['right?', false], ]); expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true); const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json(); expect(feed[0].commentCount).toBe(2); }); it('rejects empty and overlong comments', async () => { const { bob, listenId } = await setup(); expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400); expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400); }); it('notifies the player, and earlier commenters about replies', async () => { const { alice, bob, listenId } = await setup(); const dave = await createUser(app(), 'dave'); await follow(app(), alice.token, 'dave'); await comment(bob.token, listenId, 'what a tune'); await comment(dave.token, listenId, 'agreed'); await comment(alice.token, listenId, 'thanks both'); expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([ ['comment', 'agreed'], ['comment', 'what a tune'], ]); expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([ ['reply', 'thanks both'], ['reply', 'agreed'], ]); const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications; expect(latest).toMatchObject({ type: 'reply', listenId }); }); it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => { const { alice, bob, carol, listenId } = await setup(); const { id } = (await comment(bob.token, listenId, 'first')).json(); const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) }); expect((await del(carol.token)).statusCode).toBe(403); expect(await notifications(alice.token)).toHaveLength(1); expect((await del(alice.token)).statusCode).toBe(200); expect((await thread(bob.token, listenId)).json().comments).toEqual([]); expect(await notifications(alice.token)).toEqual([]); }); it('can be reported, keeping what it said for the admins', async () => { const { bob, carol, listenId } = await setup(); const { id } = (await comment(bob.token, listenId, 'rude words')).json(); const report = (token: string) => app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } }); expect((await report(bob.token)).statusCode).toBe(400); expect((await report(carol.token)).statusCode).toBe(200); }); it('goes away with a block, both ways', async () => { const { alice, bob, listenId } = await setup(); await comment(bob.token, listenId, 'hello'); await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} }); expect((await thread(bob.token, listenId)).statusCode).toBe(404); expect((await thread(alice.token, listenId)).json().comments).toEqual([]); }); it('keeps plays with comments when old history is trimmed', async () => { const { alice, bob, listenId } = await setup(); await comment(bob.token, listenId, 'keep this'); for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) { await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`)); } const res = await thread(bob.token, listenId); expect(res.statusCode).toBe(200); expect(res.json().comments).toHaveLength(1); }); });