From 9c29091b5868c5771fa757c725c7b3d9f10ffa2a Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 14 Apr 2026 22:05:36 +0300 Subject: [PATCH] Add Zod-validated getEnv() reader with module-level cache --- src/env/env.test.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++ src/env/index.ts | 86 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/env/env.test.ts create mode 100644 src/env/index.ts diff --git a/src/env/env.test.ts b/src/env/env.test.ts new file mode 100644 index 00000000..b34c49a2 --- /dev/null +++ b/src/env/env.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("getEnv", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear env to a clean slate, then set a minimum valid shape. + for (const key of Object.keys(process.env)) { + if (key.startsWith("VITEST_")) continue; + delete process.env[key]; + } + process.env["NODE_ENV"] = "testing"; + process.env["BUILD_TARGET"] = "standalone"; + process.env["PROD_ORIGIN"] = "https://flights.aeroflot.ru"; + process.env["API_BASE_URL"] = "https://platform.aeroflot.ru"; + process.env["SIGNALR_HUB_URL"] = "wss://platform.aeroflot.ru/hub"; + process.env["ANALYTICS_METRICA"] = "true"; + process.env["ANALYTICS_CTM"] = "false"; + process.env["ANALYTICS_VARIOCUBE"] = "false"; + process.env["ANALYTICS_DYNATRACE"] = "true"; + process.env["VERSION"] = "abc1234"; + }); + + afterEach(async () => { + process.env = { ...originalEnv }; + // Reset the module cache so getEnv re-reads the updated env. + const mod = await import("./index.js"); + mod.__resetEnvCacheForTests(); + }); + + it("returns a typed Env object for valid input", async () => { + const { getEnv } = await import("./index.js"); + const env = getEnv(); + expect(env.NODE_ENV).toBe("testing"); + expect(env.BUILD_TARGET).toBe("standalone"); + expect(env.PROD_ORIGIN).toBe("https://flights.aeroflot.ru"); + expect(env.API_BASE_URL).toBe("https://platform.aeroflot.ru"); + expect(env.SIGNALR_HUB_URL).toBe("wss://platform.aeroflot.ru/hub"); + expect(env.ANALYTICS_ENABLED).toEqual({ + metrica: true, + ctm: false, + variocube: false, + dynatrace: true, + }); + expect(env.VERSION).toBe("abc1234"); + expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBeUndefined(); + }); + + it("caches the result across calls (same object identity)", async () => { + const { getEnv } = await import("./index.js"); + const a = getEnv(); + const b = getEnv(); + expect(a).toBe(b); + }); + + it("throws a readable error when a required field is missing", async () => { + delete process.env["API_BASE_URL"]; + const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); + __resetEnvCacheForTests(); + expect(() => getEnv()).toThrow(/API_BASE_URL/); + }); + + it("throws when NODE_ENV is not one of the allowed values", async () => { + process.env["NODE_ENV"] = "banana"; + const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); + __resetEnvCacheForTests(); + expect(() => getEnv()).toThrow(/NODE_ENV/); + }); + + it("throws when BUILD_TARGET is neither standalone nor remote", async () => { + process.env["BUILD_TARGET"] = "hybrid"; + const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); + __resetEnvCacheForTests(); + expect(() => getEnv()).toThrow(/BUILD_TARGET/); + }); + + it("accepts optional OTEL_EXPORTER_OTLP_ENDPOINT when provided", async () => { + process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://otel.example/v1/traces"; + const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); + __resetEnvCacheForTests(); + const env = getEnv(); + expect(env.OTEL_EXPORTER_OTLP_ENDPOINT).toBe("https://otel.example/v1/traces"); + }); + + it("rejects OTEL_EXPORTER_OTLP_ENDPOINT that is not a URL", async () => { + process.env["OTEL_EXPORTER_OTLP_ENDPOINT"] = "not-a-url"; + const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); + __resetEnvCacheForTests(); + expect(() => getEnv()).toThrow(/OTEL_EXPORTER_OTLP_ENDPOINT/); + }); +}); diff --git a/src/env/index.ts b/src/env/index.ts new file mode 100644 index 00000000..cc6dac93 --- /dev/null +++ b/src/env/index.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import type { AnalyticsProviders } from "@/observability/analytics/types"; + +const boolish = z + .enum(["true", "false", "1", "0"]) + .transform((v) => v === "true" || v === "1"); + +const EnvSchema = z.object({ + NODE_ENV: z.enum(["development", "testing", "staging", "production"]), + BUILD_TARGET: z.enum(["standalone", "remote"]), + PROD_ORIGIN: z.string().url(), + API_BASE_URL: z.string().url(), + SIGNALR_HUB_URL: z.string().url(), + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), + OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(), + LOGS_ENDPOINT: z.string().url().optional(), + ANALYTICS_METRICA: boolish.default("false"), + ANALYTICS_CTM: boolish.default("false"), + ANALYTICS_VARIOCUBE: boolish.default("false"), + ANALYTICS_DYNATRACE: boolish.default("false"), + VERSION: z.string().min(1), +}); + +type RawEnv = z.infer; + +export interface Env { + NODE_ENV: RawEnv["NODE_ENV"]; + BUILD_TARGET: RawEnv["BUILD_TARGET"]; + PROD_ORIGIN: string; + API_BASE_URL: string; + SIGNALR_HUB_URL: string; + OTEL_EXPORTER_OTLP_ENDPOINT?: string; + OTEL_EXPORTER_OTLP_HEADERS?: string; + LOGS_ENDPOINT?: string; + ANALYTICS_ENABLED: AnalyticsProviders; + VERSION: string; +} + +let cached: Env | undefined; + +export function getEnv(): Env { + if (cached) return cached; + + const parsed = EnvSchema.safeParse(process.env); + if (!parsed.success) { + const details = parsed.error.issues + .map((i) => `${i.path.join(".")}: ${i.message}`) + .join("; "); + throw new Error(`Invalid environment configuration: ${details}`); + } + + const raw = parsed.data; + const result: Env = { + NODE_ENV: raw.NODE_ENV, + BUILD_TARGET: raw.BUILD_TARGET, + PROD_ORIGIN: raw.PROD_ORIGIN, + API_BASE_URL: raw.API_BASE_URL, + SIGNALR_HUB_URL: raw.SIGNALR_HUB_URL, + ANALYTICS_ENABLED: { + metrica: raw.ANALYTICS_METRICA, + ctm: raw.ANALYTICS_CTM, + variocube: raw.ANALYTICS_VARIOCUBE, + dynatrace: raw.ANALYTICS_DYNATRACE, + }, + VERSION: raw.VERSION, + }; + if (raw.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined) { + result.OTEL_EXPORTER_OTLP_ENDPOINT = raw.OTEL_EXPORTER_OTLP_ENDPOINT; + } + if (raw.OTEL_EXPORTER_OTLP_HEADERS !== undefined) { + result.OTEL_EXPORTER_OTLP_HEADERS = raw.OTEL_EXPORTER_OTLP_HEADERS; + } + if (raw.LOGS_ENDPOINT !== undefined) { + result.LOGS_ENDPOINT = raw.LOGS_ENDPOINT; + } + cached = result; + return result; +} + +/** + * Test-only: resets the module-level cache so tests can mutate process.env + * and re-read. Do NOT call this from production code. + */ +export function __resetEnvCacheForTests(): void { + cached = undefined; +}