625569485f
Adds `fn doctor` read-only diagnostic command with subcommands artefacts, services, sync, uses-functions, unused, and --json flag for agents. Each subcommand wraps a registry function in functions/infra/. New functions: - artefact_doctor, services_status, pc_locations_drift, audit_uses_functions, find_unused_functions (Go diagnostics) - backup_sqlite_db, rotate_backups, wait_for_http, wait_for_port, port_kill, tail_journal, pre_commit_hook_install (bash utilities) - notify_telegram (Go HTTP) - backup_all pipeline (tag launcher) Plus prior session leftovers (scan_secrets_in_dirty, append_diary_entry, git utilities, http_session_cookie_middleware, compile/full-git pipelines). Fixes pc_locations_drift filepath.Join bug with absolute dir_path. Documents fn doctor in CLAUDE.md, .claude/rules/fn_doctor.md (rule 23), docs/architecture.md, CHANGELOG.md (2026-05-07), and diary entry. First fn doctor uses-functions run found drift in 7/12 apps (deuda para sincronizar app.md con imports reales). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
4.4 KiB
Go
178 lines
4.4 KiB
Go
package infra
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// setupTestRegistry creates a minimal registry.db with pc_locations + apps tables
|
|
// in a temp directory and returns the root path + a cleanup func.
|
|
func setupTestRegistry(t *testing.T) (string, func()) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
|
|
dbPath := filepath.Join(root, "registry.db")
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open test db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
_, err = db.Exec(`
|
|
CREATE TABLE pc_locations (
|
|
id TEXT PRIMARY KEY,
|
|
entity_type TEXT NOT NULL,
|
|
entity_id TEXT NOT NULL,
|
|
pc_id TEXT NOT NULL,
|
|
dir_path TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_at TEXT,
|
|
updated_at TEXT
|
|
);
|
|
CREATE TABLE apps (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
dir_path TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE TABLE analysis (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
dir_path TEXT NOT NULL DEFAULT ''
|
|
);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("create schema: %v", err)
|
|
}
|
|
|
|
return root, func() {}
|
|
}
|
|
|
|
func insertLocation(t *testing.T, root, entityType, entityID, dirPath, status, pcID string) {
|
|
t.Helper()
|
|
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
|
defer db.Close()
|
|
id := entityType + "_" + entityID + "_" + pcID
|
|
_, err := db.Exec(
|
|
`INSERT INTO pc_locations(id, entity_type, entity_id, pc_id, dir_path, status) VALUES (?,?,?,?,?,?)`,
|
|
id, entityType, entityID, pcID, dirPath, status,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert location: %v", err)
|
|
}
|
|
}
|
|
|
|
func insertApp(t *testing.T, root, id, dirPath string) {
|
|
t.Helper()
|
|
db, _ := sql.Open("sqlite3", filepath.Join(root, "registry.db"))
|
|
defer db.Close()
|
|
_, err := db.Exec(`INSERT INTO apps(id, dir_path) VALUES (?,?)`, id, dirPath)
|
|
if err != nil {
|
|
t.Fatalf("insert app: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPcLocationsDrift_DetectsMissingFolder(t *testing.T) {
|
|
root, cleanup := setupTestRegistry(t)
|
|
defer cleanup()
|
|
|
|
// An active entry whose folder does NOT exist on disk
|
|
insertLocation(t, root, "app", "my_app", "apps/my_app", "active", "test-pc")
|
|
|
|
drifts, err := PcLocationsDrift(root, "test-pc")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, d := range drifts {
|
|
if d.EntityID == "my_app" && d.Issue == "missing_on_disk" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected missing_on_disk drift for my_app, got: %+v", drifts)
|
|
}
|
|
}
|
|
|
|
func TestPcLocationsDrift_ActiveFolderExistsNoDrift(t *testing.T) {
|
|
root, cleanup := setupTestRegistry(t)
|
|
defer cleanup()
|
|
|
|
// Create the folder
|
|
appDir := filepath.Join(root, "apps", "good_app")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insertLocation(t, root, "app", "good_app", "apps/good_app", "active", "test-pc")
|
|
|
|
drifts, err := PcLocationsDrift(root, "test-pc")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
for _, d := range drifts {
|
|
if d.EntityID == "good_app" {
|
|
t.Errorf("unexpected drift for good_app: %+v", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPcLocationsDrift_StatusShouldBeActive(t *testing.T) {
|
|
root, cleanup := setupTestRegistry(t)
|
|
defer cleanup()
|
|
|
|
// Folder exists but status is "missing"
|
|
appDir := filepath.Join(root, "apps", "came_back")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insertLocation(t, root, "app", "came_back", "apps/came_back", "missing", "test-pc")
|
|
|
|
drifts, err := PcLocationsDrift(root, "test-pc")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, d := range drifts {
|
|
if d.EntityID == "came_back" && d.Issue == "status_should_be_active" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected status_should_be_active for came_back, got: %+v", drifts)
|
|
}
|
|
}
|
|
|
|
func TestPcLocationsDrift_UntrackedOnDisk(t *testing.T) {
|
|
root, cleanup := setupTestRegistry(t)
|
|
defer cleanup()
|
|
|
|
// App indexed in registry with folder on disk but no pc_locations entry
|
|
appDir := filepath.Join(root, "apps", "orphan_app")
|
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
insertApp(t, root, "orphan_app", "apps/orphan_app")
|
|
// No pc_locations entry for test-pc
|
|
|
|
drifts, err := PcLocationsDrift(root, "test-pc")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, d := range drifts {
|
|
if d.EntityID == "orphan_app" && d.Issue == "untracked_on_disk" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected untracked_on_disk for orphan_app, got: %+v", drifts)
|
|
}
|
|
}
|