eb8dbf66a1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
418 lines
12 KiB
Go
418 lines
12 KiB
Go
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 <root>/<rel>/.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)
|
|
}
|
|
})
|
|
}
|