Files
musicshare/apps/api/test/notifications.test.ts
chrisandClaude Opus 5.5 4402171f46 Comments on plays
- Anyone who can see a play can read its comments; the player and the people they follow can write.
  The author, the player and admins can delete; comments can be reported (admins see the text).
- Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its
  own setting. Reaction and comment notifications now open the play.
- A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on
  the app (/play/[id]) and the website. Threads refresh every few seconds while open.
- Blocking removes comments between the two people. Plays with comments survive history trimming.
- Privacy page updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:32:14 -04:00

134 lines
6.6 KiB
TypeScript

import { createECDH, randomBytes } from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyPollResult } from '../src/services/nowPlaying';
import { sendPush } from '../src/services/notifications';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
afterEach(() => vi.unstubAllGlobals());
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
const inbox = async (token: string) => (await app().inject({ url: '/notifications', headers: bearer(token) })).json();
const react = (token: string, listenId: string, emoji: string) =>
app().inject({ method: 'PUT', url: `/listens/${listenId}/reaction`, headers: bearer(token), payload: { emoji } });
/** Alice plays a song; Bob follows her. */
async function setup() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
return { alice, bob, listenId: feed[0].listenId as string, trackId: feed[0].track.id as string };
}
describe('notifications', () => {
it('tells you about a new follower once, even if they unfollow and follow again', async () => {
const { alice, bob } = await setup();
await app().inject({ method: 'DELETE', url: '/users/alice/follow', headers: bearer(bob.token) });
await follow(app(), bob.token, 'alice');
const list = await inbox(alice.token);
expect(list.unread).toBe(1);
expect(list.notifications).toEqual([expect.objectContaining({ type: 'follow', actor: expect.objectContaining({ username: 'bob' }), read: false })]);
});
it('a reaction is one notification that changes with the emoji, and goes if taken back', async () => {
const { alice, bob, listenId } = await setup();
await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(alice.token), payload: {} });
await react(bob.token, listenId, '🔥');
await react(bob.token, listenId, '😂');
let list = await inbox(alice.token);
const reactions = list.notifications.filter((n: { type: string }) => n.type === 'reaction');
expect(reactions).toEqual([expect.objectContaining({ emoji: '😂', track: expect.objectContaining({ title: 'Teardrop' }) })]);
expect(list.unread).toBe(1);
await app().inject({ method: 'DELETE', url: `/listens/${listenId}/reaction`, headers: bearer(bob.token) });
list = await inbox(alice.token);
expect(list.notifications.filter((n: { type: string }) => n.type === 'reaction')).toEqual([]);
});
it('sends songs only to people who follow you', async () => {
const { alice, bob, trackId } = await setup();
const send = (token: string, to: string[]) =>
app().inject({ method: 'POST', url: '/shares', headers: bearer(token), payload: { trackId, to, message: 'you need this' } });
// Bob follows Alice, so Alice can send to Bob, but Bob can't send to Alice.
expect((await send(alice.token, ['bob', 'nobody'])).json()).toEqual({ sentTo: ['bob'] });
expect((await send(bob.token, ['alice'])).statusCode).toBe(400);
const list = await inbox(bob.token);
expect(list.notifications[0]).toMatchObject({
type: 'share',
message: 'you need this',
actor: { username: 'alice' },
track: { title: 'Teardrop' },
});
await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(bob.token), payload: {} });
expect((await inbox(bob.token)).unread).toBe(0);
});
it('blocking removes notifications between the two', async () => {
const { alice } = await setup();
expect((await inbox(alice.token)).notifications).toHaveLength(1);
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
expect((await inbox(alice.token)).notifications).toHaveLength(0);
});
it('saves notification settings', async () => {
const { alice } = await setup();
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
});
});
describe('push', () => {
// A real P-256 key pair, as a browser or the UnifiedPush connector makes.
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
const keys = { p256dh: ecdh.getPublicKey('base64url'), auth: randomBytes(16).toString('base64url') };
const subscribe = (token: string, endpoint: string) =>
app().inject({ method: 'PUT', url: '/me/push', headers: bearer(token), payload: { kind: 'unifiedpush', endpoint, keys } });
it('refuses push endpoints on private networks', async () => {
const alice = await createUser(app(), 'alice');
expect((await subscribe(alice.token, 'http://127.0.0.1:8080/up/abc')).statusCode).toBe(400);
expect((await subscribe(alice.token, 'https://93.184.216.34/up/abc')).statusCode).toBe(200);
});
it('sends an encrypted, signed push, respects settings, and forgets endpoints that are gone', async () => {
const { alice } = await setup();
await subscribe(alice.token, 'https://93.184.216.34/up/abc');
const id = (await inbox(alice.token)).notifications[0].id;
const sent: { url: string; headers: Record<string, string>; body: Buffer }[] = [];
let status = 201;
vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {
sent.push({ url, headers: init.headers as Record<string, string>, body: init.body as Buffer });
return new Response(null, { status });
});
await sendPush(id);
expect(sent).toHaveLength(1);
expect(sent[0]!.url).toBe('https://93.184.216.34/up/abc');
expect(sent[0]!.headers['Content-Encoding']).toBe('aes128gcm');
expect(sent[0]!.headers.Authorization).toMatch(/^vapid t=/);
expect(sent[0]!.body.toString()).not.toContain('bob'); // encrypted
await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } });
await sendPush(id);
expect(sent).toHaveLength(1);
await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: true } } });
status = 410;
await sendPush(id);
expect(sent).toHaveLength(2);
await sendPush(id);
expect(sent).toHaveLength(2); // subscription removed
});
it('serves the public key', async () => {
const res = await app().inject({ url: '/push/key' });
expect(res.json().publicKey).toMatch(/^[A-Za-z0-9_-]{80,}$/);
});
});