j126: read bot settings from a .env file (default ./.env, --env-file flag, TR_ENV_FILE); file wins over shell leftovers, boot report labels (source: .env)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
## Guard test for the .env loader (the module the bot uses to read its
|
||||
## settings from a `.env` file).
|
||||
##
|
||||
## NO battle, NO Java, NO server. Run with:
|
||||
## nim c -r --path:common_libs common_libs/tests/test_env_dotenv.nim
|
||||
##
|
||||
## It pins the pieces that must not silently drift:
|
||||
## * the parser (comments, `export `, quotes, empty value, CRLF, and a
|
||||
## malformed line that MUST raise with the file+line);
|
||||
## * resolution precedence (flag > TR_ENV_FILE > ./.env > .env next to the
|
||||
## executable > none);
|
||||
## * the FILE WINS over the real environment, and the conflict is reported;
|
||||
## * an explicitly requested missing file raises (the caller then exits
|
||||
## nonzero), while a missing DEFAULT file is a silent no-op.
|
||||
|
||||
import std/[os, strutils]
|
||||
import "../../ModularBot_garage/src/env_dotenv"
|
||||
|
||||
var failures = 0
|
||||
var checks = 0
|
||||
|
||||
proc check(name: string, ok: bool) =
|
||||
inc checks
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
let tmpDir = getTempDir() / "test_env_dotenv_guard"
|
||||
createDir(tmpDir)
|
||||
|
||||
proc tmpPath(name: string): string = tmpDir / name
|
||||
|
||||
proc writeTmp(name, content: string): string =
|
||||
result = tmpPath(name)
|
||||
writeFile(result, content)
|
||||
|
||||
# ── 1. the parser ────────────────────────────────────────────────────────────
|
||||
|
||||
proc testParser() =
|
||||
let entries = parseEnvFileContent("""# a comment, ignored
|
||||
|
||||
TR_MOVEMENT=tfil
|
||||
export TR_RACK_TMHORIZON=both
|
||||
QUOTED="hello world"
|
||||
SINGLE='x y'
|
||||
EMPTY=
|
||||
SPACED = trimmed
|
||||
""", "sample.env")
|
||||
check "comment + blank lines are skipped (6 entries)", entries.len == 6
|
||||
if entries.len == 6:
|
||||
check "plain KEY=VALUE", entries[0].key == "TR_MOVEMENT" and entries[0].value == "tfil"
|
||||
check "leading `export ` is tolerated",
|
||||
entries[1].key == "TR_RACK_TMHORIZON" and entries[1].value == "both"
|
||||
check "double quotes are stripped",
|
||||
entries[2].key == "QUOTED" and entries[2].value == "hello world"
|
||||
check "single quotes are stripped",
|
||||
entries[3].key == "SINGLE" and entries[3].value == "x y"
|
||||
check "an empty value is allowed",
|
||||
entries[4].key == "EMPTY" and entries[4].value == ""
|
||||
check "whitespace around key and value is trimmed",
|
||||
entries[5].key == "SPACED" and entries[5].value == "trimmed"
|
||||
|
||||
let crlf = parseEnvFileContent("A=1\r\nB=two\r\n", "crlf.env")
|
||||
check "CRLF line endings are stripped from the value",
|
||||
crlf.len == 2 and crlf[0].value == "1" and crlf[1].value == "two"
|
||||
|
||||
var raised = false
|
||||
try:
|
||||
discard parseEnvFileContent("this line has no equals sign\n", "bad.env")
|
||||
except ValueError as e:
|
||||
raised = true
|
||||
check "malformed line error names the file", "bad.env" in e.msg
|
||||
check "malformed line error names the line number", ":1:" in e.msg
|
||||
check "malformed line RAISES (never silently ignored)", raised
|
||||
|
||||
var emptyKeyRaised = false
|
||||
try:
|
||||
discard parseEnvFileContent("=value\n", "bad2.env")
|
||||
except ValueError:
|
||||
emptyKeyRaised = true
|
||||
check "an empty key raises", emptyKeyRaised
|
||||
|
||||
# ── 2. resolution precedence ─────────────────────────────────────────────────
|
||||
|
||||
proc testResolution() =
|
||||
let flagChoice = chooseEnvFile("/x/flag.env", "/x/tr.env",
|
||||
"/cwd/.env", "/exe/.env")
|
||||
check "flag beats TR_ENV_FILE and the defaults",
|
||||
flagChoice.source == "flag" and flagChoice.path == "/x/flag.env" and
|
||||
flagChoice.explicit
|
||||
|
||||
let envChoice = chooseEnvFile("", "/x/tr.env", "/cwd/.env", "/exe/.env")
|
||||
check "TR_ENV_FILE beats the default paths",
|
||||
envChoice.source == EnvFileEnvVar and envChoice.path == "/x/tr.env" and
|
||||
envChoice.explicit
|
||||
|
||||
let cwdFile = writeTmp("cwd.env", "A=1\n")
|
||||
let exeFile = writeTmp("exe.env", "A=2\n")
|
||||
let cwdChoice = chooseEnvFile("", "", cwdFile, exeFile)
|
||||
check "./.env (cwd) is chosen when it exists and is not explicit",
|
||||
cwdChoice.source == "cwd" and cwdChoice.path == cwdFile and
|
||||
not cwdChoice.explicit
|
||||
|
||||
let exeChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"), exeFile)
|
||||
check ".env next to the executable is the fallback",
|
||||
exeChoice.source == "exe" and exeChoice.path == exeFile
|
||||
|
||||
let noneChoice = chooseEnvFile("", "", tmpPath("missing_cwd.env"),
|
||||
tmpPath("missing_exe.env"))
|
||||
check "no candidate exists -> none (silent)",
|
||||
noneChoice.path == "" and noneChoice.source == "none" and
|
||||
not noneChoice.explicit
|
||||
|
||||
check "--env-file <path> is parsed",
|
||||
flagEnvFilePath(@["--server", "x", "--env-file", "/a/b.env"]) == "/a/b.env"
|
||||
check "--env-file=<path> is parsed",
|
||||
flagEnvFilePath(@["--env-file=/a/b.env"]) == "/a/b.env"
|
||||
check "no --env-file yields empty", flagEnvFilePath(@["--server", "x"]) == ""
|
||||
var raised = false
|
||||
try:
|
||||
discard flagEnvFilePath(@["--env-file"])
|
||||
except EnvFileError:
|
||||
raised = true
|
||||
check "--env-file with no argument raises", raised
|
||||
|
||||
# ── 3. the file wins over the real environment ───────────────────────────────
|
||||
|
||||
proc testFileWins() =
|
||||
let f = writeTmp("wins.env", "TR_TEST_J126=from-file\nTR_TEST_SAME_J126=same\n")
|
||||
putEnv("TR_TEST_J126", "from-shell")
|
||||
putEnv("TR_TEST_SAME_J126", "same")
|
||||
let conflicts = applyEnvFile(f)
|
||||
check "the file value WINS over the exported value",
|
||||
getEnv("TR_TEST_J126") == "from-file"
|
||||
check "the conflict is reported with key, file value and shell value",
|
||||
conflicts.len == 1 and conflicts[0].key == "TR_TEST_J126" and
|
||||
conflicts[0].fileValue == "from-file" and
|
||||
conflicts[0].shellValue == "from-shell"
|
||||
check "an identical value is NOT a conflict",
|
||||
conflicts.len == 1 # TR_TEST_SAME_J126 is absent from conflicts
|
||||
check "keys from the file are tracked for the boot report",
|
||||
isFromEnvFile("TR_TEST_J126") and isFromEnvFile("TR_TEST_SAME_J126")
|
||||
delEnv("TR_TEST_J126")
|
||||
delEnv("TR_TEST_SAME_J126")
|
||||
|
||||
# ── 4. missing explicit file vs missing default file ─────────────────────────
|
||||
|
||||
proc testMissingFiles() =
|
||||
var raised = false
|
||||
try:
|
||||
discard loadEnvFile(EnvFileChoice(path: tmpPath("nope.env"),
|
||||
source: "flag", explicit: true))
|
||||
except EnvFileError as e:
|
||||
raised = true
|
||||
check "missing explicit file error names the path", "nope.env" in e.msg
|
||||
check "missing explicit file RAISES (caller exits nonzero)", raised
|
||||
|
||||
var quiet = true
|
||||
var n = 0
|
||||
try:
|
||||
n = loadEnvFile(EnvFileChoice(path: tmpPath("missing-default.env"),
|
||||
source: "cwd", explicit: false)).len
|
||||
except CatchableError:
|
||||
quiet = false
|
||||
check "missing default file is a silent no-op", quiet and n == 0
|
||||
|
||||
check "no file at all is a silent no-op",
|
||||
loadEnvFile(EnvFileChoice(path: "", source: "none", explicit: false)).len == 0
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
testParser()
|
||||
testResolution()
|
||||
testFileWins()
|
||||
testMissingFiles()
|
||||
|
||||
echo "\n", checks, " checks, ", failures, " failure(s)"
|
||||
if failures > 0:
|
||||
echo checks, " check(s) run, ", failures, " FAILED"
|
||||
quit(1)
|
||||
echo "All env-dotenv checks passed."
|
||||
Reference in New Issue
Block a user