Files
chrisandClaude Opus 5.5 586acdbeab API: follower lists, blocking, reports, admin; drop Spotify sign-in
- Followers/following lists (paginated), 'follows you', remove a follower
- Blocking severs follows both ways and hides each user from the other
  (profile 404s, search, follows); the blocker sees activity hidden
- Reports for moderation (App Store guideline 1.2)
- First account on a server becomes admin; admins see the report queue,
  resolve reports, and suspend accounts (signs out, blocks login, hides
  the profile, stops sharing). set-role script to promote/demote
- users.onboardedAt drives first-run onboarding; existing accounts are
  backfilled as already onboarded. Empty PATCH /me no longer 500s
- Remove Spotify account sign-in and polling: Spotify caps new apps at 5
  accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the
  Android reader. App-level catalog search stays for exact links
- Shared: QR code generation and profile-link parsing (verified with jsQR)
- Integration test timeout raised; password hashing is slow by design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-05 19:16:40 -04:00

128 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
const get = (url: string, token?: string) => app().inject({ url, headers: token ? bearer(token) : {} });
const post = (url: string, token: string, payload: object = {}) => app().inject({ method: 'POST', url, headers: bearer(token), payload });
const del = (url: string, token: string) => app().inject({ method: 'DELETE', url, headers: bearer(token) });
const names = (list: { users: { username: string }[] }) => list.users.map((u) => u.username);
describe('followers and following lists', () => {
it('lists followers and following, newest first, with the viewer’s follow state', async () => {
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(), carol.token, 'alice');
await follow(app(), alice.token, 'carol');
const followers = (await get('/users/alice/followers', alice.token)).json();
expect(names(followers)).toEqual(['carol', 'bob']);
expect(followers.users.find((u: { username: string }) => u.username === 'carol').isFollowing).toBe(true);
expect(followers.nextCursor).toBeNull();
expect(names((await get('/users/alice/following')).json())).toEqual(['carol']);
});
it('paginates', { timeout: 60_000 }, async () => {
const alice = await createUser(app(), 'alice');
for (let i = 0; i < 55; i++) {
const f = await createUser(app(), `fan${i}`);
await follow(app(), f.token, 'alice');
}
const first = (await get('/users/alice/followers', alice.token)).json();
expect(first.users).toHaveLength(50);
const second = (await get(`/users/alice/followers?cursor=${first.nextCursor}`, alice.token)).json();
expect(second.users).toHaveLength(5);
expect(second.nextCursor).toBeNull();
});
it('shows "follows you" on profiles', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
expect((await get('/users/bob', alice.token)).json().followsYou).toBe(true);
expect((await get('/users/alice', bob.token)).json().followsYou).toBe(false);
});
it('removing a follower takes them off your list', async () => {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
expect((await del('/me/followers/bob', alice.token)).statusCode).toBe(200);
expect(names((await get('/users/alice/followers', alice.token)).json())).toEqual([]);
expect((await get('/feed', bob.token)).json()).toEqual([]);
});
});
describe('blocking', () => {
async function blockedPair() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), alice.token, 'bob');
await follow(app(), bob.token, 'alice');
await applyPollResult(alice.user.id, 'spotify', { track: { title: 'Secret', artist: 'X' }, isPlaying: true, progressMs: 0 });
expect((await post('/users/bob/block', alice.token)).statusCode).toBe(200);
return { alice, bob };
}
it('severs follows in both directions', async () => {
const { alice, bob } = await blockedPair();
expect(await db.select().from(schema.follows)).toHaveLength(0);
expect((await get('/feed', bob.token)).json()).toEqual([]);
expect((await get('/feed', alice.token)).json()).toEqual([]);
});
it('hides the blocker from the blocked person entirely', async () => {
const { bob } = await blockedPair();
expect((await get('/users/alice', bob.token)).statusCode).toBe(404);
expect((await get('/users/alice/followers', bob.token)).statusCode).toBe(404);
expect((await get('/users/search?q=ali', bob.token)).json()).toEqual([]);
expect((await post('/users/alice/follow', bob.token)).statusCode).toBe(404);
});
it('still shows them to everyone else', async () => {
await blockedPair();
const carol = await createUser(app(), 'carol');
expect((await get('/users/alice', carol.token)).json().nowPlaying.track.title).toBe('Secret');
});
it("the blocker sees a profile with activity hidden, and can unblock", async () => {
const { alice, bob } = await blockedPair();
await applyPollResult(bob.user.id, 'spotify', { track: { title: 'Bobs song', artist: 'Y' }, isPlaying: true, progressMs: 0 });
const profile = (await get('/users/bob', alice.token)).json();
expect(profile).toMatchObject({ isBlocked: true, nowPlaying: null, recent: [], featured: null });
expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(400);
expect((await get('/me/blocks', alice.token)).json().map((u: { username: string }) => u.username)).toEqual(['bob']);
await del('/users/bob/block', alice.token);
expect((await get('/users/alice', bob.token)).statusCode).toBe(200);
expect((await post('/users/bob/follow', alice.token)).statusCode).toBe(200);
});
it("can't block yourself", async () => {
const alice = await createUser(app(), 'alice');
expect((await post('/users/alice/block', alice.token)).statusCode).toBe(400);
});
});
describe('reporting', () => {
it('stores a report for moderation', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'spammer');
const res = await post('/users/spammer/report', alice.token, { reason: 'spam', note: 'posting links' });
expect(res.statusCode).toBe(200);
const [report] = await db.select().from(schema.reports);
expect(report).toMatchObject({ reason: 'spam', note: 'posting links', status: 'open', reporterId: alice.user.id });
});
it('validates the reason and refuses self-reports', async () => {
const alice = await createUser(app(), 'alice');
await createUser(app(), 'bob');
expect((await post('/users/bob/report', alice.token, { reason: 'boring' })).statusCode).toBe(400);
expect((await post('/users/alice/report', alice.token, { reason: 'spam' })).statusCode).toBe(400);
});
});