Add Zod-validated getEnv() reader with module-level cache
This commit is contained in:
Vendored
+91
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+86
@@ -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<typeof EnvSchema>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user