diff --git a/tests/unit/auth-config.spec.ts b/tests/unit/auth-config.spec.ts deleted file mode 100644 index 24fb496..0000000 --- a/tests/unit/auth-config.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; - -const ORIGINAL_BASE_URL = process.env.BASE_URL; -const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET; -const ORIGINAL_USE_ANON = process.env.USE_ANONYMOUS_AUTH_SESSIONS; -const ORIGINAL_GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID; -const ORIGINAL_GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET; - -function restoreEnv() { - if (ORIGINAL_BASE_URL === undefined) delete process.env.BASE_URL; - else process.env.BASE_URL = ORIGINAL_BASE_URL; - - if (ORIGINAL_AUTH_SECRET === undefined) delete process.env.AUTH_SECRET; - else process.env.AUTH_SECRET = ORIGINAL_AUTH_SECRET; - - if (ORIGINAL_USE_ANON === undefined) delete process.env.USE_ANONYMOUS_AUTH_SESSIONS; - else process.env.USE_ANONYMOUS_AUTH_SESSIONS = ORIGINAL_USE_ANON; - - if (ORIGINAL_GITHUB_CLIENT_ID === undefined) delete process.env.GITHUB_CLIENT_ID; - else process.env.GITHUB_CLIENT_ID = ORIGINAL_GITHUB_CLIENT_ID; - - if (ORIGINAL_GITHUB_CLIENT_SECRET === undefined) delete process.env.GITHUB_CLIENT_SECRET; - else process.env.GITHUB_CLIENT_SECRET = ORIGINAL_GITHUB_CLIENT_SECRET; -} - -function setAuthEnabledEnv() { - process.env.BASE_URL = 'http://localhost:3003'; - process.env.AUTH_SECRET = 'test-secret'; -} - -test.describe('auth config anonymous-session flag', () => { - test.afterEach(() => { - restoreEnv(); - }); - - test('returns false when auth is disabled', () => { - delete process.env.BASE_URL; - delete process.env.AUTH_SECRET; - process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true'; - - expect(isAnonymousAuthSessionsEnabled()).toBe(false); - }); - - test('defaults to false when env var is unset', () => { - setAuthEnabledEnv(); - delete process.env.USE_ANONYMOUS_AUTH_SESSIONS; - - expect(isAnonymousAuthSessionsEnabled()).toBe(false); - }); - - test('returns true only when env var is true', () => { - setAuthEnabledEnv(); - process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true'; - - expect(isAnonymousAuthSessionsEnabled()).toBe(true); - }); - - test('returns false when env var is false', () => { - setAuthEnabledEnv(); - process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'false'; - - expect(isAnonymousAuthSessionsEnabled()).toBe(false); - }); - - test('falls back to false for invalid values', () => { - setAuthEnabledEnv(); - process.env.USE_ANONYMOUS_AUTH_SESSIONS = '1'; - - expect(isAnonymousAuthSessionsEnabled()).toBe(false); - }); -}); - -test.describe('auth config github-auth flag', () => { - test.afterEach(() => { - restoreEnv(); - }); - - test('returns false when auth is disabled', () => { - delete process.env.BASE_URL; - delete process.env.AUTH_SECRET; - process.env.GITHUB_CLIENT_ID = 'some-id'; - process.env.GITHUB_CLIENT_SECRET = 'some-secret'; - - expect(isGithubAuthEnabled()).toBe(false); - }); - - test('returns false when GITHUB_CLIENT_ID is missing', () => { - setAuthEnabledEnv(); - delete process.env.GITHUB_CLIENT_ID; - process.env.GITHUB_CLIENT_SECRET = 'some-secret'; - - expect(isGithubAuthEnabled()).toBe(false); - }); - - test('returns false when GITHUB_CLIENT_SECRET is missing', () => { - setAuthEnabledEnv(); - process.env.GITHUB_CLIENT_ID = 'some-id'; - delete process.env.GITHUB_CLIENT_SECRET; - - expect(isGithubAuthEnabled()).toBe(false); - }); - - test('returns false when both GitHub env vars are missing', () => { - setAuthEnabledEnv(); - delete process.env.GITHUB_CLIENT_ID; - delete process.env.GITHUB_CLIENT_SECRET; - - expect(isGithubAuthEnabled()).toBe(false); - }); - - test('returns true when auth is enabled and both GitHub env vars are set', () => { - setAuthEnabledEnv(); - process.env.GITHUB_CLIENT_ID = 'some-id'; - process.env.GITHUB_CLIENT_SECRET = 'some-secret'; - - expect(isGithubAuthEnabled()).toBe(true); - }); -}); diff --git a/tests/unit/auth-config.vitest.spec.ts b/tests/unit/auth-config.vitest.spec.ts new file mode 100644 index 0000000..eac330d --- /dev/null +++ b/tests/unit/auth-config.vitest.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'vitest'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; +import { withEnv } from './support/env'; + +describe('auth config contract', () => { + test('auth is enabled only when AUTH_SECRET and BASE_URL are both set', async () => { + await withEnv( + { + AUTH_SECRET: undefined, + BASE_URL: undefined, + }, + async () => { + expect(isAuthEnabled()).toBe(false); + expect(getAuthBaseUrl()).toBeNull(); + }, + ); + + await withEnv( + { + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + }, + async () => { + expect(isAuthEnabled()).toBe(true); + expect(getAuthBaseUrl()).toBe('http://localhost:3003'); + }, + ); + }); + + test.each([ + { envValue: undefined, expected: false }, + { envValue: 'true', expected: true }, + { envValue: 'false', expected: false }, + { envValue: '1', expected: false }, + { envValue: 'TRUE', expected: true }, + ])( + 'anonymous sessions honor strict boolean parsing when auth is enabled (value: $envValue)', + async ({ envValue, expected }) => { + await withEnv( + { + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + USE_ANONYMOUS_AUTH_SESSIONS: envValue, + }, + async () => { + expect(isAnonymousAuthSessionsEnabled()).toBe(expected); + }, + ); + }, + ); + + test('anonymous sessions are always disabled when auth is disabled', async () => { + await withEnv( + { + AUTH_SECRET: undefined, + BASE_URL: undefined, + USE_ANONYMOUS_AUTH_SESSIONS: 'true', + }, + async () => { + expect(isAnonymousAuthSessionsEnabled()).toBe(false); + }, + ); + }); + + test.each([ + { + title: 'returns false when auth is disabled', + env: { + AUTH_SECRET: undefined, + BASE_URL: undefined, + GITHUB_CLIENT_ID: 'id', + GITHUB_CLIENT_SECRET: 'secret', + }, + expected: false, + }, + { + title: 'returns false when GitHub client id is missing', + env: { + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + GITHUB_CLIENT_ID: undefined, + GITHUB_CLIENT_SECRET: 'secret', + }, + expected: false, + }, + { + title: 'returns false when GitHub client secret is missing', + env: { + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + GITHUB_CLIENT_ID: 'id', + GITHUB_CLIENT_SECRET: undefined, + }, + expected: false, + }, + { + title: 'returns true when auth is enabled and GitHub credentials are present', + env: { + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + GITHUB_CLIENT_ID: 'id', + GITHUB_CLIENT_SECRET: 'secret', + }, + expected: true, + }, + ])('$title', async ({ env, expected }) => { + await withEnv(env, async () => { + expect(isGithubAuthEnabled()).toBe(expected); + }); + }); +}); diff --git a/tests/unit/rate-limit-runtime-settings.spec.ts b/tests/unit/rate-limit-runtime-settings.vitest.spec.ts similarity index 76% rename from tests/unit/rate-limit-runtime-settings.spec.ts rename to tests/unit/rate-limit-runtime-settings.vitest.spec.ts index df7748d..b39f3e9 100644 --- a/tests/unit/rate-limit-runtime-settings.spec.ts +++ b/tests/unit/rate-limit-runtime-settings.vitest.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { describe, expect, test } from 'vitest'; import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings'; -test.describe('TTS rate limit runtime config seeds', () => { +describe('TTS rate limit runtime config seeds', () => { test('defaults disable TTS daily rate limiting', () => { expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true); }); @@ -12,6 +12,9 @@ test.describe('TTS rate limit runtime config seeds', () => { expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('false')).toBe(false); expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('1')).toBe(true); expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('0')).toBe(false); + expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('on')).toBe(true); + expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('no')).toBe(false); + expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('invalid')).toBeUndefined(); }); test('daily limit values are runtime-only (no env seed vars)', () => { diff --git a/tests/unit/server-error-contract.spec.ts b/tests/unit/server-error-contract.vitest.spec.ts similarity index 62% rename from tests/unit/server-error-contract.spec.ts rename to tests/unit/server-error-contract.vitest.spec.ts index 84307d7..e489af3 100644 --- a/tests/unit/server-error-contract.spec.ts +++ b/tests/unit/server-error-contract.vitest.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { describe, expect, test } from 'vitest'; import { ServerAppError, createServerAppError, @@ -7,10 +7,12 @@ import { toApiErrorBody, toHttpStatus, } from '../../src/lib/server/errors/contract'; +import { makeServerAppErrorInput } from './support/factories'; -test.describe('server error contract', () => { +describe('server error contract', () => { test('normalizes unknown throwable to fallback shape', () => { const normalized = normalizeServerError('boom'); + expect(normalized.code).toBe('UNKNOWN_SERVER_ERROR'); expect(normalized.errorClass).toBe('unknown'); expect(normalized.httpStatus).toBe(500); @@ -19,15 +21,17 @@ test.describe('server error contract', () => { }); test('preserves ServerAppError metadata', () => { - const err = createServerAppError({ + const err = createServerAppError(makeServerAppErrorInput({ code: 'USER_PROGRESS_UPDATE_FAILED', message: 'Failed to update progress', errorClass: 'db', retryable: true, httpStatus: 500, details: { operation: 'update_progress' }, - }); + })); + const normalized = normalizeServerError(err); + expect(isServerAppError(err)).toBe(true); expect(normalized.code).toBe('USER_PROGRESS_UPDATE_FAILED'); expect(normalized.errorClass).toBe('db'); @@ -36,6 +40,22 @@ test.describe('server error contract', () => { expect(normalized.details?.operation).toBe('update_progress'); }); + test('normalizes partially-shaped thrown errors with code sanitization', () => { + const thrown = Object.assign(new Error('Timed out waiting for storage read'), { + code: 'not_valid_code', + errorClass: 'timeout', + httpStatus: 604, + retryable: true, + }); + + const normalized = normalizeServerError(thrown); + + expect(normalized.code).toBe('UNKNOWN_SERVER_ERROR'); + expect(normalized.errorClass).toBe('timeout'); + expect(normalized.httpStatus).toBe(599); + expect(normalized.retryable).toBe(true); + }); + test('maps normalized errors to API body + status', () => { const normalized = normalizeServerError( new ServerAppError({ @@ -44,7 +64,9 @@ test.describe('server error contract', () => { errorClass: 'upstream', }), ); + const body = toApiErrorBody(normalized, { includeDetails: false }); + expect(body).toEqual({ error: 'Upstream failure', errorCode: 'UPSTREAM_TTS_ERROR', @@ -52,4 +74,12 @@ test.describe('server error contract', () => { }); expect(toHttpStatus(normalized)).toBe(502); }); + + test('rejects invalid server error code at creation boundary', () => { + expect(() => createServerAppError(makeServerAppErrorInput({ + code: 'bad-code', + message: 'Invalid', + errorClass: 'validation', + }))).toThrow(/Invalid ServerAppError code/i); + }); }); diff --git a/tests/unit/server-error-response.spec.ts b/tests/unit/server-error-response.spec.ts deleted file mode 100644 index a5ed779..0000000 --- a/tests/unit/server-error-response.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { errorResponse } from '../../src/lib/server/errors/next-response'; -import { createServerAppError } from '../../src/lib/server/errors/contract'; - -test.describe('server error response helper', () => { - test('returns mapped 4xx response with explicit app code', async () => { - const response = errorResponse( - createServerAppError({ - code: 'AUTH_UNAUTHORIZED', - message: 'Unauthorized', - errorClass: 'auth', - httpStatus: 401, - retryable: false, - }), - { - apiErrorMessage: 'Unauthorized', - }, - ); - - expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ - error: 'Unauthorized', - errorCode: 'AUTH_UNAUTHORIZED', - retryable: false, - }); - }); - - test('normalizes unknown errors to safe 500 without stack leakage', async () => { - const response = errorResponse(new Error('sensitive internal details'), { - apiErrorMessage: 'Internal Server Error', - normalize: { code: 'UNKNOWN_SERVER_ERROR', errorClass: 'unknown' }, - }); - const body = await response.json(); - expect(response.status).toBe(500); - expect(body).toEqual({ - error: 'Internal Server Error', - errorCode: 'UNKNOWN_SERVER_ERROR', - retryable: false, - }); - expect(JSON.stringify(body)).not.toContain('stack'); - expect(JSON.stringify(body)).not.toContain('cause'); - }); -}); diff --git a/tests/unit/server-error-response.vitest.spec.ts b/tests/unit/server-error-response.vitest.spec.ts new file mode 100644 index 0000000..5bfba1d --- /dev/null +++ b/tests/unit/server-error-response.vitest.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'vitest'; +import { createServerAppError } from '../../src/lib/server/errors/contract'; +import { asRouteErrorContext, errorResponse, withErrorBoundary } from '../../src/lib/server/errors/next-response'; +import { createServerLoggerStub } from './support/server-stubs'; +import { makeServerAppErrorInput } from './support/factories'; + +describe('server error response helper', () => { + test('returns mapped 4xx response with explicit app code', async () => { + const response = errorResponse( + createServerAppError(makeServerAppErrorInput({ + code: 'AUTH_UNAUTHORIZED', + message: 'Unauthorized', + errorClass: 'auth', + httpStatus: 401, + retryable: false, + })), + { + apiErrorMessage: 'Unauthorized', + }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: 'Unauthorized', + errorCode: 'AUTH_UNAUTHORIZED', + retryable: false, + }); + }); + + test('normalizes unknown errors to safe 500 without stack leakage', async () => { + const response = errorResponse(new Error('sensitive internal details'), { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'UNKNOWN_SERVER_ERROR', errorClass: 'unknown' }, + }); + const body = await response.json(); + expect(response.status).toBe(500); + expect(body).toEqual({ + error: 'Internal Server Error', + errorCode: 'UNKNOWN_SERVER_ERROR', + retryable: false, + }); + expect(JSON.stringify(body)).not.toContain('stack'); + expect(JSON.stringify(body)).not.toContain('cause'); + }); + + test('withErrorBoundary returns normalized error response and emits a structured log', async () => { + const logger = createServerLoggerStub(); + const handler = withErrorBoundary( + async () => { + throw new Error('storage dependency timed out'); + }, + { + route: '/api/unit-test', + logger: logger as never, + event: 'unit_test.route.failed', + msg: 'Route failed', + normalize: { + code: 'DOCUMENT_BLOB_FETCH_FAILED', + errorClass: 'storage', + }, + apiErrorMessage: 'Failed to fetch document blob', + }, + ); + + const response = await handler(); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body).toEqual({ + error: 'Failed to fetch document blob', + errorCode: 'DOCUMENT_BLOB_FETCH_FAILED', + retryable: true, + }); + expect(logger.error).toHaveBeenCalledTimes(1); + }); + + test('asRouteErrorContext derives method and request id consistently', () => { + const context = asRouteErrorContext({ + request: { method: 'PATCH' } as never, + route: '/api/runtime-config', + requestId: 'req-123', + }); + + expect(context).toEqual({ + route: '/api/runtime-config', + method: 'PATCH', + requestId: 'req-123', + }); + }); +}); diff --git a/tests/unit/server-route-error-mappings.spec.ts b/tests/unit/server-route-error-mappings.spec.ts deleted file mode 100644 index ac1a131..0000000 --- a/tests/unit/server-route-error-mappings.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { errorResponse } from '../../src/lib/server/errors/next-response'; -import { AdminProviderError } from '../../src/lib/server/admin/providers'; - -test.describe('route error mapping contract', () => { - test('admin provider validation errors map to 4xx via route normalize policy', async () => { - const adminError = new AdminProviderError('slug is required', 400); - const response = errorResponse(adminError, { - apiErrorMessage: adminError.message, - normalize: { - code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', - errorClass: 'validation', - httpStatus: adminError.status, - retryable: false, - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: 'slug is required', - errorCode: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', - retryable: false, - }); - }); - - test('documents storage failures map to retryable 503', async () => { - const response = errorResponse(new Error('blobstore timeout'), { - apiErrorMessage: 'Failed to fetch document blob', - normalize: { - code: 'DOCUMENT_BLOB_FETCH_FAILED', - errorClass: 'storage', - }, - }); - - expect(response.status).toBe(503); - await expect(response.json()).resolves.toEqual({ - error: 'Failed to fetch document blob', - errorCode: 'DOCUMENT_BLOB_FETCH_FAILED', - retryable: true, - }); - }); - - test('audiobook upstream failures map to retryable 502', async () => { - const response = errorResponse(new Error('provider overloaded'), { - apiErrorMessage: 'Failed to process audio chapter', - normalize: { - code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', - errorClass: 'upstream', - }, - }); - - expect(response.status).toBe(502); - await expect(response.json()).resolves.toEqual({ - error: 'Failed to process audio chapter', - errorCode: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', - retryable: true, - }); - }); - - test('user export auth initialization failure maps to 500 auth classification', async () => { - const response = errorResponse(new Error('Auth not initialized'), { - apiErrorMessage: 'Auth not initialized', - normalize: { - code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', - errorClass: 'auth', - httpStatus: 500, - retryable: false, - }, - }); - - expect(response.status).toBe(500); - await expect(response.json()).resolves.toEqual({ - error: 'Auth not initialized', - errorCode: 'USER_EXPORT_AUTH_NOT_INITIALIZED', - retryable: false, - }); - }); -}); diff --git a/tests/unit/server-route-error-mappings.vitest.spec.ts b/tests/unit/server-route-error-mappings.vitest.spec.ts new file mode 100644 index 0000000..6d5bb23 --- /dev/null +++ b/tests/unit/server-route-error-mappings.vitest.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'vitest'; +import { AdminProviderError } from '../../src/lib/server/admin/providers'; +import { errorResponse } from '../../src/lib/server/errors/next-response'; +import { makeServerErrorContext } from './support/factories'; + +describe('route error mapping contract', () => { + test('admin provider validation errors map to 4xx via route normalize policy', async () => { + const adminError = new AdminProviderError('slug is required', 400); + const response = errorResponse(adminError, { + apiErrorMessage: adminError.message, + normalize: makeServerErrorContext({ + code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + errorClass: 'validation', + httpStatus: adminError.status, + retryable: false, + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'slug is required', + errorCode: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + retryable: false, + }); + }); + + test.each([ + { + name: 'documents storage failures map to retryable 503', + thrown: new Error('blobstore timeout'), + apiErrorMessage: 'Failed to fetch document blob', + normalize: makeServerErrorContext({ + code: 'DOCUMENT_BLOB_FETCH_FAILED', + errorClass: 'storage', + }), + expectedStatus: 503, + expectedBody: { + error: 'Failed to fetch document blob', + errorCode: 'DOCUMENT_BLOB_FETCH_FAILED', + retryable: true, + }, + }, + { + name: 'audiobook upstream failures map to retryable 502', + thrown: new Error('provider overloaded'), + apiErrorMessage: 'Failed to process audio chapter', + normalize: makeServerErrorContext({ + code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + errorClass: 'upstream', + }), + expectedStatus: 502, + expectedBody: { + error: 'Failed to process audio chapter', + errorCode: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + retryable: true, + }, + }, + { + name: 'user export auth initialization failure maps to 500 auth classification', + thrown: new Error('Auth not initialized'), + apiErrorMessage: 'Auth not initialized', + normalize: makeServerErrorContext({ + code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + errorClass: 'auth', + httpStatus: 500, + retryable: false, + }), + expectedStatus: 500, + expectedBody: { + error: 'Auth not initialized', + errorCode: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + retryable: false, + }, + }, + ])('$name', async ({ thrown, apiErrorMessage, normalize, expectedStatus, expectedBody }) => { + const response = errorResponse(thrown, { apiErrorMessage, normalize }); + expect(response.status).toBe(expectedStatus); + await expect(response.json()).resolves.toEqual(expectedBody); + }); +}); diff --git a/tests/unit/shared-provider-selection.spec.ts b/tests/unit/shared-provider-selection.vitest.spec.ts similarity index 85% rename from tests/unit/shared-provider-selection.spec.ts rename to tests/unit/shared-provider-selection.vitest.spec.ts index a371989..69d9eb2 100644 --- a/tests/unit/shared-provider-selection.spec.ts +++ b/tests/unit/shared-provider-selection.vitest.spec.ts @@ -1,14 +1,11 @@ -import { expect, test } from '@playwright/test'; +import { describe, expect, test } from 'vitest'; import { resolvePreferredSharedProviderSlug } from '../../src/lib/shared/shared-provider-selection'; +import { makeSharedProviders } from './support/factories'; -const PROVIDERS = [ - { slug: 'shared-a' }, - { slug: 'default-openai' }, - { slug: 'shared-b' }, -] as const; +const PROVIDERS = makeSharedProviders(['shared-a', 'default-openai', 'shared-b']); -test.describe('resolvePreferredSharedProviderSlug', () => { +describe('resolvePreferredSharedProviderSlug', () => { test('prefers requested shared slug when present', () => { expect(resolvePreferredSharedProviderSlug({ providers: PROVIDERS, diff --git a/tests/unit/signup-policy.spec.ts b/tests/unit/signup-policy.vitest.spec.ts similarity index 76% rename from tests/unit/signup-policy.spec.ts rename to tests/unit/signup-policy.vitest.spec.ts index 190ef67..a74ba83 100644 --- a/tests/unit/signup-policy.spec.ts +++ b/tests/unit/signup-policy.vitest.spec.ts @@ -1,9 +1,9 @@ -import { expect, test } from '@playwright/test'; +import { describe, expect, test } from 'vitest'; import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings'; import { assertUserSignupAllowed } from '../../src/lib/server/auth/signup-policy'; -test.describe('enableUserSignups runtime config', () => { +describe('enableUserSignups runtime config', () => { test('defaults to enabled', () => { expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.default).toBe(true); }); @@ -13,10 +13,13 @@ test.describe('enableUserSignups runtime config', () => { expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('false')).toBe(false); expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('1')).toBe(true); expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('0')).toBe(false); + expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('yes')).toBe(true); + expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('off')).toBe(false); + expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('maybe')).toBeUndefined(); }); }); -test.describe('signup policy enforcement', () => { +describe('signup policy enforcement', () => { test('allows new non-anonymous users when signups are enabled', () => { expect(() => assertUserSignupAllowed({ enableUserSignups: true, isAnonymous: false })).not.toThrow(); }); diff --git a/tests/unit/support/env.ts b/tests/unit/support/env.ts new file mode 100644 index 0000000..4f76129 --- /dev/null +++ b/tests/unit/support/env.ts @@ -0,0 +1,30 @@ +export type EnvPatch = Record; + +export function captureEnv(keys: readonly string[]): EnvPatch { + const snapshot: EnvPatch = {}; + for (const key of keys) { + snapshot[key] = process.env[key]; + } + return snapshot; +} + +export function restoreEnv(snapshot: EnvPatch): void { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +export async function withEnv(patch: EnvPatch, run: () => T | Promise): Promise { + const keys = Object.keys(patch); + const snapshot = captureEnv(keys); + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return await run(); + } finally { + restoreEnv(snapshot); + } +} diff --git a/tests/unit/support/factories.ts b/tests/unit/support/factories.ts new file mode 100644 index 0000000..cb8835b --- /dev/null +++ b/tests/unit/support/factories.ts @@ -0,0 +1,30 @@ +import type { ServerErrorClass, ServerErrorContext } from '../../../src/lib/server/errors/contract'; + +export function makeServerAppErrorInput(overrides: Partial<{ + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus: number; + retryable: boolean; + details: Record; + cause: unknown; +}> = {}) { + return { + code: 'UNKNOWN_SERVER_ERROR', + message: 'Unhandled server failure', + errorClass: 'unknown' as const, + ...overrides, + }; +} + +export function makeServerErrorContext(overrides: ServerErrorContext = {}): ServerErrorContext { + return { + code: 'UNKNOWN_SERVER_ERROR', + errorClass: 'unknown', + ...overrides, + }; +} + +export function makeSharedProviders(slugs: readonly string[]): Array<{ slug: string }> { + return slugs.map((slug) => ({ slug })); +} diff --git a/tests/unit/support/server-stubs.ts b/tests/unit/support/server-stubs.ts new file mode 100644 index 0000000..c8b7e29 --- /dev/null +++ b/tests/unit/support/server-stubs.ts @@ -0,0 +1,22 @@ +import type { Mock } from 'vitest'; +import { vi } from 'vitest'; + +export interface ServerLoggerStub { + child: Mock; + debug: Mock; + error: Mock; + info: Mock; + warn: Mock; +} + +export function createServerLoggerStub(): ServerLoggerStub { + const logger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + child: vi.fn(), + } as ServerLoggerStub; + logger.child.mockReturnValue(logger); + return logger; +} diff --git a/vitest.config.ts b/vitest.config.ts index fb32f4f..fe7ca8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,30 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +const srcDir = fileURLToPath(new URL('./src/', import.meta.url)); +const alias = [ + { find: /^@\//, replacement: `${srcDir}` }, + { find: '@', replacement: srcDir }, +]; export default defineConfig({ + resolve: { + alias, + }, test: { + alias, reporters: process.env.CI ? ['default', 'github'] : ['default'], projects: [ + { + resolve: { + alias, + }, + test: { + name: 'openreader', + environment: 'node', + include: ['tests/unit/**/*.vitest.spec.ts'], + }, + }, { test: { name: 'compute-core',