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) } }