package main import ( "bytes" "os" "path/filepath" "strings" "testing" "github.com/spf13/cobra" "git.warky.dev/wdevs/relspecgo/pkg/jobs" ) func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } // jobFixture creates a job-file project with two DBML sources and returns the // project directory. func jobFixture(t *testing.T, manifest string) string { t.Helper() dir := t.TempDir() writeFile(t, filepath.Join(dir, "schema", "core.dbml"), "Table users {\n id int [pk]\n name varchar\n}\n") writeFile(t, filepath.Join(dir, "schema", "tenant.dbml"), "Table posts {\n id int [pk]\n title varchar\n}\n") writeFile(t, filepath.Join(dir, "relspec.yml"), manifest) return dir } func mustLoadSet(t *testing.T, files ...string) *jobs.Set { t.Helper() set, err := jobs.Load(files) if err != nil { t.Fatalf("load: %v", err) } if err := set.Validate(); err != nil { t.Fatalf("validate: %v", err) } return set } const convertMergeManifest = `version: 1 jobs: build-schema: command: convert description: Merge DBML sources to PostgreSQL DDL inputs: - path: schema/core.dbml format: dbml - path: schema/tenant.dbml format: dbml output: format: pgsql path: build/schema.sql overwrite: true logfile: .relspec/log/build.log ` func TestJobRun_ConvertMultiFileMerge(t *testing.T) { dir := jobFixture(t, convertMergeManifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "build-schema", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("executeJobPlan: %v", err) } out, err := os.ReadFile(filepath.Join(dir, "build", "schema.sql")) if err != nil { t.Fatalf("expected output file: %v", err) } sql := string(out) if !strings.Contains(sql, "users") || !strings.Contains(sql, "posts") { t.Fatalf("merged output missing tables:\n%s", sql) } logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "log", "build.log")) if err != nil { t.Fatalf("expected logfile: %v", err) } if !strings.Contains(string(logData), "OK") { t.Fatalf("logfile missing success marker:\n%s", logData) } } func TestJobRun_DryRunDoesNotExecute(t *testing.T) { dir := jobFixture(t, convertMergeManifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) var buf bytes.Buffer if err := executeJobPlan(set, "build-schema", true, false, &buf); err != nil { t.Fatalf("dry run error: %v", err) } if !strings.Contains(buf.String(), "dry run") { t.Fatalf("expected dry-run banner, got: %s", buf.String()) } if _, err := os.Stat(filepath.Join(dir, "build", "schema.sql")); !os.IsNotExist(err) { t.Fatal("dry run must not create the output file") } if _, err := os.Stat(filepath.Join(dir, ".relspec", "log", "build.log")); !os.IsNotExist(err) { t.Fatal("dry run must not create the logfile") } } func TestJobRun_ValidationFailureNoExecution(t *testing.T) { badManifest := `version: 1 jobs: evil: command: convert inputs: - path: ../../../etc/passwd format: dbml output: format: json path: build/out.json logfile: .relspec/evil.log ` dir := jobFixture(t, badManifest) if _, err := jobs.Load([]string{filepath.Join(dir, "relspec.yml")}); err != nil { // structural load ok; validation should reject t.Fatalf("unexpected load error: %v", err) } set, _ := jobs.Load([]string{filepath.Join(dir, "relspec.yml")}) if err := set.Validate(); err == nil { t.Fatal("expected validation failure for path traversal") } // Nothing should have been produced. if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) { t.Fatal("validation failure must not create output dir") } if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) { t.Fatal("validation failure must not create logfile dir") } } func TestJobRun_MissingInputNoExecution(t *testing.T) { manifest := `version: 1 jobs: x: command: convert inputs: - path: schema/does-not-exist.dbml format: dbml output: format: json path: build/out.json logfile: .relspec/x.log ` dir := jobFixture(t, manifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) err := executeJobPlan(set, "x", false, false, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("expected missing-input error, got %v", err) } if _, err := os.Stat(filepath.Join(dir, "build")); !os.IsNotExist(err) { t.Fatal("missing input must not create output dir") } if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) { t.Fatal("missing input must not create logfile") } } func TestJobRun_MissingConnEnvNoExecution(t *testing.T) { manifest := `version: 1 jobs: remote: command: convert inputs: - format: pgsql conn_env: RELSPEC_TEST_MISSING_CONN output: format: json path: build/out.json logfile: .relspec/remote.log ` dir := jobFixture(t, manifest) os.Unsetenv("RELSPEC_TEST_MISSING_CONN") set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) err := executeJobPlan(set, "remote", false, false, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "conn_env") { t.Fatalf("expected missing conn_env error, got %v", err) } if _, err := os.Stat(filepath.Join(dir, ".relspec")); !os.IsNotExist(err) { t.Fatal("missing conn_env must not create logfile") } } func TestJobRun_ExitCodePropagation(t *testing.T) { // gorm output without options.package makes the underlying writer fail. manifest := `version: 1 jobs: fail: command: convert inputs: - path: schema/core.dbml format: dbml output: format: gorm path: build/models overwrite: true logfile: .relspec/fail.log ` dir := jobFixture(t, manifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) err := executeJobPlan(set, "fail", false, false, &bytes.Buffer{}) if err == nil { t.Fatal("expected underlying failure to propagate") } if !strings.Contains(err.Error(), "job \"fail\" failed") { t.Fatalf("error should identify the failing job: %v", err) } // Logfile records the failure and no misleading success marker. logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "fail.log")) if strings.Contains(string(logData), "\nOK\n") || strings.HasSuffix(strings.TrimSpace(string(logData)), "OK") { t.Fatalf("failed job must not log OK:\n%s", logData) } if !strings.Contains(string(logData), "FAILED") { t.Fatalf("failed job should log FAILED:\n%s", logData) } } func TestJobRun_DependencyChainExecutes(t *testing.T) { manifest := `version: 1 jobs: a: command: convert inputs: - path: schema/core.dbml format: dbml output: format: json path: build/a.json overwrite: true b: command: convert depends_on: [a] inputs: - path: schema/tenant.dbml format: dbml output: format: json path: build/b.json overwrite: true ` dir := jobFixture(t, manifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("executeJobPlan: %v", err) } for _, f := range []string{"a.json", "b.json"} { if _, err := os.Stat(filepath.Join(dir, "build", f)); err != nil { t.Fatalf("expected %s to be produced: %v", f, err) } } } func TestJobRun_ScriptsListMultipleDirs(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "migrations", "core", "1_001_create_users.sql"), "CREATE TABLE users();\n") writeFile(t, filepath.Join(dir, "migrations", "tenant", "1_002_create_posts.sql"), "CREATE TABLE posts();\n") writeFile(t, filepath.Join(dir, "migrations", "tenant", "2_001_add_index.sql"), "CREATE INDEX x ON posts(id);\n") manifest := `version: 1 jobs: list-all: command: scripts-list script_dirs: - migrations/core - migrations/tenant logfile: .relspec/scripts.log ` writeFile(t, filepath.Join(dir, "relspec.yml"), manifest) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "list-all", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("executeJobPlan: %v", err) } logData, err := os.ReadFile(filepath.Join(dir, ".relspec", "scripts.log")) if err != nil { t.Fatal(err) } s := string(logData) iUsers := strings.Index(s, "create_users") iPosts := strings.Index(s, "create_posts") iIndex := strings.Index(s, "add_index") if iUsers < 0 || iPosts < 0 || iIndex < 0 { t.Fatalf("expected all scripts listed:\n%s", s) } if !(iUsers < iPosts && iPosts < iIndex) { t.Fatalf("scripts not in priority/sequence order:\n%s", s) } if !strings.Contains(s, "found 3 script(s) across 2") { t.Fatalf("expected multi-directory summary:\n%s", s) } } func TestJobRun_ConnEnvRedactedInPlan(t *testing.T) { manifest := `version: 1 jobs: remote: command: convert inputs: - format: pgsql conn_env: RELSPEC_TEST_PLAN_CONN output: format: json path: build/out.json ` dir := jobFixture(t, manifest) secret := "postgres://user:supersecret@db.example/app" t.Setenv("RELSPEC_TEST_PLAN_CONN", secret) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) var buf bytes.Buffer if err := executeJobPlan(set, "remote", true, false, &buf); err != nil { t.Fatalf("dry run: %v", err) } if strings.Contains(buf.String(), "supersecret") || strings.Contains(buf.String(), secret) { t.Fatalf("plan leaked secret:\n%s", buf.String()) } if !strings.Contains(buf.String(), "env:RELSPEC_TEST_PLAN_CONN") { t.Fatalf("plan should reference the env var name:\n%s", buf.String()) } } func TestJobLogger_Redaction(t *testing.T) { lg := &jobLogger{secrets: []string{"topsecret"}} got := lg.redact("connecting with password topsecret and postgres://u:p@h/db") if strings.Contains(got, "topsecret") { t.Fatalf("secret not redacted: %q", got) } if !strings.Contains(got, "***") { t.Fatalf("expected redaction marker: %q", got) } } func TestJobList_DeterministicOutput(t *testing.T) { manifest := `version: 1 jobs: zebra: command: convert inputs: [{path: schema/core.dbml, format: dbml}] output: {format: json, path: build/z.json} alpha: command: convert inputs: [{path: schema/core.dbml, format: dbml}] output: {format: json, path: build/a.json} ` dir := jobFixture(t, manifest) run := func() string { jobDir = dir jobFiles = nil cmd := &cobra.Command{} var buf bytes.Buffer cmd.SetOut(&buf) if err := runJobList(cmd, nil); err != nil { t.Fatalf("runJobList: %v", err) } return buf.String() } first := run() if strings.Index(first, "alpha") > strings.Index(first, "zebra") { t.Fatalf("jobs not sorted:\n%s", first) } if first != run() { t.Fatal("job list output not deterministic") } } func TestJobRun_SplitJob(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: extract: command: split inputs: - path: schema/core.dbml format: dbml - path: schema/tenant.dbml format: dbml select: tables: [users] output: format: json path: build/subset.json overwrite: true `) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "extract", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute split job: %v", err) } out, err := os.ReadFile(filepath.Join(dir, "build", "subset.json")) if err != nil { t.Fatalf("read split output: %v", err) } s := string(out) if !strings.Contains(s, "users") { t.Fatalf("split output missing selected table:\n%s", s) } if strings.Contains(s, "posts") { t.Fatalf("split output should have excluded posts:\n%s", s) } } func TestJobRun_InspectJob(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: lint: command: inspect inputs: - path: schema/core.dbml format: dbml report: format: json path: build/report.json overwrite: true logfile: .relspec/lint.log `) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) // Default rules only warn, so the job succeeds. if err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute inspect job: %v", err) } if _, err := os.ReadFile(filepath.Join(dir, "build", "report.json")); err != nil { t.Fatalf("expected report file: %v", err) } logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log")) if !strings.Contains(string(logData), "inspect:") { t.Fatalf("logfile missing inspect summary:\n%s", logData) } } func TestJobRun_InspectJobFailsOnRuleError(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: lint: command: inspect inputs: - path: schema/core.dbml format: dbml rules: rules.yaml report: format: json path: build/report.json overwrite: true logfile: .relspec/lint.log `) // A rule set to "error" level for a violation the fixture triggers. writeFile(t, filepath.Join(dir, "rules.yaml"), `version: "1.0" rules: primary_key_naming: enabled: enforce function: primary_key_naming pattern: "^id_" message: "Primary key columns should start with id_" `) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) err := executeJobPlan(set, "lint", false, false, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "error(s)") { t.Fatalf("expected inspect job to fail on rule error, got %v", err) } logData, _ := os.ReadFile(filepath.Join(dir, ".relspec", "lint.log")) if !strings.Contains(string(logData), "FAILED") { t.Fatalf("failed inspect job should log FAILED:\n%s", logData) } } func TestJobRun_DiffJob(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: compare: command: diff inputs: - path: schema/core.dbml format: dbml - path: schema/tenant.dbml format: dbml report: format: json path: build/diff.json overwrite: true `) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "compare", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute diff job: %v", err) } out, err := os.ReadFile(filepath.Join(dir, "build", "diff.json")) if err != nil { t.Fatalf("read diff report: %v", err) } if len(out) == 0 { t.Fatal("diff report is empty") } } func TestJobRun_FromJobWiring(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: a: command: convert inputs: - path: schema/core.dbml format: dbml output: format: json path: build/a.json overwrite: true b: command: convert inputs: - from_job: a output: format: yaml path: build/b.yaml overwrite: true `) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "b", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute from_job chain: %v", err) } if _, err := os.Stat(filepath.Join(dir, "build", "a.json")); err != nil { t.Fatalf("producer output missing: %v", err) } out, err := os.ReadFile(filepath.Join(dir, "build", "b.yaml")) if err != nil { t.Fatalf("consumer output missing: %v", err) } if !strings.Contains(string(out), "users") { t.Fatalf("consumer did not consume producer output:\n%s", out) } } func TestJobRun_LogRotation(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: build: command: scripts-list script_dirs: [migrations] log_max_size: "150B" log_keep: 2 logfile: .relspec/build.log `) writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n") logPath := filepath.Join(dir, ".relspec", "build.log") writeFile(t, logPath, strings.Repeat("x", 300)+"\n") set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "build", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute job: %v", err) } rotated, err := os.ReadFile(logPath + ".1") if err != nil { t.Fatalf("expected rotated logfile build.log.1: %v", err) } if !strings.Contains(string(rotated), strings.Repeat("x", 300)) { t.Fatalf("rotated logfile should hold the old content") } fresh, err := os.ReadFile(logPath) if err != nil { t.Fatalf("expected fresh logfile: %v", err) } if strings.Contains(string(fresh), strings.Repeat("x", 300)) { t.Fatalf("fresh logfile should not contain the rotated-out content:\n%s", fresh) } if !strings.Contains(string(fresh), "OK") { t.Fatalf("fresh logfile should hold the new run:\n%s", fresh) } } func TestJobRun_AtomicOutputLeavesOriginalOnFailure(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: x: command: convert inputs: - path: schema/core.dbml format: dbml output: format: json path: build/out.json overwrite: true `) // Seed the destination, then make its parent directory read-only so the // rename step fails. The seeded file must survive intact. seeded := filepath.Join(dir, "build", "out.json") writeFile(t, seeded, `{"seeded":true}`) if err := os.Chmod(filepath.Join(dir, "build"), 0o500); err != nil { t.Skipf("cannot chmod: %v", err) } t.Cleanup(func() { _ = os.Chmod(filepath.Join(dir, "build"), 0o755) }) set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "x", false, false, &bytes.Buffer{}); err == nil { t.Skip("write unexpectedly succeeded (running as root?)") } if err := os.Chmod(filepath.Join(dir, "build"), 0o755); err != nil { t.Fatal(err) } data, err := os.ReadFile(seeded) if err != nil { t.Fatalf("seeded file gone: %v", err) } if !strings.Contains(string(data), "seeded") { t.Fatalf("seeded file was corrupted: %s", data) } } func TestJobRun_ScriptsExecMissingConnEnv(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: migrate: command: scripts-exec script_dirs: [migrations] output: conn_env: RELSPEC_TEST_EXEC_MISSING logfile: .relspec/migrate.log `) writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n") os.Unsetenv("RELSPEC_TEST_EXEC_MISSING") set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) err := executeJobPlan(set, "migrate", false, false, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "conn_env") { t.Fatalf("expected missing conn_env error, got %v", err) } } func TestJobRun_ScriptsExecDryRun(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: migrate: command: scripts-exec script_dirs: [migrations] output: conn_env: RELSPEC_TEST_EXEC_CONN `) writeFile(t, filepath.Join(dir, "migrations", "1_001_a.sql"), "CREATE TABLE a();\n") t.Setenv("RELSPEC_TEST_EXEC_CONN", "postgres://u:secretpw@h/db") set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) var buf bytes.Buffer if err := executeJobPlan(set, "migrate", true, false, &buf); err != nil { t.Fatalf("dry run: %v", err) } if strings.Contains(buf.String(), "secretpw") { t.Fatalf("plan leaked secret:\n%s", buf.String()) } if !strings.Contains(buf.String(), "env:RELSPEC_TEST_EXEC_CONN") { t.Fatalf("plan should name the env var:\n%s", buf.String()) } } func TestJobRun_TemplDatabaseMode(t *testing.T) { dir := jobFixture(t, `version: 1 jobs: docs: command: templ inputs: - path: schema/core.dbml format: dbml template: templates/schema.tmpl output: path: build/schema.txt overwrite: true `) writeFile(t, filepath.Join(dir, "templates", "schema.tmpl"), "{{range .Database.Schemas}}{{range .Tables}}{{.Name}} {{end}}{{end}}") set := mustLoadSet(t, filepath.Join(dir, "relspec.yml")) if err := executeJobPlan(set, "docs", false, false, &bytes.Buffer{}); err != nil { t.Fatalf("execute templ job: %v", err) } out, err := os.ReadFile(filepath.Join(dir, "build", "schema.txt")) if err != nil { t.Fatalf("read templ output: %v", err) } if !strings.Contains(string(out), "users") { t.Fatalf("templ output missing users table: %s", out) } }