import { describe, expect, it, vi } from 'vitest'; import { ArenaApiError, ArenaSessionManager } from '../src/arena/session.js'; import { loadConfig } from '../src/config.js'; import { HealthRegistry, type LoginHealthSink } from '../src/health/registry.js'; import type { HttpClient, HttpRequest, HttpResponse } from '../src/http/types.js'; function makeManager(overrides?: { sessionTtlMs?: number; now?: () => number; health?: LoginHealthSink; }): { manager: ArenaSessionManager; request: ReturnType } { const request = vi.fn(); const http: HttpClient = { request: request as HttpClient['request'] }; const manager = new ArenaSessionManager({ http, baseUrl: 'https://arena.invalid/v1', email: 'worker@example.test', password: 'secret', workspaceId: 123, sessionTtlMs: 24 * 60_000, ...overrides, }); return { manager, request }; } function loginOk(sessionId: string): HttpResponse { return { status: 200, body: { arenaSessionId: sessionId } }; } describe('ArenaSessionManager', () => { it('coalesces concurrent callers onto a single in-flight login', async () => { const { manager, request } = makeManager(); let resolveLogin!: (res: HttpResponse) => void; request.mockImplementationOnce( () => new Promise((resolve) => { resolveLogin = resolve; }), ); const pending = Promise.all([manager.getSessionId(), manager.getSessionId(), manager.getSessionId()]); resolveLogin(loginOk('sess-1')); const ids = await pending; expect(ids).toEqual(['sess-1', 'sess-1', 'sess-1']); expect(request).toHaveBeenCalledTimes(1); const loginReq = request.mock.calls[0]?.[0] as HttpRequest; expect(loginReq.method).toBe('POST'); expect(loginReq.url).toBe('https://arena.invalid/v1/login'); expect(loginReq.body).toEqual({ email: 'worker@example.test', password: 'secret', workspaceId: 123, }); }); it('reuses the cached session until the TTL and re-logs in after expiry', async () => { let nowMs = 0; const sessionTtlMs = 24 * 60_000; const { manager, request } = makeManager({ sessionTtlMs, now: () => nowMs }); request.mockResolvedValueOnce(loginOk('sess-1')); expect(manager.state()).toBe('none'); expect(await manager.getSessionId()).toBe('sess-1'); expect(await manager.getSessionId()).toBe('sess-1'); expect(request).toHaveBeenCalledTimes(1); expect(manager.state()).toBe('active'); nowMs = sessionTtlMs + 1; expect(manager.state()).toBe('expired'); request.mockResolvedValueOnce(loginOk('sess-2')); expect(await manager.getSessionId()).toBe('sess-2'); expect(request).toHaveBeenCalledTimes(2); expect(manager.state()).toBe('active'); }); it('wires ARENA_SESSION_TTL_MINUTES through loadConfig to the effective TTL (was silently dropped in 0.1.0)', async () => { const config = loadConfig({ ARENA_SESSION_TTL_MINUTES: '1' }); let nowMs = 0; const request = vi.fn().mockResolvedValue(loginOk('sess-1')); // Same spread the composition root uses: ArenaSessionOptions is a Pick of // ArenaConfig, so a field-name drift would fail to compile here. const { httpTimeoutMs: _httpTimeoutMs, ...sessionConfig } = config.arena; const manager = new ArenaSessionManager({ http: { request: request as HttpClient['request'] }, now: () => nowMs, ...sessionConfig, }); expect(manager.sessionTtlMs).toBe(60_000); await manager.getSessionId(); nowMs = 59_999; expect(manager.state()).toBe('active'); nowMs = 60_000; expect(manager.state()).toBe('expired'); }); it('rejects a non-positive sessionTtlMs at construction', () => { expect(() => makeManager({ sessionTtlMs: 0 })).toThrow(/sessionTtlMs/); }); it('reports state "refreshing" while a login is in flight, then "active"', async () => { const { manager, request } = makeManager(); let resolveLogin!: (res: HttpResponse) => void; request.mockImplementationOnce( () => new Promise((resolve) => { resolveLogin = resolve; }), ); expect(manager.state()).toBe('none'); const pending = manager.getSessionId(); expect(manager.state()).toBe('refreshing'); resolveLogin(loginOk('sess-1')); await pending; expect(manager.state()).toBe('active'); }); it('reports "refreshing" during a re-login after expiry (refreshing wins over expired)', async () => { let nowMs = 0; const { manager, request } = makeManager({ sessionTtlMs: 1000, now: () => nowMs }); request.mockResolvedValueOnce(loginOk('sess-1')); await manager.getSessionId(); nowMs = 5000; expect(manager.state()).toBe('expired'); let resolveLogin!: (res: HttpResponse) => void; request.mockImplementationOnce( () => new Promise((resolve) => { resolveLogin = resolve; }), ); const pending = manager.getSessionId(); expect(manager.state()).toBe('refreshing'); resolveLogin(loginOk('sess-2')); await pending; expect(manager.state()).toBe('active'); }); it('reports login outcomes to the health sink (degraded on failure, ok again on success)', async () => { const health = new HealthRegistry(); const { manager, request } = makeManager({ health }); expect(health.status()).toBe('ok'); request.mockResolvedValueOnce({ status: 503, body: 'upstream down' }); await expect(manager.getSessionId()).rejects.toBeInstanceOf(ArenaApiError); expect(health.status()).toBe('degraded'); request.mockResolvedValueOnce(loginOk('sess-1')); await manager.getSessionId(); expect(health.status()).toBe('ok'); }); it('reports a transport error (network/timeout) as a login failure too', async () => { const health = new HealthRegistry(); const { manager, request } = makeManager({ health }); request.mockRejectedValueOnce(new Error('TimeoutError')); await expect(manager.getSessionId()).rejects.toThrow('TimeoutError'); expect(health.status()).toBe('degraded'); expect(manager.state()).toBe('none'); }); it('withSession re-logs in and retries exactly once on 401', async () => { const { manager, request } = makeManager(); request.mockResolvedValueOnce(loginOk('sess-1')).mockResolvedValueOnce(loginOk('sess-2')); const fn = vi .fn() .mockRejectedValueOnce(new ArenaApiError('unauthorized', 401)) .mockResolvedValueOnce('ok'); const result = await manager.withSession(fn as (sessionId: string) => Promise); expect(result).toBe('ok'); expect(fn.mock.calls.map((call) => call[0])).toEqual(['sess-1', 'sess-2']); expect(request).toHaveBeenCalledTimes(2); }); it('withSession does not retry more than once on repeated 401', async () => { const { manager, request } = makeManager(); request.mockResolvedValueOnce(loginOk('sess-1')).mockResolvedValueOnce(loginOk('sess-2')); const fn = vi.fn().mockRejectedValue(new ArenaApiError('unauthorized', 401)); await expect(manager.withSession(fn as (sessionId: string) => Promise)).rejects.toBeInstanceOf( ArenaApiError, ); expect(fn).toHaveBeenCalledTimes(2); expect(request).toHaveBeenCalledTimes(2); }); it('withSession propagates non-401 errors without re-login', async () => { const { manager, request } = makeManager(); request.mockResolvedValueOnce(loginOk('sess-1')); const fn = vi.fn().mockRejectedValue(new ArenaApiError('server error', 500)); await expect(manager.withSession(fn as (sessionId: string) => Promise)).rejects.toThrow( 'server error', ); expect(fn).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledTimes(1); }); it('rejects all coalesced callers on login failure and allows a later retry', async () => { const { manager, request } = makeManager(); request.mockResolvedValueOnce({ status: 401, body: { errors: [{ message: 'bad creds' }] } }); const results = await Promise.allSettled([manager.getSessionId(), manager.getSessionId()]); expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']); expect(request).toHaveBeenCalledTimes(1); // The promise lock must clear so a subsequent call can log in again. expect(manager.state()).toBe('none'); request.mockResolvedValueOnce(loginOk('sess-1')); expect(await manager.getSessionId()).toBe('sess-1'); expect(request).toHaveBeenCalledTimes(2); }); it('rejects a login response without arenaSessionId', async () => { const { manager, request } = makeManager(); request.mockResolvedValueOnce({ status: 200, body: {} }); await expect(manager.getSessionId()).rejects.toThrow('no arenaSessionId'); expect(manager.state()).toBe('none'); }); });