diff --git a/packages/player/package.json b/packages/player/package.json index 8abafdc..2d5b547 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -18,7 +18,7 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap", "dev": "tsup src/index.ts --format esm,cjs --dts --watch", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run --passWithNoTests" }, "dependencies": { "@scormflow/sdk": "workspace:*" diff --git a/packages/react/package.json b/packages/react/package.json index e9f4ad3..528bf53 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -18,7 +18,7 @@ "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap --external react", "dev": "tsup src/index.ts --format esm,cjs --dts --watch --external react", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run --passWithNoTests" }, "peerDependencies": { "react": ">=18", diff --git a/packages/sdk/src/errors.test.ts b/packages/sdk/src/errors.test.ts new file mode 100644 index 0000000..afa96fd --- /dev/null +++ b/packages/sdk/src/errors.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { + ScormError, + ScormHttpError, + ScormNetworkError, + ScormTimeoutError, + ScormAbortError, + isRetryableStatus, +} from './errors.js'; + +describe('ScormError', () => { + it('is an Error subclass with the correct name', () => { + const e = new ScormError('boom'); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe('ScormError'); + expect(e.message).toBe('boom'); + }); + + it('attaches the cause when provided', () => { + const cause = new Error('underlying'); + const e = new ScormError('boom', { cause }); + expect((e as Error & { cause?: unknown }).cause).toBe(cause); + }); +}); + +describe('ScormHttpError', () => { + it('lifts code, requestId, and details from the response body', () => { + const e = new ScormHttpError( + 404, + { code: 'course_not_found', message: 'no such course', requestId: 'req-1', details: { id: 'x' } }, + 'fallback', + { code: 'course_not_found' }, + ); + expect(e.status).toBe(404); + expect(e.code).toBe('course_not_found'); + expect(e.message).toBe('no such course'); + expect(e.requestId).toBe('req-1'); + expect(e.details).toEqual({ id: 'x' }); + expect(e.name).toBe('ScormHttpError'); + }); + + it('falls back to fallbackMessage and `http_` code when body is missing fields', () => { + const e = new ScormHttpError(500, undefined, 'POST /x → 500'); + expect(e.code).toBe('http_500'); + expect(e.message).toBe('POST /x → 500'); + expect(e.requestId).toBeUndefined(); + expect(e.details).toBeUndefined(); + }); + + it('preserves the raw body for inspection', () => { + const raw = { code: 'oops', extra: 'field' }; + const e = new ScormHttpError(500, raw, 'fallback', raw); + expect(e.body).toBe(raw); + }); +}); + +describe('ScormNetworkError', () => { + it('captures the original cause', () => { + const cause = new TypeError('fetch failed'); + const e = new ScormNetworkError('network down', cause); + expect(e.name).toBe('ScormNetworkError'); + expect((e as Error & { cause?: unknown }).cause).toBe(cause); + }); + + it('works without a cause', () => { + const e = new ScormNetworkError('network down'); + expect((e as Error & { cause?: unknown }).cause).toBeUndefined(); + }); +}); + +describe('ScormTimeoutError', () => { + it('reports the timeout in the message and exposes timeoutMs', () => { + const e = new ScormTimeoutError(5000); + expect(e.name).toBe('ScormTimeoutError'); + expect(e.timeoutMs).toBe(5000); + expect(e.message).toMatch(/5000ms/); + }); +}); + +describe('ScormAbortError', () => { + it('has a fixed message and name', () => { + const e = new ScormAbortError(); + expect(e.name).toBe('ScormAbortError'); + expect(e.message).toBe('Request was aborted'); + }); +}); + +describe('isRetryableStatus', () => { + it('marks 408, 429, and 5xx as retryable', () => { + expect(isRetryableStatus(408)).toBe(true); + expect(isRetryableStatus(429)).toBe(true); + expect(isRetryableStatus(500)).toBe(true); + expect(isRetryableStatus(503)).toBe(true); + expect(isRetryableStatus(599)).toBe(true); + }); + + it('marks 2xx, 3xx, and other 4xx as non-retryable', () => { + expect(isRetryableStatus(200)).toBe(false); + expect(isRetryableStatus(301)).toBe(false); + expect(isRetryableStatus(400)).toBe(false); + expect(isRetryableStatus(401)).toBe(false); + expect(isRetryableStatus(404)).toBe(false); + expect(isRetryableStatus(422)).toBe(false); + }); +}); diff --git a/packages/sdk/src/http/retry.test.ts b/packages/sdk/src/http/retry.test.ts new file mode 100644 index 0000000..b8fdf12 --- /dev/null +++ b/packages/sdk/src/http/retry.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { computeBackoff, parseRetryAfter, sleep } from './retry.js'; + +describe('computeBackoff', () => { + it('grows exponentially up to maxMs', () => { + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 1, jitter: false })).toBe(100); + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 2, jitter: false })).toBe(200); + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 3, jitter: false })).toBe(400); + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 4, jitter: false })).toBe(800); + }); + + it('caps growth at maxMs', () => { + expect(computeBackoff({ baseMs: 1000, maxMs: 2000, attempt: 5, jitter: false })).toBe(2000); + }); + + it('uses the injected random fn when jitter is on', () => { + const value = computeBackoff({ + baseMs: 100, + maxMs: 10_000, + attempt: 4, + jitter: true, + random: () => 0.5, + }); + expect(value).toBe(400); + }); + + it('floors jittered values', () => { + const value = computeBackoff({ + baseMs: 100, + maxMs: 10_000, + attempt: 1, + jitter: true, + random: () => 0.999, + }); + expect(Number.isInteger(value)).toBe(true); + }); + + it('treats attempt <= 0 as the first attempt', () => { + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: 0, jitter: false })).toBe(100); + expect(computeBackoff({ baseMs: 100, maxMs: 10_000, attempt: -3, jitter: false })).toBe(100); + }); +}); + +describe('parseRetryAfter', () => { + it('returns null for missing header', () => { + expect(parseRetryAfter(null)).toBeNull(); + }); + + it('parses seconds as a number', () => { + expect(parseRetryAfter('5')).toBe(5000); + expect(parseRetryAfter('0')).toBe(0); + }); + + it('parses HTTP-date and returns milliseconds from now', () => { + const future = new Date('2030-01-01T00:00:30Z').toUTCString(); + const result = parseRetryAfter(future, () => Date.parse('2030-01-01T00:00:00Z')); + expect(result).toBe(30_000); + }); + + it('clamps past dates to 0', () => { + const past = new Date('2000-01-01T00:00:00Z').toUTCString(); + const result = parseRetryAfter(past, () => Date.parse('2030-01-01T00:00:00Z')); + expect(result).toBe(0); + }); + + it('returns null for garbage', () => { + expect(parseRetryAfter('not-a-date-or-number')).toBeNull(); + }); +}); + +describe('sleep', () => { + it('resolves after the given delay', async () => { + const start = Date.now(); + await sleep(20); + expect(Date.now() - start).toBeGreaterThanOrEqual(15); + }); + + it('resolves immediately for non-positive delays', async () => { + const start = Date.now(); + await sleep(0); + await sleep(-100); + expect(Date.now() - start).toBeLessThan(10); + }); + + it('rejects when the signal is aborted mid-sleep', async () => { + const controller = new AbortController(); + const p = sleep(1000, controller.signal); + controller.abort(); + await expect(p).rejects.toBeDefined(); + }); + + it('rejects immediately when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + await expect(sleep(1000, controller.signal)).rejects.toBeDefined(); + }); +});