Files
arena-plm-fiori/worker/test/session.test.ts
T
2026-09-16 01:47:28 -07:00

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');
});
});