- 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
117 lines
5.4 KiB
TypeScript
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);
|
|
});
|
|
|
|
});
|