Reactions to your songs, new followers, and songs sent to you are now notifications: a bell on the feed (app and website) with an unread count that updates live, and a list that marks them read. "Send ✉" on a song sends it to people who follow you, with an optional note. Settings picks which kinds to be notified about. Push delivery uses no third-party accounts: - Android: UnifiedPush through a distributor app (ntfy), pointed at our own ntfy server (docker-compose.ntfy.yml). Works the same in the F-Droid build; no Firebase. - Browsers: standard Web Push from the existing service worker. Both use Web Push encryption with a VAPID key pair the server makes and keeps in the database. Push addresses are checked like any other user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers the new data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
6.2 KiB
TypeScript
127 lines
6.2 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('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 } } })).json();
|
|
expect(me.notifyPrefs).toEqual({ reaction: true, 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,}$/);
|
|
});
|
|
});
|