ajhahn.de
← eeco
Go 200 lines
package workflow

import (
	"os/exec"
	"strings"
	"testing"
)

// stubDocDriftTags overrides docDriftTagSource for the test so the
// tag side of the comparison is fixed without touching real git.
func stubDocDriftTags(t *testing.T, tags []string) {
	t.Helper()
	old := docDriftTagSource
	docDriftTagSource = func(string) ([]string, error) { return tags, nil }
	t.Cleanup(func() { docDriftTagSource = old })
}

func TestDocDrift_NoChangelog(t *testing.T) {
	cfg := newCfg(t)
	stubDocDriftTags(t, []string{"v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if res.Summary != "no CHANGELOG.md to check" {
		t.Errorf("Summary = %q", res.Summary)
	}
}

func TestDocDrift_NoTags(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
	stubDocDriftTags(t, nil)

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if res.Summary != "no git tags to check against" {
		t.Errorf("Summary = %q", res.Summary)
	}
}

func TestDocDrift_AllDocumented(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
		"# Changelog\n\n## [Unreleased]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
	stubDocDriftTags(t, []string{"v1.1.0", "v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if q := queueBody(t, cfg); q != "" {
		t.Errorf("queue should be empty, got:\n%s", q)
	}
}

func TestDocDrift_TagWithoutSection(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
	stubDocDriftTags(t, []string{"v1.1.0", "v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 {
		t.Fatalf("Findings = %d, want 1: %+v", len(res.Findings), res.Findings)
	}
	if !strings.Contains(res.Findings[0].Msg, "v1.1.0") {
		t.Errorf("Finding.Msg = %q, want it to name v1.1.0", res.Findings[0].Msg)
	}
	q := queueBody(t, cfg)
	if strings.Count(q, "**doc-drift**") != 1 || !strings.Contains(q, "v1.1.0") {
		t.Errorf("queue missing doc-drift item for v1.1.0:\n%s", q)
	}
}

func TestDocDrift_SectionWithoutTagBelowLatest(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
		"# Changelog\n\n## [v1.2.0]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
	// v1.1.0 is documented but never tagged; it sits below the latest
	// tag v1.2.0, so it is a genuine gap, not a release-in-progress.
	stubDocDriftTags(t, []string{"v1.2.0", "v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "v1.1.0") {
		t.Fatalf("Findings = %+v, want one naming v1.1.0", res.Findings)
	}
	if strings.Count(queueBody(t, cfg), "**doc-drift**") != 1 {
		t.Errorf("want exactly one queued doc-drift item")
	}
}

func TestDocDrift_SectionAheadOfLatestTagIsClean(t *testing.T) {
	cfg := newCfg(t)
	// v1.1.0 is documented ahead of the latest tag v1.0.0 — the expected
	// release-in-progress state, not drift. `## [Unreleased]` is ignored.
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
		"# Changelog\n\n## [Unreleased]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
	stubDocDriftTags(t, []string{"v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
	}
	if q := queueBody(t, cfg); q != "" {
		t.Errorf("queue should be empty, got:\n%s", q)
	}
}

func TestDocDrift_Mixed(t *testing.T) {
	cfg := newCfg(t)
	// Tags v1.0.0 + v1.2.0 are both undocumented (class 1); section
	// v1.1.0 has no tag and sits below latest v1.2.0 (class 2); section
	// v1.3.0 is ahead of latest, exempt.
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
		"# Changelog\n\n## [v1.3.0]\n\n## [v1.1.0]\n")
	stubDocDriftTags(t, []string{"v1.2.0", "v1.0.0"})

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 3 {
		t.Fatalf("Findings = %d, want 3: %+v", len(res.Findings), res.Findings)
	}
	if n := strings.Count(queueBody(t, cfg), "**doc-drift**"); n != 3 {
		t.Errorf("queued doc-drift items = %d, want 3", n)
	}
}

func TestDocDrift_GitMissing(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
	// Emptying PATH hides the git binary; the workflow must report
	// blocked rather than pass a check it never ran.
	t.Setenv("PATH", "")

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeBlocked {
		t.Errorf("Code = %d, want %d (%q)", res.Code, CodeBlocked, res.Summary)
	}
}

// TestDocDrift_RealGit exercises the real gitx.SemverTags wiring (no
// stub) against an actual tagged repo, covering the integration end to
// end alongside the stubbed table cases above.
func TestDocDrift_RealGit(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n")
	gitInit(t, cfg.RepoRoot)
	runGit(t, cfg.RepoRoot, "commit", "-q", "-m", "init")
	runGit(t, cfg.RepoRoot, "tag", "v0.1.0")

	res, err := docDrift{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
	}
	if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "v0.1.0") {
		t.Errorf("Findings = %+v, want one naming v0.1.0", res.Findings)
	}
}