zscripts-token-savers/tests/bash/helpers.bats
kellymichels 37a703ce19
test: bats suite for the bash port, + fix underscore-key guard (phase 5 of #26) (#32)
* test: add bats suite for the bash port + fix underscore-key guard (phase 5)

Part of #26. Closes #30.

The bash port reimplements the exclude lists, config accessors and argument
parsing, so it can drift from PowerShell independently. 50 bats tests mirror
the Pester suites assertion-for-assertion where the two are meant to agree:
z_archive_excludes (per-kind lists and the deploy-vs-backup gating), config
accessors, z_path Windows->WSL translation, json_build_label, and argument
handling (bare invocation, unknown key, 'all' expansion, --port override).

Fixes #30 along the way, because the alternative was a test enshrining the bug:
zproj_require accepted underscore comment keys. It only checked the key was
non-null, and a comment is a non-null JSON string, so 'zkill _note' sailed
through and exited 0 having done nothing - the silent-success failure mode.
Now rejects any _-prefixed key and requires the value to be a JSON object.
Both checks earn their place: the type check catches string comments, the
prefix rule catches an object-valued _template key that PowerShell refuses and
the type check alone would allow.

Documents #31 rather than fixing it: a leading dash on a project key works in
every PowerShell script but only in bash/zdeploy - the others reject -myapp as
an unknown option. Stripping it everywhere would make a mistyped flag resolve
as a project key, so the tests pin current behaviour and bash/README.md now
states the difference instead of the README's blanket claim.

Verified by mutation testing: all 9 mutations turn the suite red - removing the
python and vite backup gates, reintroducing #23 in bash, unfiltering underscore
keys in zproj_keys and zproj_require, breaking zremote_compose_dir fallback and
z_path translation, and removing zkill's all-expansion and no-args guard. Both
mutated files confirmed restored byte-for-byte.

An early run also caught a bug in the tests themselves: the membership helper
used 'grep -qx' (regex), so the needle '.env' matched 'venv' and several
'excludes .env' assertions were false passes. Now uses -qxF.

* fix(gitattributes): keep .bats files LF so 'bats tests/bash' works on a Windows checkout

The LF rule was scoped to 'bash/**', which does not match tests/bash/. With
core.autocrlf a Windows working copy got CRLF .bats files, and bats fails on
them - so the command the README documents would not run on the machine the
suite was written on without stripping \r first.

Adds tests/bash/** and *.bats to the same eol=lf rule and renormalises.
Verified by running 'bats tests/bash' with no sed preprocessing: 50/50.
2026-07-26 13:02:23 -05:00

238 lines
8.5 KiB
Bash

#!/usr/bin/env bats
# Evomedia.net Token Savers - https://github.com/kellymichels/zscripts-token-savers
# Created by Kelly Michels - dev@evomedia.net
# Licensed under the MIT License. See LICENSE.
#
# helpers.bats - coverage for the pure/config functions in bash/zhelpers.sh.
#
# bats tests/bash
#
# The bash port has its own implementation of the exclude lists and config
# accessors, so it can drift from the PowerShell side independently. These
# mirror tests/ZHelpers.Tests.ps1 assertion-for-assertion where the two are
# meant to agree.
#
# A fixture config is injected through ZCONFIG (zhelpers.sh has always honored
# it), so no real zconfig.json is read.
setup() {
REPO="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
export ZCONFIG="$BATS_TEST_TMPDIR/zconfig.json"
cat > "$ZCONFIG" <<'JSON'
{
"ec2": { "ip": "203.0.113.10", "user": "testuser", "pemKey": "/fixtures/test.pem", "stackRoot": "/home/testuser/stack" },
"paths": { "temp": "/fixtures/temp", "backupsLocal": "/fixtures/backups", "backupsEc2": "/fixtures/backups-ec2", "scriptsRoot": "/fixtures/zscripts", "oneDriveBackups": "" },
"projects": {
"_note": "comment key - must never be treated as a project",
"pyapp": {
"label": "Fixture Python App", "kind": "python", "localRoot": "/fixtures/pyapp",
"startModule": "pyapp.main", "ports": { "dev": 59990 }, "domain": "pyapp.example.com",
"remote": { "path": "/home/testuser/stack/pyapp", "composeDir": "/home/testuser/stack/pyapp/docker", "appService": "app" },
"deploy": { "zipName": "PyAppDeploy.zip", "exclude": ["docs", "fixtures-extra"] }
},
"viteapp": {
"label": "Fixture Vite Site", "kind": "vite", "localRoot": "/fixtures/viteapp",
"ports": { "dev": 59991 }, "domain": "www.example.com",
"remote": { "path": "/home/testuser/stack/viteapp" }
},
"nextapp": {
"label": "Fixture Next App", "kind": "nextjs", "localRoot": "/fixtures/nextapp",
"ports": { "dev": 59992 }, "remote": { "path": "/home/testuser/stack/nextapp" }
},
"edgeproxy": {
"label": "Fixture Edge", "kind": "edge", "localRoot": "/fixtures/edge",
"proxyContainer": "edge_proxy", "remote": { "path": "/home/testuser/stack/edge" }
},
"noports": {
"label": "Fixture No Dev Port", "kind": "python", "localRoot": "/fixtures/noports",
"remote": { "path": "/home/testuser/stack/noports" }
}
}
}
JSON
# shellcheck disable=SC1090
source "$REPO/bash/zhelpers.sh"
}
# Emit the exclude list for a project; $2=1 means "for backup".
excludes() { z_archive_excludes "$1" "${2:-0}"; }
# Fixed-string, whole-line match. -F matters: without it grep treats the needle
# as a regex, and '.env' would match 'venv' (leading '.' = any char) - which
# silently turns every ".env is excluded" assertion into a false pass.
has() { printf '%s\n' "$1" | grep -qxF "$2"; }
# ---- config accessors -------------------------------------------------------
@test "zproj_keys returns every project in config order" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys | paste -sd, -"
[ "$status" -eq 0 ]
[ "$output" = "pyapp,viteapp,nextapp,edgeproxy,noports" ]
}
@test "zproj_keys omits underscore comment keys" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys"
[ "$status" -eq 0 ]
! has "$output" "_note"
}
@test "zproj reads a project field" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj pyapp .label"
[ "$output" = "Fixture Python App" ]
}
@test "zproj returns empty for a missing field rather than the string null" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj viteapp .startModule"
[ "$output" = "" ]
}
@test "zec2_target composes user@ip" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zec2_target"
[ "$output" = "testuser@203.0.113.10" ]
}
@test "zremote_compose_dir prefers composeDir" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zremote_compose_dir pyapp"
[ "$output" = "/home/testuser/stack/pyapp/docker" ]
}
@test "zremote_compose_dir falls back to remote.path" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zremote_compose_dir viteapp"
[ "$output" = "/home/testuser/stack/viteapp" ]
}
@test "zedge_key finds the first edge-kind project" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zedge_key"
[ "$output" = "edgeproxy" ]
}
@test "zproj_keys_with_domain lists only projects that have a domain" {
run bash -c "source '$REPO/bash/zhelpers.sh'; zproj_keys_with_domain | paste -sd, -"
[ "$output" = "pyapp,viteapp" ]
}
# ---- z_path (Windows -> host path translation) ------------------------------
# A shared zconfig.json may carry Windows paths when the same file is used from
# a Windows checkout and from WSL.
@test "z_path translates a Windows drive path to /mnt form" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path 'F:\\evomedia.net\\app'"
[ "$output" = "/mnt/f/evomedia.net/app" ]
}
@test "z_path lowercases the drive letter" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path 'C:\\Users\\Someone'"
[ "$output" = "/mnt/c/Users/Someone" ]
}
@test "z_path leaves a unix path untouched (no-op on native Linux/macOS)" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path '/home/kelly/project'"
[ "$output" = "/home/kelly/project" ]
}
@test "z_path leaves an empty string empty" {
run bash -c "source '$REPO/bash/zhelpers.sh'; z_path ''"
[ "$output" = "" ]
}
# ---- z_archive_excludes -----------------------------------------------------
# Parity target: tests/ZHelpers.Tests.ps1 "Get-ArchiveExcludes".
@test "excludes: common junk applies to every kind" {
run excludes pyapp
for n in .git .idea .vscode .claude tmp nul .DS_Store backups; do
has "$output" "$n" || { echo "missing common exclude: $n"; return 1; }
done
}
@test "excludes: python build/venv cruft" {
run excludes pyapp
for n in .venv venv __pycache__ .pytest_cache .nicegui archive dist build htmlcov; do
has "$output" "$n" || { echo "missing python exclude: $n"; return 1; }
done
}
@test "excludes: vite drops node_modules and dist" {
run excludes viteapp
has "$output" "node_modules"
has "$output" "dist"
}
@test "excludes: nextjs drops .next and .vercel" {
run excludes nextapp
has "$output" ".next"
has "$output" ".vercel"
has "$output" "next-env.d.ts"
}
# The regression this suite exists for: a dev .env shipped to production in a
# deploy zip. Deploys drop secrets and uploads; backups must keep them or the
# backup is not a restore point.
@test "excludes: python DEPLOY drops .env, .env.local, .env.production and uploads" {
run excludes pyapp 0
for n in .env .env.local .env.production uploads; do
has "$output" "$n" || { echo "deploy should exclude: $n"; return 1; }
done
}
@test "excludes: python BACKUP keeps .env, .env.local, .env.production and uploads" {
run excludes pyapp 1
for n in .env .env.local .env.production uploads; do
if has "$output" "$n"; then echo "backup must NOT exclude: $n"; return 1; fi
done
}
@test "excludes: python BACKUP still drops the virtualenv (bulk, not a secret)" {
run excludes pyapp 1
has "$output" ".venv"
}
@test "excludes: vite DEPLOY drops .env files" {
run excludes viteapp 0
for n in .env .env.local .env.production; do
has "$output" "$n" || { echo "deploy should exclude: $n"; return 1; }
done
}
@test "excludes: vite BACKUP keeps .env files" {
run excludes viteapp 1
for n in .env .env.local .env.production; do
if has "$output" "$n"; then echo "backup must NOT exclude: $n"; return 1; fi
done
}
@test "excludes: vite BACKUP still drops node_modules" {
run excludes viteapp 1
has "$output" "node_modules"
}
# nextjs has no backup gate on the PowerShell side either - both agree.
@test "excludes: nextjs drops .env in a backup too (no gate, matches PowerShell)" {
run excludes nextapp 1
has "$output" ".env"
}
@test "excludes: the project's own deploy.exclude entries are merged in" {
run excludes pyapp
has "$output" "docs"
has "$output" "fixtures-extra"
}
@test "excludes: a kind with no specific list still gets the common entries" {
run excludes edgeproxy
has "$output" ".git"
}
# ---- json_build_label -------------------------------------------------------
@test "json_build_label formats v<productVersion>.<buildNumber>" {
printf '{ "productVersion": "1.0", "buildNumber": 42 }' > "$BATS_TEST_TMPDIR/bv.json"
run bash -c "source '$REPO/bash/zhelpers.sh'; json_build_label '$BATS_TEST_TMPDIR/bv.json'"
[ "$output" = "v1.0.42" ]
}
@test "json_build_label reports unknown for a missing file" {
run bash -c "source '$REPO/bash/zhelpers.sh'; json_build_label '$BATS_TEST_TMPDIR/nope.json'"
[ "$output" = "unknown" ]
}