import { test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { api } from '../src/api/client.js' // The client half of Team notifications (docs/website/TEAMS.md Part 6, phase 6). // // There is no DOM in this runner, so what is asserted here is the WIRE — which is // where this feature's client-side mistakes actually live. Two of them have // already been made once in this repo and are recorded rather than re-derived: // // 1. **A PUT-the-whole-set body must always carry its array**, empty included. // `docs/android/PLAN.md` §11: a DTO field with a default is dropped by // kotlinx when it equals that default, so "clear the last entry" arrives as a // body with no array at all and 400s. The web client has no such // serialisation quirk, but it shares the endpoint's contract, and a test that // pins the shape here is what keeps the two clients honest about the same // rule. // 2. **The unsubscribe call is a POST**, not the GET the link in the mail was. // A GET that mutated would be triggered by every mail-client link scanner. let calls const realFetch = global.fetch function reply(body = {}) { return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body), } } beforeEach(() => { calls = [] global.fetch = async (url, opts = {}) => { calls.push({ url, opts }) return reply({ teams: [], streams: [], ok: true }) } }) afterEach(() => { global.fetch = realFetch }) const body = (i = 0) => JSON.parse(calls[i].opts.body) test('the per-Team preference endpoints sit under /auth/me, not /player', async () => { await api.teamNotificationPrefs() // Role-agnostic self-service, the same rule that put the Team forum under // /player rather than behind a staff gate: staff are a superset of players and // manage their own notifications like anyone else. assert.match(calls[0].url, /\/auth\/me\/notifications\/teams$/) assert.equal(calls[0].opts.method ?? 'GET', 'GET') }) test('saving preferences PUTs the whole set under a `teams` key', async () => { await api.setTeamNotificationPrefs([{ teamId: 3, muted: true, emailMode: 'digest' }]) assert.equal(calls[0].opts.method, 'PUT') assert.deepEqual(body(), { teams: [{ teamId: 3, muted: true, emailMode: 'digest' }] }) }) test('clearing every preference still sends the array, never an absent key', async () => { await api.setTeamNotificationPrefs([]) assert.deepEqual(body(), { teams: [] }) assert.equal('teams' in body(), true) }) test('the same rule holds for the stream subscriptions beside them', async () => { await api.setNotificationSubscriptions([]) assert.deepEqual(body(), { streams: [] }) }) test('unsubscribe is a POST to the public tier, with the token encoded into the path', async () => { await api.unsubscribeTeam('1.7.3.abcDEF') assert.equal(calls[0].opts.method, 'POST') assert.match(calls[0].url, /\/public\/teams\/unsubscribe\/1\.7\.3\.abcDEF$/) }) test('a token with url-unsafe characters is encoded rather than pasted in', async () => { await api.unsubscribeTeam('a/b c') assert.match(calls[0].url, /unsubscribe\/a%2Fb%20c$/) }) test('the streams catalog and subscriptions are separate reads', async () => { await api.notificationStreams() await api.notificationSubscriptions() assert.match(calls[0].url, /\/notifications\/streams$/) assert.match(calls[1].url, /\/notifications\/subscriptions$/) })