Files
musicshare/apps/api/test/social.test.ts
T
chrisandClaude Opus 5.5 63d618f0e3 Unlock everything, add a donation link, credit Last.fm, nudge re-linking
- Every feature is free for everyone (the plan system stays for later);
  remaining limits are abuse guards
- Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in
  the mobile apps, where tips to the developer generally need in-app
  purchase
- Last.fm/ListenBrainz-sourced plays link to the user's profile, as
  Last.fm's API terms require
- Connections track when they last saw activity; a scrobbler silent for
  over a week prompts re-linking Spotify (its link expires every 6 months)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-04 08:13:54 -04:00

117 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { PLAN_ENTITLEMENTS } from '@musicshare/shared';
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<string, string> = {};
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('caps pins as an abuse guard (402 past the limit), and unpinning frees a slot', async () => {
const max = PLAN_ENTITLEMENTS.free.maxPinnedFriends;
const alice = await createUser(app(), 'alice');
const names = Array.from({ length: max + 1 }, (_, i) => `friend${i}`);
for (const name of names) {
await createUser(app(), name);
await follow(app(), alice.token, name);
}
for (const name of names.slice(0, max)) expect((await pin(alice.token, name)).statusCode).toBe(200);
const over = await pin(alice.token, names[max]!);
expect(over.statusCode).toBe(402);
expect(over.json().error).toBe(`You can pin up to ${max} friends on your plan`);
await pin(alice.token, names[0]!, 'DELETE');
expect((await pin(alice.token, names[max]!)).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);
});
});