Files
relspecgo/pkg/jobs/jobs_validate_test.go
T
warkanum 495a21b67b test: expand coverage across readers, writers, cmd, ui, diff and merge
Implements tests/_plans and previously deferred packages; updates plan
README with new coverage numbers.
2026-10-03 21:33:59 +02:00

263 lines
15 KiB
Go

package jobs
import (
"os"
"path/filepath"
"strings"
"testing"
)
// validateYAML loads one job file and returns the Validate error text ("" when valid).
func validateYAML(t *testing.T, body string) string {
t.Helper()
set := loadOne(t, "version: 1\njobs:\n"+body)
if err := set.Validate(); err != nil {
return err.Error()
}
return ""
}
func TestValidateJobTable(t *testing.T) {
in := " inputs:\n - path: a.dbml\n format: dbml\n"
out := " output:\n format: json\n path: out.json\n"
tests := []struct {
name string
job string
want string // substring of the error, "" for valid
}{
{"missing command", " x:\n description: d\n", "missing command"},
{"convert valid", " x:\n command: convert\n" + in + out, ""},
{"convert script dirs", " x:\n command: convert\n script_dirs: [s]\n" + in + out, "script_dirs is not valid"},
{"convert missing output", " x:\n command: convert\n" + in, "missing output"},
{"convert output missing format", " x:\n command: convert\n" + in + " output:\n path: o\n", "output: missing format"},
{"convert output unsupported format", " x:\n command: convert\n" + in + " output:\n format: nope\n path: o\n", "unsupported output format"},
{"convert output missing path", " x:\n command: convert\n" + in + " output:\n format: json\n", "output: missing path"},
{"convert output conn_env on non-exec format", " x:\n command: convert\n" + in + " output:\n format: json\n conn_env: DB\n", "not supported for format"},
{"convert output path and conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n path: o.sql\n", "either path or conn_env"},
{"convert output conn_env ok", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: DB\n", ""},
{"output secret conn_env", " x:\n command: convert\n" + in + " output:\n format: pgsql\n conn_env: postgres://u:p@h/db\n", "environment variable name"},
{"merge needs two inputs", " x:\n command: merge\n" + in + out, "at least 2 input"},
{"input missing format", " x:\n command: convert\n inputs:\n - path: a\n" + out, "missing format"},
{"input unsupported format", " x:\n command: convert\n inputs:\n - path: a\n format: nope\n" + out, "unsupported input format"},
{"input file missing path", " x:\n command: convert\n inputs:\n - format: dbml\n" + out, "missing path"},
{"input file with conn_env", " x:\n command: convert\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n" + out, "does not use conn_env"},
{"input db missing conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n" + out, "requires conn_env"},
{"input db with path", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n" + out, "takes conn_env, not path"},
{"input db ok", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: DB\n" + out, ""},
{"input secret conn_env", " x:\n command: convert\n inputs:\n - format: pgsql\n conn_env: \"host=h password=p\"\n" + out, "environment variable name"},
{"bad log size", " x:\n command: convert\n log_max_size: lots\n" + in + out, "log_max_size"},
{"absolute logfile", " x:\n command: convert\n logfile: /var/log/x.log\n" + in + out, "absolute paths"},
{"home path", " x:\n command: convert\n template: ~/t\n" + in + out, "home-relative"},
{"report path traversal", " x:\n command: inspect\n" + in + " report:\n format: json\n path: ../r.json\n", "escapes"},
{"script_dir traversal", " x:\n command: scripts-list\n script_dirs: [../x]\n", "escapes"},
{"templ valid", " x:\n command: templ\n" + in + " template: t.tmpl\n mode: table\n output:\n format: text\n path: o\n", ""},
{"templ pgsql input valid", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n template: t.tmpl\n", ""},
{"templ no inputs", " x:\n command: templ\n template: t.tmpl\n", "at least 1 input"},
{"templ no template", " x:\n command: templ\n" + in, "requires template"},
{"templ bad mode", " x:\n command: templ\n" + in + " template: t\n mode: weird\n", "unsupported mode"},
{"templ script dirs", " x:\n command: templ\n" + in + " template: t\n script_dirs: [s]\n", "script_dirs is not valid"},
{"templ db output", " x:\n command: templ\n" + in + " template: t\n output:\n conn_env: DB\n", "does not support database output"},
{"templ non-text output", " x:\n command: templ\n" + in + " template: t\n output:\n format: json\n path: o\n", "only output.format: text"},
{"templ input missing format", " x:\n command: templ\n inputs:\n - path: a\n template: t\n", "missing format"},
{"templ pgsql input without conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n template: t\n", "requires conn_env"},
{"templ pgsql input with path", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: DB\n path: a\n template: t\n", "takes conn_env, not path"},
{"templ file input without path", " x:\n command: templ\n inputs:\n - format: dbml\n template: t\n", "missing path"},
{"templ file input with conn_env", " x:\n command: templ\n inputs:\n - path: a\n format: dbml\n conn_env: DB\n template: t\n", "does not use conn_env"},
{"templ unsupported input format", " x:\n command: templ\n inputs:\n - path: a\n format: nope\n template: t\n", "unsupported templ input format"},
{"templ secret conn_env", " x:\n command: templ\n inputs:\n - format: pgsql\n conn_env: a/b\n template: t\n", "environment variable name"},
{"split needs input", " x:\n command: split\n" + out, "at least 1 input"},
{"split script dirs", " x:\n command: split\n" + in + " script_dirs: [s]\n" + out, "script_dirs is not valid"},
{"split report", " x:\n command: split\n" + in + " report:\n format: json\n path: r\n" + out, "report is not valid"},
{"split db output", " x:\n command: split\n" + in + " output:\n format: pgsql\n conn_env: DB\n", "writes a file"},
{"inspect script dirs", " x:\n command: inspect\n" + in + " script_dirs: [s]\n report:\n path: r\n", "script_dirs is not valid"},
{"inspect output", " x:\n command: inspect\n" + in + out + " report:\n path: r\n", "output is not valid"},
{"inspect bad report format", " x:\n command: inspect\n" + in + " report:\n format: html\n path: r\n", "not supported"},
{"inspect report without path", " x:\n command: inspect\n" + in + " report:\n format: json\n", "requires report.path"},
{"inspect default format ok", " x:\n command: inspect\n" + in + " report:\n path: r.md\n", ""},
{"diff summary without path ok", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: summary\n", ""},
{"diff json needs path", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n report:\n format: json\n", "requires report.path"},
{"diff output", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n" + out + " report:\n format: summary\n", "output is not valid"},
{"diff script dirs", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n script_dirs: [s]\n report:\n format: summary\n", "script_dirs is not valid"},
{"diff no report", " x:\n command: diff\n" + in + " - path: b.dbml\n format: dbml\n", "requires a report block"},
{"scripts-list inputs", " x:\n command: scripts-list\n script_dirs: [s]\n" + in, "inputs is not valid"},
{"scripts-list output", " x:\n command: scripts-list\n script_dirs: [s]\n" + out, "output is not valid"},
{"scripts-exec inputs", " x:\n command: scripts-exec\n script_dirs: [s]\n" + in + " output:\n conn_env: DB\n", "inputs is not valid"},
{"scripts-exec report", " x:\n command: scripts-exec\n script_dirs: [s]\n report:\n path: r\n output:\n conn_env: DB\n", "report is not valid"},
{"scripts-exec output path", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n path: p\n", "output.path is not supported"},
{"scripts-exec non-pgsql", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: mssql\n", "only supports pgsql"},
{"scripts-exec secret conn_env", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: \"postgres://u@h/d\"\n", "environment variable name"},
{"scripts-exec no script dirs", " x:\n command: scripts-exec\n output:\n conn_env: DB\n", "requires at least one script_dir"},
{"scripts-exec pgsql format ok", " x:\n command: scripts-exec\n script_dirs: [s]\n output:\n conn_env: DB\n format: pgsql\n", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := validateYAML(t, tt.job)
if tt.want == "" {
if got != "" {
t.Errorf("expected valid, got: %s", got)
}
return
}
if !strings.Contains(got, tt.want) {
t.Errorf("error %q does not contain %q", got, tt.want)
}
})
}
}
func TestFromJobInputShape(t *testing.T) {
producer := " p:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: out.json\n"
tests := []struct {
name string
input string
want string
}{
{"path", " - from_job: p\n path: x\n", "takes no path"},
{"format", " - from_job: p\n format: json\n", "drop format"},
{"conn_env", " - from_job: p\n conn_env: DB\n", "takes no conn_env"},
}
for _, tt := range tests {
for _, cmd := range []string{"convert", "templ"} {
t.Run(cmd+"/"+tt.name, func(t *testing.T) {
extra := " output:\n format: json\n path: o.json\n"
if cmd == "templ" {
extra = " template: t.tmpl\n"
}
got := validateYAML(t, producer+" c:\n command: "+cmd+"\n inputs:\n"+tt.input+extra)
if !strings.Contains(got, tt.want) {
t.Errorf("error %q does not contain %q", got, tt.want)
}
})
}
}
}
func TestResolvedLogPolicy(t *testing.T) {
keep2 := 2
keep0 := 0
tests := []struct {
name string
job Job
want LogPolicy
}{
{"built-in defaults", Job{}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
{"file defaults", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}}, LogPolicy{MaxSizeBytes: 1 << 20, Keep: 7}},
{"file defaults invalid size falls back", Job{fileDefaults: &Defaults{LogMaxSize: "junk", LogKeep: 0}}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
{"job overrides file", Job{fileDefaults: &Defaults{LogMaxSize: "1MB", LogKeep: 7}, LogMaxSize: "2kb", LogKeep: &keep2}, LogPolicy{MaxSizeBytes: 2 << 10, Keep: 2}},
{"job keep zero is honoured", Job{LogKeep: &keep0}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: 0}},
{"job invalid size ignored", Job{LogMaxSize: "junk"}, LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.job.ResolvedLogPolicy(); got != tt.want {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestLoadAppliesFileDefaultsAndDir(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "relspec.yml")
write(t, p, "version: 1\ndefaults:\n log_max_size: 1MB\n log_keep: 9\n"+"jobs:\n a:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n")
set, err := Load([]string{p})
if err != nil {
t.Fatal(err)
}
job := set.Jobs["a"]
if job.Dir() != dir {
t.Errorf("Dir = %q, want %q", job.Dir(), dir)
}
if pol := job.ResolvedLogPolicy(); pol.MaxSizeBytes != 1<<20 || pol.Keep != 9 {
t.Errorf("policy %+v", pol)
}
}
func TestSetNamesSorted(t *testing.T) {
set := &Set{Jobs: map[string]*Job{"b": {}, "a": {}, "c": {}}}
if got := strings.Join(set.Names(), ","); got != "a,b,c" {
t.Errorf("got %s", got)
}
}
func TestPlanErrors(t *testing.T) {
set := loadOne(t, "version: 1\njobs:\n"+
" a:\n command: convert\n depends_on: [ghost]\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o.json\n"+
" b:\n command: convert\n inputs:\n - path: a.dbml\n format: dbml\n output:\n format: json\n path: o2.json\n")
if _, err := set.Plan("nope", true); err == nil || !strings.Contains(err.Error(), "unknown job") || !strings.Contains(err.Error(), "a, b") {
t.Errorf("unknown job: %v", err)
}
if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "unknown job \"ghost\"") {
t.Errorf("unknown dependency: %v", err)
}
// Without dependencies the declared dependency is not walked.
if got, err := set.Plan("a", false); err != nil || len(got) != 1 || got[0].Name != "a" {
t.Errorf("no-deps plan: %v %v", got, err)
}
}
func TestPlanCycleAtRuntime(t *testing.T) {
set := &Set{Jobs: map[string]*Job{
"a": {Name: "a", DependsOn: []string{"b"}},
"b": {Name: "b", DependsOn: []string{"a"}},
}}
if _, err := set.Plan("a", true); err == nil || !strings.Contains(err.Error(), "cycle") {
t.Errorf("want cycle error, got %v", err)
}
}
func TestDiscoverErrorsAndFiltering(t *testing.T) {
if _, err := Discover(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("missing dir must fail")
}
dir := t.TempDir()
for _, f := range []string{"relspec.yaml", "relspec.b.yml", "relspec.a.yaml", "relspec.txt", "other.yml", "relspec"} {
write(t, filepath.Join(dir, f), "")
}
if err := os.Mkdir(filepath.Join(dir, "relspec.dir.yml"), 0o755); err != nil {
t.Fatal(err)
}
got, err := Discover(dir)
if err != nil {
t.Fatal(err)
}
var names []string
for _, p := range got {
names = append(names, filepath.Base(p))
}
if strings.Join(names, ",") != "relspec.yaml,relspec.a.yaml,relspec.b.yml" {
t.Errorf("got %v", names)
}
}
func TestSafeJoinCases(t *testing.T) {
root := t.TempDir()
if got, err := SafeJoin(root, "sub/file.sql"); err != nil || !strings.HasSuffix(got, filepath.Join("sub", "file.sql")) {
t.Errorf("nested: %q %v", got, err)
}
for _, bad := range []string{"", "/etc/passwd", "~/x", "..", "../x", "a/../../x"} {
if _, err := SafeJoin(root, bad); err == nil {
t.Errorf("SafeJoin(%q) must fail", bad)
}
}
if _, err := SafeJoin(filepath.Join(root, "does", "not", "exist"), "x"); err == nil {
t.Error("unresolvable root must fail")
}
}
func TestLooksLikeSecret(t *testing.T) {
for in, want := range map[string]bool{
"": false, "DB_URL": false, "MY_DB": false,
"postgres://u:p@h/db": true, "host=h": true, "a b": true, "a/b": true, "u@h": true, "k:v": true,
} {
if got := looksLikeSecret(in); got != want {
t.Errorf("looksLikeSecret(%q) = %v, want %v", in, got, want)
}
}
}