package infra import ( "database/sql" "os" "os/exec" "path/filepath" "testing" _ "github.com/mattn/go-sqlite3" ) // seedProjectsRegistry builds a temp registry.db with the columns // AuditProjectsCoverage reads, plus a handful of on-disk directories that // model the cloned/missing states. func seedProjectsRegistry(t *testing.T) string { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "registry.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("open temp db: %v", err) } defer db.Close() _, err = db.Exec(` CREATE TABLE projects ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL DEFAULT '' ); CREATE TABLE apps ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', project_id TEXT NOT NULL DEFAULT '' ); CREATE TABLE analysis ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', project_id TEXT NOT NULL DEFAULT '' ); `) if err != nil { t.Fatalf("create schema: %v", err) } _, err = db.Exec(` INSERT INTO projects VALUES ('healthy', 'projects/healthy', 'https://gitea.example/dataforge/healthy'), ('no_repo', 'projects/no_repo', ''), ('missing', 'projects/missing', 'https://gitea.example/dataforge/missing'); INSERT INTO apps VALUES ('app_cloned', 'projects/healthy/apps/app_cloned', 'healthy'), ('app_orphan', 'projects/healthy/apps/app_orphan', 'healthy'); INSERT INTO analysis VALUES ('an_cloned', 'projects/healthy/analysis/an_cloned', 'healthy'); `) if err != nil { t.Fatalf("seed data: %v", err) } // healthy: directory + .git present; one child cloned, one missing. mkGitDir(t, dir, "projects/healthy") mkGitDir(t, dir, "projects/healthy/apps/app_cloned") mkGitDir(t, dir, "projects/healthy/analysis/an_cloned") // app_orphan has no .git on disk → counts as missing. // no_repo: directory exists, no .git. if err := os.MkdirAll(filepath.Join(dir, "projects/no_repo"), 0o755); err != nil { t.Fatalf("mkdir no_repo: %v", err) } // missing: no directory at all on disk. return dir } // mkGitDir creates //.git so dirExists treats it as a git repo. func mkGitDir(t *testing.T, root, rel string) { t.Helper() if err := os.MkdirAll(filepath.Join(root, rel, ".git"), 0o755); err != nil { t.Fatalf("mkdir %s/.git: %v", rel, err) } } func findCoverage(rows []ProjectCoverage, id string) *ProjectCoverage { for i := range rows { if rows[i].ProjectID == id { return &rows[i] } } return nil } func hasIssue(pc *ProjectCoverage, issue string) bool { for _, i := range pc.Issues { if i == issue { return true } } return false } func TestAuditProjectsCoverage(t *testing.T) { t.Run("healthy project con un hijo sin clonar marca children_missing", func(t *testing.T) { dir := seedProjectsRegistry(t) rows, err := AuditProjectsCoverage(dir) if err != nil { t.Fatalf("AuditProjectsCoverage error: %v", err) } pc := findCoverage(rows, "healthy") if pc == nil { t.Fatal("project 'healthy' missing from results") } if !pc.HasGit { t.Error("expected HasGit=true for healthy") } if !pc.RepoURLDeclared { t.Error("expected RepoURLDeclared=true for healthy") } if pc.ChildrenInDB != 3 { t.Errorf("expected 3 children in db, got %d", pc.ChildrenInDB) } if pc.ChildrenCloned != 2 { t.Errorf("expected 2 cloned children, got %d", pc.ChildrenCloned) } if pc.ChildrenMissing != 1 { t.Errorf("expected 1 missing child, got %d", pc.ChildrenMissing) } if !hasIssue(pc, "children_missing") { t.Errorf("expected children_missing issue, got %v", pc.Issues) } }) t.Run("project sin repo_url ni remote marca no_gitea_repo", func(t *testing.T) { dir := seedProjectsRegistry(t) rows, err := AuditProjectsCoverage(dir) if err != nil { t.Fatalf("error: %v", err) } pc := findCoverage(rows, "no_repo") if pc == nil { t.Fatal("project 'no_repo' missing") } if pc.HasGit { t.Error("expected HasGit=false for no_repo") } if pc.RepoURLDeclared { t.Error("expected RepoURLDeclared=false for no_repo") } if !hasIssue(pc, "no_gitea_repo") { t.Errorf("expected no_gitea_repo issue, got %v", pc.Issues) } }) t.Run("project sin directorio en disco marca dir_not_found", func(t *testing.T) { dir := seedProjectsRegistry(t) rows, err := AuditProjectsCoverage(dir) if err != nil { t.Fatalf("error: %v", err) } pc := findCoverage(rows, "missing") if pc == nil { t.Fatal("project 'missing' missing") } if !hasIssue(pc, "dir_not_found") { t.Errorf("expected dir_not_found issue, got %v", pc.Issues) } if pc.HasGit { t.Error("expected HasGit=false when dir not found") } }) t.Run("error si registry.db no existe", func(t *testing.T) { dir := t.TempDir() if _, err := AuditProjectsCoverage(dir); err == nil { t.Error("expected error for missing db, got nil") } }) } func TestGitHasRemoteOrigin(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") } t.Run("repo con origin devuelve true", func(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-q") runGit(t, dir, "remote", "add", "origin", "https://example.com/x.git") if !gitHasRemoteOrigin(dir) { t.Error("expected true for repo with origin") } }) t.Run("repo sin origin devuelve false", func(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-q") if gitHasRemoteOrigin(dir) { t.Error("expected false for repo without origin") } }) } func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } // seedOrphanRefsRegistry builds a temp registry.db where some children declare // a project_id with no matching row in the projects table (orphan refs) while // others reference a valid project. It returns the registry root directory. func seedOrphanRefsRegistry(t *testing.T, withOrphans bool) string { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "registry.db") db, err := sql.Open("sqlite3", dbPath) if err != nil { t.Fatalf("open temp db: %v", err) } defer db.Close() _, err = db.Exec(` CREATE TABLE projects ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL DEFAULT '' ); CREATE TABLE apps ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', project_id TEXT NOT NULL DEFAULT '' ); CREATE TABLE analysis ( id TEXT PRIMARY KEY, dir_path TEXT NOT NULL DEFAULT '', project_id TEXT NOT NULL DEFAULT '' ); `) if err != nil { t.Fatalf("create schema: %v", err) } // One known project exists in the projects table. if _, err := db.Exec(`INSERT INTO projects VALUES ('known', 'projects/known', '')`); err != nil { t.Fatalf("seed projects: %v", err) } // app_valid + an_valid reference the known project: never orphans. if _, err := db.Exec(` INSERT INTO apps VALUES ('app_valid', 'projects/known/apps/app_valid', 'known'); INSERT INTO analysis VALUES ('an_valid', 'projects/known/analysis/an_valid', 'known'); `); err != nil { t.Fatalf("seed valid children: %v", err) } // app_no_project has an empty project_id: must be ignored. if _, err := db.Exec(`INSERT INTO apps VALUES ('app_no_project', 'apps/app_no_project', '')`); err != nil { t.Fatalf("seed no-project app: %v", err) } if withOrphans { // Two apps + one analysis pointing at 'ghost' (missing from projects), // plus one app pointing at 'specter' (also missing). Insert the apps for // 'ghost' out of alphabetical order to exercise sorting. if _, err := db.Exec(` INSERT INTO apps VALUES ('app_zeta', 'apps/app_zeta', 'ghost'); INSERT INTO apps VALUES ('app_alpha', 'apps/app_alpha', 'ghost'); INSERT INTO analysis VALUES ('an_ghost', 'analysis/an_ghost', 'ghost'); INSERT INTO apps VALUES ('app_specter', 'apps/app_specter', 'specter'); `); err != nil { t.Fatalf("seed orphan children: %v", err) } } return dir } func findOrphan(rows []OrphanProjectRef, id string) *OrphanProjectRef { for i := range rows { if rows[i].ProjectID == id { return &rows[i] } } return nil } func TestFindOrphanProjectRefs(t *testing.T) { t.Run("app con project_id huerfano lo detecta y agrupa ordenado", func(t *testing.T) { dir := seedOrphanRefsRegistry(t, true) rows, err := FindOrphanProjectRefs(dir) if err != nil { t.Fatalf("FindOrphanProjectRefs error: %v", err) } if len(rows) != 2 { t.Fatalf("expected 2 orphan refs, got %d: %+v", len(rows), rows) } // Sorted by ProjectID: ghost before specter. if rows[0].ProjectID != "ghost" || rows[1].ProjectID != "specter" { t.Fatalf("expected [ghost specter], got [%s %s]", rows[0].ProjectID, rows[1].ProjectID) } ghost := findOrphan(rows, "ghost") if ghost == nil { t.Fatal("orphan 'ghost' missing") } wantApps := []string{"app_alpha", "app_zeta"} // alphabetical if len(ghost.Apps) != 2 || ghost.Apps[0] != wantApps[0] || ghost.Apps[1] != wantApps[1] { t.Errorf("expected ghost.Apps=%v, got %v", wantApps, ghost.Apps) } if len(ghost.Analyses) != 1 || ghost.Analyses[0] != "an_ghost" { t.Errorf("expected ghost.Analyses=[an_ghost], got %v", ghost.Analyses) } specter := findOrphan(rows, "specter") if specter == nil { t.Fatal("orphan 'specter' missing") } if len(specter.Apps) != 1 || specter.Apps[0] != "app_specter" { t.Errorf("expected specter.Apps=[app_specter], got %v", specter.Apps) } if len(specter.Analyses) != 0 { t.Errorf("expected specter.Analyses empty, got %v", specter.Analyses) } }) t.Run("app con project_id valido no aparece", func(t *testing.T) { dir := seedOrphanRefsRegistry(t, true) rows, err := FindOrphanProjectRefs(dir) if err != nil { t.Fatalf("error: %v", err) } if findOrphan(rows, "known") != nil { t.Error("valid project 'known' should not appear as orphan") } // Children of 'known' must never be listed in any orphan entry. for _, r := range rows { for _, a := range append(append([]string{}, r.Apps...), r.Analyses...) { if a == "app_valid" || a == "an_valid" || a == "app_no_project" { t.Errorf("child %q with valid/empty project leaked into orphan %q", a, r.ProjectID) } } } }) t.Run("sin huerfanos devuelve slice vacio sin error", func(t *testing.T) { dir := seedOrphanRefsRegistry(t, false) rows, err := FindOrphanProjectRefs(dir) if err != nil { t.Fatalf("error: %v", err) } if rows == nil { t.Fatal("expected non-nil empty slice, got nil") } if len(rows) != 0 { t.Errorf("expected 0 orphans, got %d: %+v", len(rows), rows) } }) t.Run("error si registry.db no existe", func(t *testing.T) { dir := t.TempDir() if _, err := FindOrphanProjectRefs(dir); err == nil { t.Error("expected error for missing db, got nil") } }) } func TestFormatOrphanProjectRefs(t *testing.T) { t.Run("sin huerfanos lo deja claro", func(t *testing.T) { out := FormatOrphanProjectRefs(nil) if !contains(out, "0 project_id huérfanos") { t.Errorf("expected clean message, got:\n%s", out) } }) t.Run("con huerfanos lista ids y cuenta", func(t *testing.T) { rows := []OrphanProjectRef{ {ProjectID: "ghost", Apps: []string{"app_alpha", "app_zeta"}, Analyses: []string{"an_ghost"}}, } out := FormatOrphanProjectRefs(rows) if !contains(out, "ghost") { t.Errorf("expected project_id in output, got:\n%s", out) } if !contains(out, "app_alpha") || !contains(out, "an_ghost") { t.Errorf("expected referencing ids in output, got:\n%s", out) } if !contains(out, "1 project_id huérfanos") { t.Errorf("expected count line, got:\n%s", out) } }) } func TestFormatProjectsCoverage(t *testing.T) { t.Run("sin issues lo deja claro", func(t *testing.T) { rows := []ProjectCoverage{ {ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true, ChildrenInDB: 2, ChildrenCloned: 2}, } out := FormatProjectsCoverage(rows) if !contains(out, "0 projects con problemas de cobertura") { t.Errorf("expected clean message, got:\n%s", out) } }) t.Run("con issues cuenta los afectados", func(t *testing.T) { rows := []ProjectCoverage{ {ProjectID: "bad", Issues: []string{"no_gitea_repo"}}, {ProjectID: "ok", HasGit: true, HasRemote: true, RepoURLDeclared: true}, } out := FormatProjectsCoverage(rows) if !contains(out, "1/2 projects con problemas de cobertura") { t.Errorf("expected 1/2 count, got:\n%s", out) } }) }