]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
feat(lupa): add validation filters, UCL-aware tests, and comprehensive unit tests
authorDmitriy Alekseev <1865999+dragoangel@users.noreply.github.com>
Mon, 16 Mar 2026 12:17:07 +0000 (13:17 +0100)
committerDmitriy Alekseev <1865999+dragoangel@users.noreply.github.com>
Mon, 16 Mar 2026 12:25:51 +0000 (13:25 +0100)
New filters:
- mandatory(msg): error if nil or empty
- require_int(msg): error if not a valid integer
- require_number(msg): error if not a valid number
- require_bool(msg): error if not a UCL boolean (true/false/yes/no/on/off/1/0)
- require_duration(msg): parse duration string to seconds, error if invalid
  (supports s, ms, min, m, h, d, w, y)
- require_json(msg): error if not valid JSON/UCL
- require_size(msg): error if not a valid size (number with optional b/Kb/Mb/Gb)
- fromjson: parse JSON/UCL string into Lua table
- tobytes: convert size string to bytes (1Kb=1024, 1Mb=1048576, 1Gb=1073741824)

Modified tests to handle string inputs (env vars are always strings):
- is_number: now returns true for numeric strings like "42" or "3.14"
- is_integer: now returns true for integer strings like "42"
- is_float: now returns true for float strings like "3.14"
- is_true: now checks UCL truthy values (true/yes/on/1, case-insensitive)
- is_false: now checks UCL falsy values (false/no/off/0, case-insensitive)

New tests:
- is_json: check if value is valid JSON/UCL
- is_size: check if value is a valid size string

Added test/lua/unit/lupa.lua with 90+ test cases covering all filters,
tests, env var patterns, and real-world configuration scenarios.

Signed-off-by: Dmitriy Alekseev <1865999+dragoangel@users.noreply.github.com>
contrib/lua-lupa/lupa.lua
test/lua/unit/lupa.lua [new file with mode: 0644]

index 8981e5e2fef60a85d54dd2e5387a9743f28a5893..98cbe8e191de5e8a1608ac28500afb4d879925e9 100644 (file)
@@ -1644,6 +1644,22 @@ function M.filters.tojson(value, indent)
   return ucl.to_format(value, indent and 'json' or 'json-compact')
 end
 
+---
+-- Parses a JSON string and returns a Lua table.
+-- @param s The JSON string to parse.
+-- @usage expand('{%- set obj = \'{"a":1}\' | fromjson %}{{ obj.a }}') --> 1
+-- @name filters.fromjson
+function M.filters.fromjson(s)
+  assert(s ~= nil and s ~= '', 'input to filter "fromjson" was nil or empty')
+  local ucl = require('ucl')
+  local parser = ucl.parser()
+  local ok, err = parser:parse_string(s)
+  if not ok then
+    error(string.format('fromjson: failed to parse: %s', err), 0)
+  end
+  return parser:get_object()
+end
+
 ---
 -- Returns a copy of string *s* truncated to *length* number of characters.
 -- Truncated strings end with '...' or string *delimiter*. If boolean
@@ -1868,8 +1884,223 @@ function M.filters.xmlattr(t)
   return table.concat(attributes, ' ')
 end
 
+-- Lupa validation filters.
+
+---
+-- Returns the value unchanged, but raises an error if the value is nil or empty.
+-- Use to enforce required env vars at config load time.
+-- @param s The value to validate.
+-- @param msg Optional error message (default: "value is required").
+-- @usage expand('{= env.API_KEY | mandatory("API_KEY is required") =}')
+-- @name filters.mandatory
+function M.filters.mandatory(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'mandatory value is missing', 0)
+  end
+  return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid integer.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.PORT | require_int("PORT must be an integer") %}')
+-- @name filters.require_int
+function M.filters.require_int(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_int: value is missing', 0)
+  end
+  if not tonumber(s) or tonumber(s) ~= math.floor(tonumber(s)) then
+    error(msg or string.format('require_int: "%s" is not a valid integer', tostring(s)), 0)
+  end
+  return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid number.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.PROB | require_number("PROB must be a number") %}')
+-- @name filters.require_number
+function M.filters.require_number(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_number: value is missing', 0)
+  end
+  if not tonumber(s) then
+    error(msg or string.format('require_number: "%s" is not a valid number', tostring(s)), 0)
+  end
+  return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not "true" or "false".
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.ENABLED | require_bool("ENABLED must be true or false") %}')
+-- @name filters.require_bool
+function M.filters.require_bool(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_bool: value is missing', 0)
+  end
+  local valid = {['true']=1, ['false']=1, ['yes']=1, ['no']=1, ['on']=1, ['off']=1, ['1']=1, ['0']=1}
+  if not valid[tostring(s):lower()] then
+    error(msg or string.format('require_bool: "%s" is not a valid boolean (use true/false, yes/no, on/off, 1/0)', tostring(s)), 0)
+  end
+  return s
+end
+
+---
+-- Parses a duration string and returns seconds as a number.
+-- Validates the input and raises an error if not a valid duration.
+-- Accepted formats: number followed by ms, s, min, m, h, d, w, y (e.g. "500ms", "30s", "5min", "1h", "10d", "1w", "1y").
+-- Plain numbers are also accepted (treated as seconds).
+-- @param s The duration string to parse.
+-- @param msg Optional error message on invalid input.
+-- @usage expand('{%- set timeout = "5min" | require_duration %}') --> 300
+-- @usage expand('{%- set timeout = "2d" | require_duration %}') --> 172800
+-- @name filters.require_duration
+function M.filters.require_duration(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_duration: value is missing', 0)
+  end
+  local str = tostring(s)
+  local num, unit = str:match('^(%d+%.?%d*)(.*)')
+  if not num then
+    error(msg or string.format('require_duration: "%s" is not a valid duration (use 30s, 5min, 1h, 10d)', str), 0)
+  end
+  num = tonumber(num)
+  unit = unit == '' and 's' or unit
+  local seconds
+  if unit == 'ms' then seconds = num / 1000
+  elseif unit == 's' then seconds = num
+  elseif unit == 'min' or unit == 'm' then seconds = num * 60
+  elseif unit == 'h' then seconds = num * 3600
+  elseif unit == 'd' then seconds = num * 86400
+  elseif unit == 'w' then seconds = num * 604800
+  elseif unit == 'y' then seconds = num * 31536000
+  else error(msg or string.format('require_duration: unknown unit "%s" in "%s" (use ms, s, min, h, d, w, y)', unit, str), 0)
+  end
+  return seconds
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not valid JSON.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.LIST | require_json("LIST must be valid JSON") %}')
+-- @name filters.require_json
+function M.filters.require_json(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_json: value is missing', 0)
+  end
+  local ucl = require('ucl')
+  local parser = ucl.parser()
+  local ok, err = parser:parse_string(s)
+  if not ok then
+    error(msg or string.format('require_json: "%s" is not valid JSON: %s', tostring(s), err), 0)
+  end
+  return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid size string.
+-- Accepted formats: number followed by optional b, Kb, Mb, Gb suffix (case-insensitive).
+-- Plain numbers are also accepted (treated as bytes).
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.MAX_SIZE | require_size("MAX_SIZE must be a size like 150Mb") %}')
+-- @name filters.require_size
+function M.filters.require_size(s, msg)
+  if s == nil or s == '' then
+    error(msg or 'require_size: value is missing', 0)
+  end
+  local str = tostring(s)
+  local lower = str:lower()
+  local stripped = lower:gsub('gb$', ''):gsub('mb$', ''):gsub('kb$', ''):gsub('b$', '')
+  if tonumber(stripped) == nil or tonumber(stripped) < 0 then
+    error(msg or string.format('require_size: "%s" is not a valid size (use number with optional b, Kb, Mb, Gb suffix)', str), 0)
+  end
+  return s
+end
+
+---
+-- Converts a size string to bytes (number).
+-- Input: number with optional suffix Kb (1024), Mb (1024^2), Gb (1024^3). Case-insensitive.
+-- Plain numbers are treated as bytes.
+-- @param s The size string to convert.
+-- @usage expand('{%- set bytes = "150Mb" | tobytes %}') --> 157286400
+-- @name filters.tobytes
+function M.filters.tobytes(s)
+  if s == nil or s == '' then
+    error('tobytes: value is missing', 0)
+  end
+  local str = tostring(s)
+  local lower = str:lower()
+  local num, suffix = lower:match('^(%d+%.?%d*)(.*)')
+  if not num then
+    error(string.format('tobytes: "%s" is not a valid size', str), 0)
+  end
+  num = tonumber(num)
+  if suffix == '' or suffix == 'b' then return num
+  elseif suffix == 'kb' then return num * 1024
+  elseif suffix == 'mb' then return num * 1024 * 1024
+  elseif suffix == 'gb' then return num * 1024 * 1024 * 1024
+  else error(string.format('tobytes: unknown suffix "%s" in "%s"', suffix, str), 0)
+  end
+end
+
 -- Lupa tests.
 
+---
+-- Returns whether or not value *s* is valid JSON.
+-- @param s The value to test.
+-- @usage expand('{% if is_json(env.X) %}...{% endif %}')
+-- @name tests.is_json
+function M.tests.is_json(s)
+  if s == nil or s == '' then return false end
+  local ucl = require('ucl')
+  local parser = ucl.parser()
+  local ok = parser:parse_string(s)
+  return ok and true or false
+end
+
+---
+-- Returns whether or not value *s* is a valid size string.
+-- Accepts: plain numbers (bytes) or numbers with b, Kb, Mb, Gb suffix (case-insensitive).
+-- @param s The value to test.
+-- @usage expand('{% if is_size(env.MAX_SIZE) %}...{% endif %}')
+-- @name tests.is_size
+function M.tests.is_size(s)
+  if s == nil or s == '' then return false end
+  local lower = tostring(s):lower()
+  local stripped = lower:gsub('gb$', ''):gsub('mb$', ''):gsub('kb$', ''):gsub('b$', '')
+  return tonumber(stripped) ~= nil and tonumber(stripped) >= 0
+end
+
+---
+-- Returns whether or not value *s* is a UCL truthy boolean (true, yes, on, 1).
+-- Case-insensitive, matching UCL parser behavior.
+-- @param s The value to test.
+-- @usage expand('{% if is_true(env.ENABLED) %}...{% endif %}')
+-- @name tests.is_true
+function M.tests.is_true(s)
+  if s == nil then return false end
+  local truthy = {['true']=1, ['yes']=1, ['on']=1, ['1']=1}
+  return truthy[tostring(s):lower()] ~= nil
+end
+
+---
+-- Returns whether or not value *s* is a UCL falsy boolean (false, no, off, 0).
+-- Case-insensitive, matching UCL parser behavior.
+-- @param s The value to test.
+-- @usage expand('{% if is_false(env.ENABLED) %}...{% endif %}')
+-- @name tests.is_false
+function M.tests.is_false(s)
+  if s == nil then return false end
+  local falsy = {['false']=1, ['no']=1, ['off']=1, ['0']=1}
+  return falsy[tostring(s):lower()] ~= nil
+end
+
 ---
 -- Returns whether or not number *n* is odd.
 -- @param n The number to test.
@@ -1960,7 +2191,11 @@ function M.tests.is_table(value) return type(value) == 'table' end
 -- @param value The value to test.
 -- @usage expand('{% if is_number(x) %}...{% endif %}')
 -- @name tests.is_number
-function M.tests.is_number(value) return type(value) == 'number' end
+function M.tests.is_number(value)
+  if type(value) == 'number' then return true end
+  if type(value) == 'string' then return tonumber(value) ~= nil end
+  return false
+end
 
 ---
 -- Returns whether or not value *value* is a sequence, namely a table with
@@ -2067,28 +2302,15 @@ end
 -- @name tests.is_boolean
 function M.tests.is_boolean(value) return type(value) == 'boolean' end
 
----
--- Returns whether or not value *value* is `true`.
--- @param value The value to test.
--- @usage expand('{% if is_true(x) %}...{% endif %}')
--- @name tests.is_true
-function M.tests.is_true(value) return value == true end
-
----
--- Returns whether or not value *value* is `false`.
--- @param value The value to test.
--- @usage expand('{% if is_false(x) %}...{% endif %}')
--- @name tests.is_false
-function M.tests.is_false(value) return value == false end
-
 ---
 -- Returns whether or not value *value* is an integer number.
 -- @param value The value to test.
 -- @usage expand('{% if is_integer(x) %}...{% endif %}')
 -- @name tests.is_integer
 function M.tests.is_integer(value)
-  if type(value) ~= 'number' then return false end
-  return value == math.floor(value)
+  local n = type(value) == 'number' and value or tonumber(tostring(value))
+  if n == nil then return false end
+  return n == math.floor(n)
 end
 
 ---
@@ -2097,8 +2319,9 @@ end
 -- @usage expand('{% if is_float(x) %}...{% endif %}')
 -- @name tests.is_float
 function M.tests.is_float(value)
-  if type(value) ~= 'number' then return false end
-  return value ~= math.floor(value)
+  local n = type(value) == 'number' and value or tonumber(tostring(value))
+  if n == nil then return false end
+  return n ~= math.floor(n)
 end
 
 ---
diff --git a/test/lua/unit/lupa.lua b/test/lua/unit/lupa.lua
new file mode 100644 (file)
index 0000000..41a77dd
--- /dev/null
@@ -0,0 +1,600 @@
+--[[
+Copyright (c) 2026, Namecheap Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+]]--
+
+context("Lupa Jinja template engine unit tests", function()
+  local lupa = require("lupa")
+
+  -- Helper: expand template with optional env table (simulates env vars as strings)
+  local function expand(template, env)
+    return lupa.expand(template, env or {})
+  end
+
+  -- Helper: expand with env vars (all values are strings, like real RSPAMD_ env vars)
+  local function expand_env(template, vars)
+    return lupa.expand(template, { env = vars })
+  end
+
+  -- =========================================================================
+  -- VALIDATION FILTERS (with env var string inputs)
+  -- =========================================================================
+
+  -- mandatory
+  test("mandatory: passes non-empty string", function()
+    assert_equal(expand('{{ "test" | mandatory("fail") }}'), 'test')
+  end)
+
+  test("mandatory: works with env var", function()
+    assert_equal(expand_env('{{ env.KEY | mandatory("KEY required") }}', { KEY = "my_key" }), 'my_key')
+  end)
+
+  test("mandatory: crashes on empty string", function()
+    local ok, err = pcall(expand, '{{ "" | mandatory("VALUE_REQUIRED") }}')
+    assert_false(ok)
+    assert_match('VALUE_REQUIRED', err)
+  end)
+
+  test("mandatory: crashes on nil env var", function()
+    local ok, err = pcall(expand_env, '{{ env.MISSING | mandatory("MISSING_VAR") }}', {})
+    assert_false(ok)
+    assert_match('MISSING_VAR', err)
+  end)
+
+  -- require_int
+  test("require_int: valid integer string", function()
+    assert_equal(expand('{{ "42" | require_int }}'), '42')
+  end)
+
+  test("require_int: works with env var", function()
+    assert_equal(expand_env('{{ env.PORT | require_int }}', { PORT = "8080" }), '8080')
+  end)
+
+  test("require_int: zero is valid", function()
+    assert_equal(expand('{{ "0" | require_int }}'), '0')
+  end)
+
+  test("require_int: negative is valid", function()
+    assert_equal(expand('{{ "-1" | require_int }}'), '-1')
+  end)
+
+  test("require_int: crashes on float string", function()
+    local ok, err = pcall(expand, '{{ "3.14" | require_int("NOT_INT") }}')
+    assert_false(ok)
+    assert_match('NOT_INT', err)
+  end)
+
+  test("require_int: crashes on non-numeric string", function()
+    local ok, err = pcall(expand, '{{ "abc" | require_int("NOT_INT") }}')
+    assert_false(ok)
+    assert_match('NOT_INT', err)
+  end)
+
+  -- require_number
+  test("require_number: valid float string", function()
+    assert_equal(expand('{{ "3.14" | require_number }}'), '3.14')
+  end)
+
+  test("require_number: valid integer string", function()
+    assert_equal(expand('{{ "42" | require_number }}'), '42')
+  end)
+
+  test("require_number: works with env var", function()
+    assert_equal(expand_env('{{ env.THRESHOLD | require_number }}', { THRESHOLD = "8.0" }), '8.0')
+  end)
+
+  test("require_number: negative is valid", function()
+    assert_equal(expand('{{ "-4.5" | require_number }}'), '-4.5')
+  end)
+
+  test("require_number: crashes on non-numeric", function()
+    local ok, err = pcall(expand, '{{ "abc" | require_number("NOT_NUM") }}')
+    assert_false(ok)
+    assert_match('NOT_NUM', err)
+  end)
+
+  -- require_bool
+  test("require_bool: accepts all UCL boolean strings", function()
+    for _, v in ipairs({"true", "false", "yes", "no", "on", "off", "1", "0", "TRUE", "False", "YES", "NO", "On", "OFF"}) do
+      local result = expand('{{ "' .. v .. '" | require_bool }}')
+      assert_equal(result, v, 'require_bool should accept "' .. v .. '"')
+    end
+  end)
+
+  test("require_bool: works with env var", function()
+    assert_equal(expand_env('{{ env.ENABLED | require_bool }}', { ENABLED = "true" }), 'true')
+    assert_equal(expand_env('{{ env.ENABLED | require_bool }}', { ENABLED = "YES" }), 'YES')
+  end)
+
+  test("require_bool: crashes on invalid", function()
+    local ok, err = pcall(expand, '{{ "maybe" | require_bool("NOT_BOOL") }}')
+    assert_false(ok)
+    assert_match('NOT_BOOL', err)
+  end)
+
+  -- require_duration
+  test("require_duration: parses all duration formats", function()
+    assert_equal(expand('{{ "30s" | require_duration }}'), '30')
+    assert_equal(expand('{{ "5min" | require_duration }}'), '300')
+    assert_equal(expand('{{ "5m" | require_duration }}'), '300')
+    assert_equal(expand('{{ "1h" | require_duration }}'), '3600')
+    assert_equal(expand('{{ "10d" | require_duration }}'), '864000')
+    assert_equal(expand('{{ "1w" | require_duration }}'), '604800')
+    assert_equal(expand('{{ "1y" | require_duration }}'), '31536000')
+    assert_equal(expand('{{ "500ms" | require_duration }}'), '0.5')
+    assert_equal(expand('{{ "42" | require_duration }}'), '42')
+  end)
+
+  test("require_duration: works with env var", function()
+    assert_equal(expand_env('{{ env.TIMEOUT | require_duration }}', { TIMEOUT = "30s" }), '30')
+    assert_equal(expand_env('{{ env.INTERVAL | require_duration }}', { INTERVAL = "5min" }), '300')
+  end)
+
+  test("require_duration: crashes on invalid", function()
+    local ok, err = pcall(expand, '{{ "abc" | require_duration("BAD_DUR") }}')
+    assert_false(ok)
+    assert_match('BAD_DUR', err)
+  end)
+
+  -- require_json
+  test("require_json: valid JSON passes through", function()
+    assert_equal(expand('{{ \'["a","b"]\' | require_json }}'), '["a","b"]')
+  end)
+
+  test("require_json: works with env var", function()
+    assert_equal(expand_env('{{ env.MODULES | require_json }}', { MODULES = '["mod1","mod2"]' }), '["mod1","mod2"]')
+  end)
+
+  test("require_json: crashes on invalid", function()
+    local ok, err = pcall(expand, '{% set x = "[unclosed" | require_json("BAD_JSON") %}')
+    assert_false(ok)
+    assert_match('BAD_JSON', err)
+  end)
+
+  -- require_size / tobytes
+  test("require_size: accepts valid sizes", function()
+    for _, v in ipairs({"150Mb", "1Gb", "512Kb", "1024", "0", "100b", "0Kb"}) do
+      assert_equal(expand('{{ "' .. v .. '" | require_size }}'), v)
+    end
+  end)
+
+  test("require_size: works with env var", function()
+    assert_equal(expand_env('{{ env.MAX_SIZE | require_size }}', { MAX_SIZE = "150Mb" }), '150Mb')
+  end)
+
+  test("require_size: crashes on invalid", function()
+    local ok, err = pcall(expand, '{{ "abc" | require_size("BAD_SIZE") }}')
+    assert_false(ok)
+    assert_match('BAD_SIZE', err)
+  end)
+
+  test("require_size: crashes on negative", function()
+    local ok, err = pcall(expand, '{{ "-5Mb" | require_size("NEG_SIZE") }}')
+    assert_false(ok)
+    assert_match('NEG_SIZE', err)
+  end)
+
+  test("tobytes: converts correctly", function()
+    assert_equal(expand('{{ "150Mb" | tobytes }}'), '157286400')
+    assert_equal(expand('{{ "1Gb" | tobytes }}'), '1073741824')
+    assert_equal(expand('{{ "512Kb" | tobytes }}'), '524288')
+    assert_equal(expand('{{ "1024" | tobytes }}'), '1024')
+    assert_equal(expand('{{ "100b" | tobytes }}'), '100')
+    assert_equal(expand('{{ "0" | tobytes }}'), '0')
+  end)
+
+  -- =========================================================================
+  -- PARSING FILTERS
+  -- =========================================================================
+
+  -- fromjson
+  test("fromjson: parse JSON object", function()
+    assert_equal(expand('{% set obj = \'{"a":1,"b":"hello"}\' | fromjson %}{{ obj.a }},{{ obj.b }}'), '1,hello')
+  end)
+
+  test("fromjson: parse JSON array", function()
+    assert_equal(expand('{% set arr = \'["x","y","z"]\' | fromjson %}{{ arr[1] }},{{ arr[2] }},{{ arr[3] }}'), 'x,y,z')
+  end)
+
+  test("fromjson: works with env var", function()
+    assert_equal(expand_env('{% set arr = env.LIST | fromjson %}{{ arr[1] }},{{ arr[2] }}', { LIST = '["a","b"]' }), 'a,b')
+  end)
+
+  test("fromjson: nested object access", function()
+    assert_equal(expand('{% set obj = \'{"a":{"b":{"c":"deep"}}}\' | fromjson %}{{ obj.a.b.c }}'), 'deep')
+  end)
+
+  test("fromjson: iterate array with for loop", function()
+    assert_equal(expand('{% set arr = \'["x","y","z"]\' | fromjson %}{% for item in arr %}{{ item }}{% endfor %}'), 'xyz')
+  end)
+
+  -- split
+  test("split: basic CSV", function()
+    assert_equal(expand('{% set arr = "a,b,c" | split(",") %}{{ arr[1] }},{{ arr[2] }},{{ arr[3] }}'), 'a,b,c')
+  end)
+
+  test("split: with max_splits", function()
+    assert_equal(expand('{% set arr = "a,b,c,d" | split(",", 2) %}{{ arr | length }}'), '3')
+  end)
+
+  -- trim
+  test("trim: strips whitespace", function()
+    assert_equal(expand('{{ "  hello  " | trim }}'), 'hello')
+  end)
+
+  test("trim: tabs and newlines", function()
+    assert_equal(expand('{{ "\t hello \n" | trim }}'), 'hello')
+  end)
+
+  -- =========================================================================
+  -- TYPE TESTS (all must work with string inputs like env vars)
+  -- =========================================================================
+
+  -- is_defined / is_undefined / is_nil / is_none
+  test("is_defined: true for set env var", function()
+    assert_equal(expand_env('{% if is_defined(env.X) %}yes{% else %}no{% endif %}', { X = "val" }), 'yes')
+  end)
+
+  test("is_defined: false for missing env var", function()
+    assert_equal(expand_env('{% if is_defined(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'no')
+  end)
+
+  test("is_nil: true for missing env var", function()
+    assert_equal(expand_env('{% if is_nil(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+  end)
+
+  test("is_none: alias for is_nil", function()
+    assert_equal(expand_env('{% if is_none(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+  end)
+
+  test("is_undefined: alias for is_nil", function()
+    assert_equal(expand_env('{% if is_undefined(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+  end)
+
+  -- is_string
+  test("is_string: true for env var (always string)", function()
+    assert_equal(expand_env('{% if is_string(env.X) %}yes{% else %}no{% endif %}', { X = "hello" }), 'yes')
+  end)
+
+  test("is_string: true for numeric env var (still string)", function()
+    assert_equal(expand_env('{% if is_string(env.X) %}yes{% else %}no{% endif %}', { X = "42" }), 'yes')
+  end)
+
+  -- is_number (string-aware)
+  test("is_number: true for integer string", function()
+    assert_equal(expand('{% if is_number("42") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_number: true for float string", function()
+    assert_equal(expand('{% if is_number("3.14") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_number: true for negative string", function()
+    assert_equal(expand('{% if is_number("-1") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_number: true for zero string", function()
+    assert_equal(expand('{% if is_number("0") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_number: false for non-numeric string", function()
+    assert_equal(expand('{% if is_number("abc") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_number: works with env var", function()
+    assert_equal(expand_env('{% if is_number(env.PORT) %}yes{% else %}no{% endif %}', { PORT = "8080" }), 'yes')
+    assert_equal(expand_env('{% if is_number(env.NAME) %}yes{% else %}no{% endif %}', { NAME = "test" }), 'no')
+  end)
+
+  test("is_number: true for actual Lua number", function()
+    assert_equal(expand('{% set x = 42 %}{% if is_number(x) %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  -- is_integer (string-aware)
+  test("is_integer: true for integer string", function()
+    assert_equal(expand('{% if is_integer("42") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_integer: false for float string", function()
+    assert_equal(expand('{% if is_integer("3.14") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_integer: true for zero string", function()
+    assert_equal(expand('{% if is_integer("0") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_integer: works with env var", function()
+    assert_equal(expand_env('{% if is_integer(env.DB) %}yes{% else %}no{% endif %}', { DB = "15" }), 'yes')
+    assert_equal(expand_env('{% if is_integer(env.PROB) %}yes{% else %}no{% endif %}', { PROB = "0.5" }), 'no')
+  end)
+
+  -- is_float
+  test("is_float: true for float string", function()
+    assert_equal(expand('{% if is_float("3.14") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_float: false for integer string", function()
+    assert_equal(expand('{% if is_float("42") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  -- is_boolean
+  test("is_boolean: false for string 'true'", function()
+    assert_equal(expand('{% if is_boolean("true") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_boolean: true for actual boolean", function()
+    assert_equal(expand('{% set x = true %}{% if is_boolean(x) %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  -- is_mapping / is_table
+  test("is_table: true for parsed JSON object", function()
+    assert_equal(expand('{% set obj = \'{"a":1}\' | fromjson %}{% if is_table(obj) %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_table: false for string", function()
+    assert_equal(expand('{% if is_table("hello") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  -- is_true / is_false (UCL-aware, string inputs)
+  test("is_true: accepts all UCL truthy strings", function()
+    for _, v in ipairs({"true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON", "On", "1"}) do
+      assert_equal(expand('{% if is_true("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_true("' .. v .. '")')
+    end
+  end)
+
+  test("is_true: rejects non-truthy", function()
+    for _, v in ipairs({"false", "no", "off", "0", "maybe", "", "2"}) do
+      assert_equal(expand('{% if is_true("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_true("' .. v .. '")')
+    end
+  end)
+
+  test("is_true: works with env var", function()
+    assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "true" }), 'yes')
+    assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "YES" }), 'yes')
+    assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "false" }), 'no')
+  end)
+
+  test("is_false: accepts all UCL falsy strings", function()
+    for _, v in ipairs({"false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", "0"}) do
+      assert_equal(expand('{% if is_false("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_false("' .. v .. '")')
+    end
+  end)
+
+  test("is_false: rejects non-falsy", function()
+    for _, v in ipairs({"true", "yes", "on", "1", "maybe", "", "2"}) do
+      assert_equal(expand('{% if is_false("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_false("' .. v .. '")')
+    end
+  end)
+
+  test("is_false: works with env var", function()
+    assert_equal(expand_env('{% if is_false(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "false" }), 'yes')
+    assert_equal(expand_env('{% if is_false(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "true" }), 'no')
+  end)
+
+  -- is_json
+  test("is_json: valid JSON", function()
+    assert_equal(expand('{% if is_json(\'["a"]\') %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_json: valid JSON object", function()
+    assert_equal(expand('{% if is_json(\'{"k":"v"}\') %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_json: invalid JSON", function()
+    assert_equal(expand('{% if is_json("{broken") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_json: empty string", function()
+    assert_equal(expand('{% if is_json("") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_json: works with env var", function()
+    assert_equal(expand_env('{% if is_json(env.DATA) %}yes{% else %}no{% endif %}', { DATA = '["a"]' }), 'yes')
+    assert_equal(expand_env('{% if is_json(env.DATA) %}yes{% else %}no{% endif %}', { DATA = 'broken' }), 'no')
+  end)
+
+  -- is_size
+  test("is_size: valid sizes", function()
+    for _, v in ipairs({"150Mb", "1Gb", "512Kb", "1024", "0", "100b", "0Kb"}) do
+      assert_equal(expand('{% if is_size("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_size("' .. v .. '")')
+    end
+  end)
+
+  test("is_size: rejects invalid", function()
+    for _, v in ipairs({"abc", "", "-5Mb"}) do
+      assert_equal(expand('{% if is_size("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_size("' .. v .. '")')
+    end
+  end)
+
+  test("is_size: works with env var", function()
+    assert_equal(expand_env('{% if is_size(env.MAX) %}yes{% else %}no{% endif %}', { MAX = "150Mb" }), 'yes')
+    assert_equal(expand_env('{% if is_size(env.MAX) %}yes{% else %}no{% endif %}', { MAX = "garbage" }), 'no')
+  end)
+
+  -- =========================================================================
+  -- NUMBER TESTS
+  -- =========================================================================
+
+  test("is_odd: works", function()
+    assert_equal(expand('{% if is_odd(3) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_odd(4) %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_even: works", function()
+    assert_equal(expand('{% if is_even(4) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_even(3) %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_divisibleby: works", function()
+    assert_equal(expand('{% if is_divisibleby(10, 5) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_divisibleby(10, 3) %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  -- =========================================================================
+  -- COMPARISON TESTS
+  -- =========================================================================
+
+  test("is_eq: string comparison", function()
+    assert_equal(expand('{% if is_eq("hello", "hello") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_eq("hello", "world") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_ne: not equal", function()
+    assert_equal(expand('{% if is_ne("a", "b") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_lt / is_gt: comparison", function()
+    assert_equal(expand('{% if is_lt(1, 2) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_gt(2, 1) %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_le / is_ge: comparison", function()
+    assert_equal(expand('{% if is_le(1, 1) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_ge(1, 1) %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  test("is_eq: works with env var", function()
+    assert_equal(expand_env('{% if is_eq(env.MODE, "strict") %}yes{% else %}no{% endif %}', { MODE = "strict" }), 'yes')
+    assert_equal(expand_env('{% if is_eq(env.MODE, "strict") %}yes{% else %}no{% endif %}', { MODE = "lax" }), 'no')
+  end)
+
+  -- =========================================================================
+  -- STRING TESTS
+  -- =========================================================================
+
+  test("is_in: substring check", function()
+    assert_equal(expand('{% if is_in("@", "user@domain.com") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_in("@", "nodomain") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_in: table membership", function()
+    assert_equal(expand('{% set t = ["a","b","c"] %}{% if is_in("b", t) %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% set t = ["a","b","c"] %}{% if is_in("d", t) %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_startswith: works", function()
+    assert_equal(expand('{% if is_startswith("hello world", "hello") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_startswith("hello world", "world") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_endswith: works", function()
+    assert_equal(expand('{% if is_endswith("hello.min", "min") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_endswith("hello.min", "max") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_match: Lua pattern", function()
+    assert_equal(expand('{% if is_match("hello123", "^%a+%d+$") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_match("hello", "^%d+$") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_lower: works", function()
+    assert_equal(expand('{% if is_lower("hello") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_lower("Hello") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_upper: works", function()
+    assert_equal(expand('{% if is_upper("HELLO") %}yes{% else %}no{% endif %}'), 'yes')
+    assert_equal(expand('{% if is_upper("Hello") %}yes{% else %}no{% endif %}'), 'no')
+  end)
+
+  test("is_sameas: alias for is_eq", function()
+    assert_equal(expand('{% if is_sameas("a", "a") %}yes{% else %}no{% endif %}'), 'yes')
+  end)
+
+  -- =========================================================================
+  -- CONTROL FLOW AND SCOPING
+  -- =========================================================================
+
+  test("variables set in if blocks persist after endif", function()
+    assert_equal(expand('{% set x = "original" %}{% if 1 == 1 %}{% set x = "redefined" %}{% endif %}{{ x }}'), 'redefined')
+  end)
+
+  test("elseif works", function()
+    assert_equal(expand('{% set x = "b" %}{% if x == "a" %}A{% elseif x == "b" %}B{% else %}C{% endif %}'), 'B')
+  end)
+
+  test("range is 1-based", function()
+    assert_equal(expand('{% for i in range(3) %}{{ i }},{% endfor %}'), '1,2,3,')
+  end)
+
+  test("range with start and stop", function()
+    assert_equal(expand('{% for i in range(2, 5) %}{{ i }},{% endfor %}'), '2,3,4,5,')
+  end)
+
+  test("loop.last works in for", function()
+    assert_equal(expand('{% set arr = ["a","b","c"] %}{% for item in arr %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}'), 'a,b,c')
+  end)
+
+  test("loop.index works in for", function()
+    assert_equal(expand('{% for i in range(3) %}{{ loop.index }},{% endfor %}'), '1,2,3,')
+  end)
+
+  -- =========================================================================
+  -- REAL-WORLD ENV VAR PATTERNS
+  -- =========================================================================
+
+  test("pattern: boolean env var with is_true", function()
+    assert_equal(
+      expand_env('{% if is_true(env.FEATURE_ENABLED) %}enabled = true;{% else %}enabled = false;{% endif %}', { FEATURE_ENABLED = "yes" }),
+      'enabled = true;'
+    )
+  end)
+
+  test("pattern: default with require_bool", function()
+    assert_equal(
+      expand_env('{% set enabled = env.FEATURE | default "true" | require_bool %}enabled = {{ enabled }};', {}),
+      'enabled = true;'
+    )
+  end)
+
+  test("pattern: duration with capping", function()
+    assert_equal(
+      expand_env('{% set timeout = env.TIMEOUT | default "30s" | require_duration %}{% if timeout > 300 %}{% set timeout = "5min" %}{% endif %}timeout = {{ timeout }};', { TIMEOUT = "600s" }),
+      'timeout = 5min;'
+    )
+  end)
+
+  test("pattern: JSON array iteration", function()
+    assert_equal(
+      expand_env('{% set modules = env.DISABLED | default \'["a"]\' | fromjson %}{% for m in modules %}{{ m }},{% endfor %}', { DISABLED = '["x","y","z"]' }),
+      'x,y,z,'
+    )
+  end)
+
+  test("pattern: size validation with tobytes", function()
+    assert_equal(
+      expand_env('{% set bytes = env.MAX_SIZE | default "150Mb" | require_size | tobytes %}{{ bytes }}', { MAX_SIZE = "1Gb" }),
+      '1073741824'
+    )
+  end)
+
+  test("pattern: conditional validation with mandatory", function()
+    local ok, err = pcall(expand_env,
+      '{% set pct = env.PERCENT | default "0.5" | require_number %}{% if (pct | float) >= 1 %}{% set _err = "" | mandatory("PERCENT must be < 1") %}{% endif %}',
+      { PERCENT = "1.5" }
+    )
+    assert_false(ok)
+    assert_match('PERCENT must be < 1', err)
+  end)
+end)