233 lines
8.7 KiB
TypeScript
233 lines
8.7 KiB
TypeScript
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<typeof vi.fn> } {
|
|
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<HttpResponse>((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<HttpResponse>((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<HttpResponse>((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<string>);
|
|
|
|
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<never>)).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<never>)).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');
|
|
});
|
|
});
|