Add unit and integration test suites

- 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
This commit is contained in:
2026-10-03 11:28:17 -04:00
co-authored by Claude Opus 5.5
parent 95cd740e51
commit fe503307df
29 changed files with 3281 additions and 10 deletions
+117
View File
@@ -0,0 +1,117 @@
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);
});
});