diff --git a/.release-please-config.json b/.release-please-config.json index b1b76d2..afacb46 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -4,7 +4,6 @@ "packages/core": { "release-type": "node", "package-name": "env-sentry-lib", - "changelog-path": "packages/core/CHANGELOG.md", "changelog-sections": [ { "type": "feat", "section": "✨ Features", "hidden": false }, diff --git a/codecov.yml b/codecov.yml index a3ac277..7847afe 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,11 +2,11 @@ coverage: status: project: default: - target: 45% # Overall project coverage target + target: 85% # Overall project coverage target threshold: 2% # Allow 2% drop while still passing patch: default: - target: 45% # Coverage target for new code in PRs + target: 95% # Coverage target for new code in PRs # Ignore patterns ignore: diff --git a/packages/core/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md similarity index 100% rename from packages/core/packages/core/CHANGELOG.md rename to packages/core/CHANGELOG.md diff --git a/packages/core/README.md b/packages/core/README.md index 909a3d5..9e6075e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -6,7 +6,7 @@ Type-safe environment variable validation for Node.js with Zod. ## Why? -Environment variables are strongly-typed and error-prone. `env-sentry-lib` gives you: +Environment variables are loosely typed and error-prone. `env-sentry-lib` gives you: - ✅ **Type Safety** - Full TypeScript inference from your schema - ✅ **Runtime Validation** - Catch configuration errors at startup @@ -29,9 +29,6 @@ yarn add env-sentry-lib zod import { createEnv } from 'env-sentry-lib'; import { z } from 'zod'; -import { createEnv } from 'env-sentry-lib'; -import { z } from 'zod'; - // Define your environment schema const env = createEnv({ // Server config @@ -109,17 +106,12 @@ env.DEBUG_MODE; // type: boolean | undefined When validation fails, you get actionable error messages: ``` -❌ Environment Validation Failed +Environment validation failed. +The application cannot start due to invalid environment variables. Missing required variables: • DATABASE_URL - Expected: string - Fix: Add DATABASE_URL to your environment - -Invalid values: - • PORT - Error: Expected number, received nan - Fix: Check the value and validation rules +Action: Define these variables in your environment. ``` ## API Reference diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 83dced9..5a72026 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -1,16 +1,15 @@ /** * Custom error class for environment validation failures * - * WHY: Better error messages than raw Zod errors - * HOW: Formats Zod validation errors into readable format + * WHY: Provide clear, actionable feedback when env validation fails + * HOW: Formats Zod validation errors into a readable, developer-friendly message */ import { z, ZodError } from 'zod'; export class EnvValidationError extends Error { constructor(zodError: ZodError) { - const message = EnvValidationError.formatError(zodError); - super(message); + super(EnvValidationError.formatError(zodError)); this.name = 'EnvValidationError'; } @@ -18,47 +17,39 @@ export class EnvValidationError extends Error { * Format ZodError into a friendly, readable message */ private static formatError(zodError: ZodError): string { - const issues = zodError.issues; + const missingVars: z.ZodIssue[] = []; + const invalidVars: z.ZodIssue[] = []; - // Group issues by type - // Use z.core.$ZodIssue instead of deprecated ZodIssue - const missingVars: z.core.$ZodIssue[] = []; - const invalidVars: z.core.$ZodIssue[] = []; - - for (const issue of issues) { - if (issue.code === 'invalid_type' && issue.input === 'undefined') { + for (const issue of zodError.issues) { + // A variable is considered "missing" only when it is truly undefined + if (issue.code === 'invalid_type' && issue.input === undefined) { missingVars.push(issue); } else { invalidVars.push(issue); } } - // Build the error message - let message = '❌ Environment Validation Failed\n\n'; + let message = + 'Environment validation failed.\n' + + 'The application cannot start due to invalid environment variables.\n\n'; - // Missing variables section if (missingVars.length > 0) { message += 'Missing required variables:\n'; for (const issue of missingVars) { const varName = issue.path.join('.'); message += ` • ${varName}\n`; - // Type guard to safely access 'expected' property - if ('expected' in issue) { - message += ` Expected: ${issue.expected}\n`; - } - message += ` Fix: Add ${varName} to your environment\n\n`; } + message += 'Action: Define these variables in your environment.\n\n'; } - // Invalid values section if (invalidVars.length > 0) { message += 'Invalid values:\n'; for (const issue of invalidVars) { const varName = issue.path.join('.'); message += ` • ${varName}\n`; message += ` Error: ${issue.message}\n`; - message += ` Fix: Check the value and validation rules\n\n`; } + message += 'Action: Verify the values match the expected format.\n\n'; } return message.trim(); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index 9cff322..00be052 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -1,72 +1,73 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { z } from 'zod'; -import { createEnv } from '../src/index'; +import { createEnv, EnvValidationError } from '../src/index'; describe('createEnv', () => { - // Save original process.env - const originalEnv = process.env; + // Snapshot original environment (keys only, not reference) + const originalEnv = { ...process.env }; beforeEach(() => { - // Start with a clean environment for each test - process.env = { ...originalEnv }; + // Clear all env vars + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + + // Restore original env vars + Object.assign(process.env, originalEnv); }); afterEach(() => { - // Restore original environment - process.env = originalEnv; + // Clear all env vars + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + + // Restore original env vars + Object.assign(process.env, originalEnv); }); it('should validate and return typed environment variables', () => { - // Setup: Set environment variables process.env.PORT = '3000'; process.env.DATABASE_URL = 'postgres://localhost:5432/db'; process.env.NODE_ENV = 'development'; - // Action: Create env with schema const env = createEnv({ PORT: z.string().transform(Number), DATABASE_URL: z.url(), NODE_ENV: z.enum(['development', 'production', 'test']), }); - // Assert: Values are correct and types are inferred expect(env.PORT).toBe(3000); expect(typeof env.PORT).toBe('number'); - expect(env.DATABASE_URL).toBe('postgres://localhost:5432/db'); expect(typeof env.DATABASE_URL).toBe('string'); - expect(env.NODE_ENV).toBe('development'); }); + it('should throw error for missing required variables', () => { - // Setup: Don't set PORT delete process.env.PORT; - // Action & Assert: Should throw ZodError expect(() => { createEnv({ PORT: z.string(), }); - }).toThrow(); + }).toThrow(EnvValidationError); }); it('should throw error for invalid values', () => { - // Setup: Invalid URL process.env.DATABASE_URL = 'not-a-url'; - // Action & Assert: Should throw ZodError expect(() => { createEnv({ DATABASE_URL: z.url(), }); - }).toThrow(); + }).toThrow(EnvValidationError); }); + it('should use default values for missing variables', () => { - // Setup: Don't set PORT or NODE_ENV delete process.env.PORT; delete process.env.NODE_ENV; - // Action: Create env with defaults const env = createEnv({ PORT: z.string().default('3000').transform(Number), NODE_ENV: z @@ -74,17 +75,14 @@ describe('createEnv', () => { .default('development'), }); - // Assert: Defaults are used expect(env.PORT).toBe(3000); expect(env.NODE_ENV).toBe('development'); }); it('should prefer actual values over defaults', () => { - // Setup: Set explicit values process.env.PORT = '8080'; process.env.NODE_ENV = 'production'; - // Action: Create env with defaults const env = createEnv({ PORT: z.string().default('3000').transform(Number), NODE_ENV: z @@ -92,47 +90,156 @@ describe('createEnv', () => { .default('development'), }); - // Assert: Actual values win over defaults expect(env.PORT).toBe(8080); expect(env.NODE_ENV).toBe('production'); }); it('should support optional values without defaults', () => { - // Setup: Don't set OPTIONAL_VAR delete process.env.OPTIONAL_VAR; - // Action: Create env with optional field const env = createEnv({ OPTIONAL_VAR: z.string().optional(), }); - // Assert: Optional value is undefined expect(env.OPTIONAL_VAR).toBeUndefined(); }); + it('should provide helpful error messages for validation failures', () => { - // Setup: Missing PORT, invalid DATABASE_URL delete process.env.PORT; process.env.DATABASE_URL = 'not-a-url'; - // Action & Assert try { createEnv({ PORT: z.string(), DATABASE_URL: z.string().url(), }); - - // Should not reach here - expect.fail('Should have thrown an error'); + expect.fail('Should have thrown'); } catch (error) { - // Type guard: Check if it's our custom error - expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(EnvValidationError); + if (error instanceof EnvValidationError) { + expect(error.message).toContain('Environment validation failed'); + expect(error.message).toContain('cannot start'); + expect(error.message).toContain('PORT'); + expect(error.message).toContain('DATABASE_URL'); + } + } + }); + + it('should throw EnvValidationError for multiple missing variables', () => { + delete process.env.PORT; + delete process.env.DATABASE_URL; + delete process.env.API_KEY; - if (error instanceof Error) { - // Now TypeScript knows error has .message and .name - expect(error.message).toContain('Environment Validation Failed'); + try { + createEnv({ + PORT: z.string(), + DATABASE_URL: z.string(), + API_KEY: z.string(), + }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + if (error instanceof EnvValidationError) { expect(error.message).toContain('PORT'); expect(error.message).toContain('DATABASE_URL'); - expect(error.name).toBe('EnvValidationError'); + expect(error.message).toContain('API_KEY'); + } + } + }); + + it('should throw EnvValidationError for multiple invalid variables', () => { + process.env.PORT = 'not-a-number'; + process.env.DATABASE_URL = 'not-a-url'; + process.env.MAX_CONNECTIONS = 'also-not-a-number'; + + try { + createEnv({ + PORT: z.string().transform(Number).pipe(z.number()), + DATABASE_URL: z.string().url(), + MAX_CONNECTIONS: z.string().transform(Number).pipe(z.number()), + }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + } + }); + + it('should handle mix of missing and invalid variables', () => { + delete process.env.PORT; + process.env.DATABASE_URL = 'invalid-url'; + + try { + createEnv({ + PORT: z.string(), + DATABASE_URL: z.string().url(), + }); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(EnvValidationError); + } + }); + + it('should handle empty string as invalid for number transform', () => { + process.env.PORT = ''; + + expect(() => { + createEnv({ + PORT: z.string().min(1).transform(Number), + }); + }).toThrow(EnvValidationError); + }); + + it('should accept valid enum values', () => { + process.env.LOG_LEVEL = 'debug'; + + const env = createEnv({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']), + }); + + expect(env.LOG_LEVEL).toBe('debug'); + }); + + it('should handle complex transformations', () => { + process.env.ALLOWED_HOSTS = 'localhost,example.com,api.example.com'; + + const env = createEnv({ + ALLOWED_HOSTS: z + .string() + .transform((val) => val.split(',')) + .pipe(z.array(z.string())), + }); + + expect(env.ALLOWED_HOSTS).toEqual([ + 'localhost', + 'example.com', + 'api.example.com', + ]); + }); + + it('should rethrow non-Zod errors (branch coverage)', () => { + // Capture the original descriptor + const originalEnvDescriptor = Object.getOwnPropertyDescriptor( + process, + 'env', + ); + + try { + Object.defineProperty(process, 'env', { + configurable: true, + get() { + throw new Error('Unexpected env access failure'); + }, + }); + + expect(() => { + createEnv({ + PORT: z.string(), + }); + }).toThrowError('Unexpected env access failure'); + } finally { + // Restore immediately so afterEach does not explode + if (originalEnvDescriptor) { + Object.defineProperty(process, 'env', originalEnvDescriptor); } } }); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index ad374cf..27118f8 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -16,10 +16,10 @@ export default defineConfig({ 'vitest.config.ts', ], thresholds: { - lines: 45, - functions: 45, - branches: 45, - statements: 45, + lines: 85, + functions: 85, + branches: 85, + statements: 85, }, }, },