import { beforeEach, describe, expect, it, vi } from 'vitest';
const cmsApiMock = vi.fn();
vi.mock('../../lib/api.js', () => ({
    cmsApi: (...args) => cmsApiMock(...args),
    // SSE uses EventSource, which doesn't go through cmsApi. The
    // helper resolves the URL via getApiBase() — return the production
    // default so tests don't need to know the prefix.
    getApiBase: () => '/api/cms',
}));
const { listNotifications, markAllNotificationsRead, markNotificationRead, notificationSummary, subscribeNotificationStream, unreadCount, } = await import('../../lib/notifications-client.js');
function row(overrides = {}) {
    return {
        id: 'n-1',
        userId: 'user-1',
        kind: 'comment_reply',
        documentId: 'doc-1',
        commentId: 'c-1',
        payload: {
            kind: 'comment_reply',
            actorId: 'someone',
            actorName: 'Someone',
            excerpt: 'hi',
        },
        readAt: null,
        createdAt: '2026-01-01T00:00:00.000Z',
        ...overrides,
    };
}
beforeEach(() => {
    cmsApiMock.mockReset();
});
describe('listNotifications', () => {
    it('GETs /notifications without query when no options', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: [row()], status: 200 });
        const out = await listNotifications();
        expect(out.ok).toBe(true);
        expect(cmsApiMock).toHaveBeenCalledWith('/notifications', { method: 'GET' });
    });
    it('appends ?includeRead and ?pageSize when set', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: [], status: 200 });
        await listNotifications({ includeRead: true, pageSize: 25 });
        expect(cmsApiMock).toHaveBeenCalledWith('/notifications?includeRead=true&pageSize=25', {
            method: 'GET',
        });
    });
    it('returns the data array unchanged', async () => {
        const rows = [row({ id: 'a' }), row({ id: 'b' })];
        cmsApiMock.mockResolvedValueOnce({ data: rows, status: 200 });
        const out = await listNotifications();
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.result.map((n) => n.id)).toEqual(['a', 'b']);
    });
    it('propagates errors as { ok: false }', async () => {
        cmsApiMock.mockResolvedValueOnce({ error: 'nope', status: 500 });
        const out = await listNotifications();
        expect(out.ok).toBe(false);
        if (out.ok)
            return;
        expect(out.error).toBe('nope');
        expect(out.status).toBe(500);
    });
});
describe('unreadCount', () => {
    it('returns the count from the envelope', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: { count: 7 }, status: 200 });
        const out = await unreadCount();
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.result).toBe(7);
    });
    it('propagates failure when the server returns no data', async () => {
        cmsApiMock.mockResolvedValueOnce({ status: 503 });
        const out = await unreadCount();
        expect(out.ok).toBe(false);
    });
});
describe('markNotificationRead', () => {
    it('POSTs to the id-specific endpoint', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: row({ readAt: 'now' }), status: 200 });
        const out = await markNotificationRead('n-1');
        expect(out.ok).toBe(true);
        expect(cmsApiMock).toHaveBeenCalledWith('/notifications/n-1/read', {
            method: 'POST',
            body: '{}',
        });
    });
    it('URL-encodes weird ids', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: row(), status: 200 });
        await markNotificationRead('n/1?');
        expect(cmsApiMock).toHaveBeenCalledWith(`/notifications/${encodeURIComponent('n/1?')}/read`, expect.anything());
    });
    it('rejects an empty id without hitting the wire', async () => {
        const out = await markNotificationRead('');
        expect(out.ok).toBe(false);
        expect(cmsApiMock).not.toHaveBeenCalled();
    });
});
describe('markAllNotificationsRead', () => {
    it('POSTs /notifications/read-all and returns the count', async () => {
        cmsApiMock.mockResolvedValueOnce({ data: { count: 4 }, status: 200 });
        const out = await markAllNotificationsRead();
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.result).toBe(4);
        expect(cmsApiMock).toHaveBeenCalledWith('/notifications/read-all', {
            method: 'POST',
            body: '{}',
        });
    });
    it('propagates a server error', async () => {
        cmsApiMock.mockResolvedValueOnce({ error: 'CSRF', status: 403 });
        const out = await markAllNotificationsRead();
        expect(out.ok).toBe(false);
    });
});
describe('notificationSummary', () => {
    it('formats comment_reply with the actor + excerpt', () => {
        expect(notificationSummary(row({
            payload: {
                kind: 'comment_reply',
                actorId: 'a',
                actorName: 'Alice',
                excerpt: 'good point',
            },
        }))).toBe('Alice replied: "good point"');
    });
    it('formats comment_resolved with rootExcerpt', () => {
        expect(notificationSummary(row({
            kind: 'comment_resolved',
            payload: {
                kind: 'comment_resolved',
                actorId: 'a',
                actorName: 'Bob',
                rootExcerpt: 'fix this',
            },
        }))).toBe('Bob resolved your thread: "fix this"');
    });
    it('formats comment_mention with excerpt', () => {
        expect(notificationSummary(row({
            kind: 'comment_mention',
            payload: {
                kind: 'comment_mention',
                actorId: 'a',
                actorName: 'Carol',
                excerpt: 'cc @you',
            },
        }))).toBe('Carol mentioned you: "cc @you"');
    });
    it('falls back to "Someone" when actorName is null', () => {
        expect(notificationSummary(row({
            payload: { kind: 'comment_reply', actorId: null, actorName: null, excerpt: 'x' },
        }))).toMatch(/^Someone replied/);
    });
});
// ---------------------------------------------------------------------------
// SSE push channel — subscribeNotificationStream
// ---------------------------------------------------------------------------
/**
 * In-memory `EventSource` double. Tests drive the wire by invoking
 * `emit(event, payload)`; the subscriber dispatches the right
 * listener. We deliberately keep the shape minimal — `EventSource`
 * has dozens of properties our admin doesn't touch.
 */
function makeFakeSource() {
    const listeners = new Map();
    let closed = false;
    const source = {
        addEventListener: (type, listener) => {
            if (closed)
                return;
            let set = listeners.get(type);
            if (!set) {
                set = new Set();
                listeners.set(type, set);
            }
            set.add(listener);
        },
        removeEventListener: (type, listener) => {
            listeners.get(type)?.delete(listener);
        },
        close: () => {
            closed = true;
            listeners.clear();
        },
    };
    return {
        source,
        listenerCount: (type) => listeners.get(type)?.size ?? 0,
        isClosed: () => closed,
        emit(type, payload) {
            const set = listeners.get(type);
            if (!set)
                return;
            const event = typeof payload === 'string'
                ? { data: payload }
                : payload === undefined
                    ? new Event(type)
                    : { data: JSON.stringify(payload) };
            for (const l of [...set])
                l(event);
        },
    };
}
function startStream(fake, overrides = {}) {
    return subscribeNotificationStream({
        onNotification: () => undefined,
        factory: () => fake.source,
        ...overrides,
    });
}
describe('subscribeNotificationStream', () => {
    it('dispatches `notification` frames as Notification objects', () => {
        const fake = makeFakeSource();
        const seen = [];
        startStream(fake, { onNotification: (n) => seen.push(n) });
        fake.emit('notification', row({ id: 'sse-1' }));
        expect(seen.map((n) => n.id)).toEqual(['sse-1']);
    });
    it('dispatches `ready` frames once via onReady', () => {
        const fake = makeFakeSource();
        const readys = [];
        startStream(fake, { onReady: (p) => readys.push(p) });
        fake.emit('ready', { userId: 'user-1' });
        expect(readys).toEqual([{ userId: 'user-1' }]);
    });
    it('ignores malformed payloads (defence against wire drift)', () => {
        const fake = makeFakeSource();
        const seen = [];
        startStream(fake, { onNotification: (n) => seen.push(n) });
        // Missing id + payload — must not throw, must not deliver.
        fake.emit('notification', { userId: 'user-1', createdAt: 'now' });
        fake.emit('notification', 'not json');
        expect(seen).toEqual([]);
    });
    it('removes its listeners and closes the underlying source on close()', () => {
        const fake = makeFakeSource();
        const handle = startStream(fake);
        expect(fake.listenerCount('notification')).toBe(1);
        expect(fake.listenerCount('ready')).toBe(1);
        expect(fake.listenerCount('error')).toBe(1);
        handle.close();
        expect(fake.isClosed()).toBe(true);
        // Second close() is a no-op — guards against double-unmount loops.
        handle.close();
        expect(fake.isClosed()).toBe(true);
    });
    it('returns a no-op handle when no EventSource is available', () => {
        // No factory + no global EventSource (vitest jsdom env doesn't
        // ship one). The subscriber must NOT throw — admin code must
        // remain mountable in SSR / Node environments.
        const handle = subscribeNotificationStream({
            onNotification: () => undefined,
            factory: () => null,
        });
        expect(() => handle.close()).not.toThrow();
    });
    it('forwards error events to onError without crashing the page', () => {
        const fake = makeFakeSource();
        const errors = [];
        startStream(fake, { onError: (e) => errors.push(e) });
        fake.emit('error', undefined);
        expect(errors).toHaveLength(1);
    });
    it('builds the stream URL from getApiBase() so consumers with a custom prefix connect to the right host', () => {
        // Custom factory inspects the URL it's handed. Verifies the
        // subscriber composes /<base>/notifications/stream rather than
        // hardcoding the production prefix.
        const seen = [];
        subscribeNotificationStream({
            onNotification: () => undefined,
            factory: (url) => {
                seen.push(url);
                return null;
            },
        });
        expect(seen).toEqual(['/api/cms/notifications/stream']);
    });
});
//# sourceMappingURL=notifications-client.test.js.map