import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createNotificationStream, createNotificationStreamResponse, encodeSseEvent, NOTIFICATION_SSE_HEADERS, } from '../../realtime/notification-stream.js';
import { __resetNotificationBus, notificationSubscriberCount, publishNotification, } from '../../realtime/notification-bus.js';
beforeEach(() => {
    __resetNotificationBus();
});
const decoder = new TextDecoder();
function makeDto(overrides = {}) {
    return {
        id: 'n-1',
        userId: 'user-a',
        kind: 'comment_mention',
        documentId: 'doc-1',
        commentId: 'c-1',
        payload: {
            kind: 'comment_mention',
            actorId: 'user-b',
            actorName: 'B',
            excerpt: 'ping',
        },
        readAt: null,
        createdAt: '2026-05-27T00:00:00.000Z',
        ...overrides,
    };
}
async function readNext(reader, timeoutMs = 1000) {
    let timer;
    const timeout = new Promise((_, reject) => {
        timer = setTimeout(() => reject(new Error(`SSE read timed out after ${timeoutMs}ms`)), timeoutMs);
    });
    try {
        const result = await Promise.race([reader.read(), timeout]);
        if (result.done)
            throw new Error('Stream closed before a chunk arrived');
        return decoder.decode(result.value);
    }
    finally {
        if (timer)
            clearTimeout(timer);
    }
}
describe('createNotificationStream', () => {
    let reader;
    afterEach(async () => {
        if (reader) {
            // Cancel triggers the stream's cancel() — verifies cleanup.
            await reader.cancel().catch(() => undefined);
            reader = undefined;
        }
    });
    it('emits a ready frame immediately', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 60_000,
        });
        reader = stream.getReader();
        const first = await readNext(reader);
        expect(first).toContain('event: ready');
        expect(first).toContain('"userId":"user-a"');
    });
    it('forwards published notifications as notification events', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 60_000,
        });
        reader = stream.getReader();
        // ready frame
        await readNext(reader);
        const dto = makeDto({ id: 'sse-1', userId: 'user-a' });
        publishNotification(dto);
        const chunk = await readNext(reader);
        expect(chunk).toContain('event: notification');
        expect(chunk).toContain('"id":"sse-1"');
        expect(chunk).toContain('"userId":"user-a"');
    });
    it('does not deliver events for other users', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 60_000,
        });
        reader = stream.getReader();
        await readNext(reader); // ready
        publishNotification(makeDto({ id: 'wrong', userId: 'user-b' }));
        publishNotification(makeDto({ id: 'right', userId: 'user-a' }));
        const chunk = await readNext(reader);
        expect(chunk).toContain('"id":"right"');
        expect(chunk).not.toContain('"id":"wrong"');
    });
    it('emits heartbeats on interval', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 20,
            maxLifetimeMs: 60_000,
        });
        reader = stream.getReader();
        await readNext(reader); // ready
        const heartbeat = await readNext(reader, 500);
        expect(heartbeat).toBe(': heartbeat\n\n');
    });
    it('closes the stream after maxLifetimeMs and tears down the bus subscription', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 20,
        });
        reader = stream.getReader();
        await readNext(reader); // ready
        expect(notificationSubscriberCount('user-a')).toBe(1);
        // Wait for the lifetime timer to fire.
        const result = await reader.read();
        expect(result.done).toBe(true);
        // After the stream closes the cancel() handler must run and
        // drop the subscription — otherwise we leak listener refs over
        // the process lifetime.
        expect(notificationSubscriberCount('user-a')).toBe(0);
        reader = undefined;
    });
    it('removes the bus subscription on cancel() (client disconnect)', async () => {
        const stream = createNotificationStream('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 60_000,
        });
        reader = stream.getReader();
        await readNext(reader); // ready
        expect(notificationSubscriberCount('user-a')).toBe(1);
        await reader.cancel();
        reader = undefined;
        expect(notificationSubscriberCount('user-a')).toBe(0);
    });
    it('rejects an empty userId at stream creation', () => {
        expect(() => createNotificationStream('')).toThrow();
    });
});
describe('createNotificationStreamResponse', () => {
    it('wraps the stream with text/event-stream headers + 200', async () => {
        const res = createNotificationStreamResponse('user-a', {
            heartbeatMs: 60_000,
            maxLifetimeMs: 60_000,
        });
        expect(res.status).toBe(200);
        expect(res.headers.get('content-type')).toBe(NOTIFICATION_SSE_HEADERS['Content-Type']);
        expect(res.headers.get('cache-control')).toBe(NOTIFICATION_SSE_HEADERS['Cache-Control']);
        expect(res.headers.get('x-accel-buffering')).toBe(NOTIFICATION_SSE_HEADERS['X-Accel-Buffering']);
        // Drain the body so the underlying stream tears down cleanly.
        await res.body?.cancel();
    });
});
describe('encodeSseEvent (notification stream copy)', () => {
    // The notification stream re-exports its own encoder so callers
    // don't take a cross-module dep on the preview stream's copy.
    // Verify the contract stays in lock-step.
    it('serializes JSON payloads with an event tag', () => {
        expect(encodeSseEvent({ id: 'x' }, 'notification')).toBe('event: notification\ndata: {"id":"x"}\n\n');
    });
    it('splits multi-line strings across data lines', () => {
        expect(encodeSseEvent('one\ntwo')).toBe('data: one\ndata: two\n\n');
    });
});
//# sourceMappingURL=notification-stream.test.js.map