import { describe, expect, it } from 'bun:test'; import { exchangeWebAppSession, WebAppSessionError, type WebAppSessionFetch } from '../web-app-session.service'; const okResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' }, }); const validBody = (overrides: Record = {}): Record => { const body: Record = { token: 'jwt-web', refreshToken: 'grant-1', expiresIn: 900, expiresAt: new Date(Date.now() + 900 * 1000).toISOString(), deadlineInSeconds: 3600, }; for (const [key, value] of Object.entries(overrides)) { body[key] = value; } return body; }; const expectThrown = async (paramsOverrides: { code?: string; refreshToken?: string; fetch: WebAppSessionFetch; }): Promise => { let caught: unknown; try { await exchangeWebAppSession({ urlId: 'lobby-dog', sessionBaseUrl: 'http://localhost:14080', code: paramsOverrides.code, refreshToken: paramsOverrides.refreshToken, fetch: paramsOverrides.fetch, }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(WebAppSessionError); return caught as WebAppSessionError; }; describe('exchangeWebAppSession', () => { it('mints a session from a claim code on 200', async () => { const calls: Array<{ url: string; init?: { method?: string; body?: string } }> = []; const fakeFetch: WebAppSessionFetch = (input, init) => { calls.push({ url: input, init }); return Promise.resolve(okResponse(validBody())); }; const beforeMs = Date.now(); const session = await exchangeWebAppSession({ urlId: 'lobby-dog', sessionBaseUrl: 'http://localhost:14080', code: 'abc12345', fetch: fakeFetch, }); const afterMs = Date.now(); expect(session.token).toBe('jwt-web'); expect(session.refreshToken).toBe('grant-1'); expect(session.expiresIn).toBe(900); expect(calls).toHaveLength(1); expect(calls[0]?.url).toBe('http://localhost:14080/api/v1/web-sessions'); expect(calls[0]?.init?.method).toBe('POST'); expect(calls[0]?.init?.body).toBe(JSON.stringify({ urlId: 'lobby-dog', code: 'abc12345' })); // Deadlines are computed at response receipt from the relative seconds — // phone clocks are skewed, so server timestamps never drive scheduling. expect(session.deadlineMs).toBeGreaterThanOrEqual(beforeMs + 900 * 1000); expect(session.deadlineMs).toBeLessThanOrEqual(afterMs + 900 * 1000); expect(session.sessionDeadlineMs).toBeGreaterThanOrEqual(beforeMs + 3600 * 1000); expect(session.sessionDeadlineMs).toBeLessThanOrEqual(afterMs + 3600 * 1000); }); it('throws on a 200 body missing deadlineInSeconds', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ deadlineInSeconds: undefined }))); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.message).toContain('unexpected payload shape'); }); it('throws on a malformed deadlineInSeconds', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ deadlineInSeconds: 'soon' }))); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.message).toContain('unexpected payload shape'); }); it('renews a session from a refresh token on 200', async () => { const calls: Array<{ init?: { body?: string } }> = []; const fakeFetch: WebAppSessionFetch = (_, init) => { calls.push({ init }); return Promise.resolve(okResponse(validBody({ refreshToken: 'grant-2' }))); }; const session = await exchangeWebAppSession({ urlId: 'lobby-dog', sessionBaseUrl: 'http://localhost:14080', refreshToken: 'grant-1', fetch: fakeFetch, }); expect(calls[0]?.init?.body).toBe(JSON.stringify({ urlId: 'lobby-dog', refreshToken: 'grant-1' })); expect(session.refreshToken).toBe('grant-2'); }); it('computes the deadline from expiresIn even when expiresAt reflects a skewed server clock', async () => { // A server clock 3 hours ahead of the phone: expiresAt is useless for // scheduling, expiresIn is not. const skewedExpiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(); const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ expiresIn: 900, expiresAt: skewedExpiresAt }))); const beforeMs = Date.now(); const session = await exchangeWebAppSession({ urlId: 'lobby-dog', sessionBaseUrl: 'http://core', code: 'abc12345', fetch: fakeFetch, }); expect(session.expiresAt).toBe(skewedExpiresAt); expect(session.deadlineMs).toBeGreaterThanOrEqual(beforeMs + 900 * 1000); expect(session.deadlineMs).toBeLessThan(beforeMs + 900 * 1000 + 5_000); // NOT anywhere near the skewed server timestamp. expect(session.deadlineMs).toBeLessThan(Date.parse(skewedExpiresAt) - 60 * 60 * 1000); }); it('classifies 401 as terminal (invalid/expired code or grant)', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('unauthorized', { status: 401 })); const error = await expectThrown({ code: 'stale-code', fetch: fakeFetch }); expect(error.status).toBe(401); expect(error.terminal).toBe(true); }); it('classifies 404 as terminal (unknown urlId)', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('not found', { status: 404 })); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.status).toBe(404); expect(error.terminal).toBe(true); }); it('classifies 429 as retryable', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('slow down', { status: 429 })); const error = await expectThrown({ refreshToken: 'grant-1', fetch: fakeFetch }); expect(error.status).toBe(429); expect(error.terminal).toBe(false); }); it('classifies 5xx as retryable', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(new Response('boom', { status: 503 })); const error = await expectThrown({ refreshToken: 'grant-1', fetch: fakeFetch }); expect(error.status).toBe(503); expect(error.terminal).toBe(false); expect(error.message).toContain('HTTP 503'); }); it('classifies network failures as retryable', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.reject(new Error('econnrefused')); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.terminal).toBe(false); expect(error.message).toContain('econnrefused'); }); it('throws on a 200 body missing refreshToken', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ refreshToken: undefined }))); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.message).toContain('unexpected payload shape'); }); it('throws on a 200 body with a non-numeric expiresIn', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ expiresIn: '900' }))); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.message).toContain('unexpected payload shape'); }); it('throws on a 200 body with a non-positive expiresIn', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody({ expiresIn: 0 }))); const error = await expectThrown({ code: 'abc12345', fetch: fakeFetch }); expect(error.message).toContain('invalid expiresIn'); }); it('requires exactly one of code / refreshToken', async () => { const fakeFetch: WebAppSessionFetch = () => Promise.resolve(okResponse(validBody())); const neither = await expectThrown({ fetch: fakeFetch }); expect(neither.message).toContain('either code or refreshToken required'); const both = await expectThrown({ code: 'abc12345', refreshToken: 'grant-1', fetch: fakeFetch }); expect(both.message).toContain('mutually exclusive'); }); });