- Shared: link building, feed ordering, input schemas, API client - API unit: track matching, private-address blocking, token crypto, plan expiry, provider response parsing - API integration against Postgres and Redis: auth, follows and pins, now-playing state, webhooks, OAuth return URLs, realtime, rate limits - Tests use a dedicated *_test database and Redis DB, and refuse to wipe anything else; fetch is stubbed so they never hit the network - Pin Node to 22.13+ (.nvmrc), which Vite's native bindings require Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
118 lines
5.5 KiB
TypeScript
118 lines
5.5 KiB
TypeScript
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<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('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);
|
|
});
|
|
});
|