test(unit): migrate batch1 core contracts to vitest
This commit is contained in:
parent
b16328abe9
commit
7570181b8a
14 changed files with 433 additions and 256 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
111
tests/unit/auth-config.vitest.spec.ts
Normal file
111
tests/unit/auth-config.vitest.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)', () => {
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
90
tests/unit/server-error-response.vitest.spec.ts
Normal file
90
tests/unit/server-error-response.vitest.spec.ts
Normal file
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
80
tests/unit/server-route-error-mappings.vitest.spec.ts
Normal file
80
tests/unit/server-route-error-mappings.vitest.spec.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
@ -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();
|
||||
});
|
||||
30
tests/unit/support/env.ts
Normal file
30
tests/unit/support/env.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type EnvPatch = Record<string, string | undefined>;
|
||||
|
||||
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<T>(patch: EnvPatch, run: () => T | Promise<T>): Promise<T> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
30
tests/unit/support/factories.ts
Normal file
30
tests/unit/support/factories.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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 }));
|
||||
}
|
||||
22
tests/unit/support/server-stubs.ts
Normal file
22
tests/unit/support/server-stubs.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue