ajhahn.de
← eeco
Go 509 lines
package workflow

import (
	"strings"
	"testing"
)

// stubTagSource replaces the versionSyncTagSource resolver for the
// duration of a test. The returned cleanup restores the previous
// resolver; defer the call. An empty value simulates "no semver tag
// reachable" (a fresh repo or one carrying only foreign tags).
func stubTagSource(tag string) func() {
	prev := versionSyncTagSource
	versionSyncTagSource = func(string) (string, error) { return tag, nil }
	return func() { versionSyncTagSource = prev }
}

func TestVersionSync_NoLocationsDeclaredIsClean(t *testing.T) {
	cfg := newCfg(t)
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("no version_locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
}

func TestVersionSync_SingleLocationAgreesWithItself(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
	cfg.VersionLocations = []string{`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("single location -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
}

func TestVersionSync_MultipleLocationsAgreeIsClean(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
	writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
		`README.md:badge: v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("agreeing locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "1.11.0") {
		t.Errorf("summary missing anchor version: %s", res.Summary)
	}
}

func TestVersionSync_DriftReportsFinding(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
	writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
		`README.md:badge: v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if len(res.Findings) != 1 {
		t.Fatalf("findings = %d, want 1: %+v", len(res.Findings), res.Findings)
	}
	if res.Findings[0].Path != "VERSION" {
		t.Errorf("finding path = %q, want VERSION", res.Findings[0].Path)
	}
}

func TestVersionSync_MultipleDriftsReported(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
	writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.9.5 here\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
		`README.md:badge: v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("multi-drift -> %d (%s)", res.Code, res.Summary)
	}
	if len(res.Findings) != 2 {
		t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
	}
	if res.Findings[0].Path != "README.md" || res.Findings[1].Path != "VERSION" {
		t.Errorf("findings unsorted or wrong paths: %+v", res.Findings)
	}
}

func TestVersionSync_MissingLocationBlocks(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`MISSING.md:v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeBlocked {
		t.Fatalf("missing location -> %d (%s)", res.Code, res.Summary)
	}
	if !strings.Contains(res.Summary, "MISSING.md") {
		t.Errorf("summary missing the absent path: %s", res.Summary)
	}
}

func TestVersionSync_RegexMatchesNothingReportsFinding(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "no version here\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("no match -> %d (%s)", res.Code, res.Summary)
	}
	if len(res.Findings) != 1 || res.Findings[0].Path != "VERSION" {
		t.Errorf("unexpected findings: %+v", res.Findings)
	}
}

func TestVersionSync_InvalidEntryErrors(t *testing.T) {
	cfg := newCfg(t)
	cfg.VersionLocations = []string{"no-colon-here"}
	_, err := versionSync{}.Run(Env{Config: cfg})
	if err == nil {
		t.Fatal("expected error for malformed entry")
	}
}

func TestVersionSync_RegexWithoutCaptureGroupErrors(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
	cfg.VersionLocations = []string{`CHANGELOG.md:v\d+\.\d+\.\d+`}
	_, err := versionSync{}.Run(Env{Config: cfg})
	if err == nil {
		t.Fatal("expected error for regex without capture group")
	}
}

func TestVersionSync_BadRegexErrors(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
	cfg.VersionLocations = []string{"CHANGELOG.md:[unclosed"}
	_, err := versionSync{}.Run(Env{Config: cfg})
	if err == nil {
		t.Fatal("expected error for malformed regex")
	}
}

func TestVersionSync_TagAnchorCleanWhenLocationsAgreeWithTag(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("tag-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
		t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
	}
}

func TestVersionSync_TagAnchorAllowsForwardDrift(t *testing.T) {
	// Release-commit case: CHANGELOG bumped to v1.13.0 ahead of the
	// not-yet-pushed tag. The gate must NOT block this.
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("tag-anchor forward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "ahead of tag-anchor v1.12.0") {
		t.Errorf("summary missing forward-drift note: %s", res.Summary)
	}
}

func TestVersionSync_TagAnchorBackwardDriftFails(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("backward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if len(res.Findings) != 2 {
		t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
	}
	if !strings.Contains(res.Findings[0].Msg, "behind tag-anchor v1.12.0") {
		t.Errorf("finding msg = %q, want backward-drift mention", res.Findings[0].Msg)
	}
}

func TestVersionSync_TagAnchorMutualDisagreementCaught(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.1\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("mutual disagreement under tag-anchor -> %d (%s)", res.Code, res.Summary)
	}
	// Both ahead of the tag (≥), so the tag pre-check passes; the
	// consistency check then catches the mutual disagreement.
	if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
		t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
	}
}

func TestVersionSync_TagAnchorNoTagsFallsBackToConsistency(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:^v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("tag-anchor no-tags -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "no semver tag reachable yet") {
		t.Errorf("summary missing fallback note: %s", res.Summary)
	}
}

func TestVersionSync_TagAnchorNonSemverFails(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "PROJECT", "v2024-05-22\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`PROJECT:^v(.+)`,
	}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("non-semver under tag-anchor -> %d (%s)", res.Code, res.Summary)
	}
	if len(res.Findings) != 1 || res.Findings[0].Path != "PROJECT" {
		t.Errorf("findings unexpected: %+v", res.Findings)
	}
	if !strings.Contains(res.Findings[0].Msg, "not semver-shaped") {
		t.Errorf("finding msg = %q, want non-semver mention", res.Findings[0].Msg)
	}
}

func TestVersionSync_FileAnchorClean(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.13.0 here\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`README.md:badge: v(\d+\.\d+\.\d+)`,
	}
	cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("file-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "version_anchor VERSION") {
		t.Errorf("summary missing version_anchor mention: %s", res.Summary)
	}
}

func TestVersionSync_FileAnchorDriftFails(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
	}
	cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("file-anchor drift -> %d (%s)", res.Code, res.Summary)
	}
	if len(res.Findings) != 1 || res.Findings[0].Path != "CHANGELOG.md" {
		t.Errorf("findings = %+v", res.Findings)
	}
}

func TestVersionSync_FileAnchorMissingPathBlocks(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
	}
	cfg.VersionAnchor = `MISSING:^v(\d+\.\d+\.\d+)`
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeBlocked {
		t.Fatalf("file-anchor missing -> %d (%s)", res.Code, res.Summary)
	}
	if !strings.Contains(res.Summary, "MISSING") {
		t.Errorf("summary missing the absent path: %s", res.Summary)
	}
}

func TestVersionSync_FindingLineNumberMatchesContent(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
	// Drift sits on line 3; the finding must point there. `(?m)` flips
	// `^` from start-of-string to start-of-line — the documented spelling
	// for matching a version string further down a file.
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "header\n\nv1.10.0\n")
	cfg.VersionLocations = []string{
		`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
		`VERSION:(?m)^v(\d+\.\d+\.\d+)`,
	}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding || len(res.Findings) != 1 {
		t.Fatalf("unexpected result: %+v", res)
	}
	if res.Findings[0].Line != 3 {
		t.Errorf("finding line = %d, want 3", res.Findings[0].Line)
	}
}

func TestVersionSync_AutoDetectNoVersionFilesIsClean(t *testing.T) {
	cfg := newCfg(t)
	cfg.VersionLocations = []string{"auto"}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("auto, no files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "no version locations found") {
		t.Errorf("summary missing the no-locations note: %s", res.Summary)
	}
}

func TestVersionSync_AutoDetectSingleFileIsClean(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0] - 2026-05-22\n")
	cfg.VersionLocations = []string{"auto"}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("auto, single file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.HasPrefix(res.Summary, "auto-detect: ") {
		t.Errorf("summary missing the auto-detect prefix: %s", res.Summary)
	}
}

func TestVersionSync_AutoDetectAgreeingFilesIsClean(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.14.0\n")
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n  \"name\": \"demo\",\n  \"version\": \"1.14.0\"\n}\n")
	cfg.VersionLocations = []string{"auto"}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("auto, agreeing files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.Contains(res.Summary, "1.14.0") {
		t.Errorf("summary missing the detected version: %s", res.Summary)
	}
}

func TestVersionSync_AutoDetectDriftReportsFinding(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
	writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n  \"version\": \"1.13.0\"\n}\n")
	cfg.VersionLocations = []string{"auto"}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeFinding {
		t.Fatalf("auto, drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if len(res.Findings) != 1 || res.Findings[0].Path != "package.json" {
		t.Fatalf("findings = %+v, want one on package.json", res.Findings)
	}
}

func TestVersionSync_AutoDetectSkipsFileWithoutVersion(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
	// A package.json with no `version` field carries no version-shaped
	// string — auto-detect must skip it, not flag it as a drift.
	writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n  \"name\": \"demo\"\n}\n")
	cfg.VersionLocations = []string{"auto"}
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("auto, version-less file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
}

func TestVersionSync_AutoDetectComposesWithTagAnchor(t *testing.T) {
	cfg := newCfg(t)
	writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.13.0\n")
	writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
	cfg.VersionLocations = []string{"auto"}
	cfg.VersionAnchor = "tag"
	defer stubTagSource("v1.12.0")()
	res, err := versionSync{}.Run(Env{Config: cfg})
	if err != nil {
		t.Fatal(err)
	}
	if res.Code != CodeClean {
		t.Fatalf("auto + tag-anchor -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
	}
	if !strings.HasPrefix(res.Summary, "auto-detect: ") || !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
		t.Errorf("summary missing auto-detect prefix or tag-anchor note: %s", res.Summary)
	}
}