package store import ( "database/sql" "path/filepath" "testing" "time" _ "modernc.org/sqlite" "git.xengi.de/ffd/indexer/scraper" ) func newTestStore(t *testing.T) *Store { t.Helper() path := filepath.Join(t.TempDir(), "test.db") db, err := Open(path) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { _ = db.Close() }) return db } func TestRecordAndReadForgeStatus(t *testing.T) { db := newTestStore(t) now := time.Now().UTC() if err := db.RecordForgeStatus(ForgeStatus{ URL: "https://example.test", ForgeType: "forgejo", Version: "16.0.5", Online: true, LastScrapedAt: now, }); err != nil { t.Fatalf("RecordForgeStatus: %v", err) } // Offline forge also gets a row so metrics always see it. if err := db.RecordForgeStatus(ForgeStatus{ URL: "https://offline.test", ForgeType: "forgejo", Online: false, LastScrapedAt: now, LastError: "timeout", }); err != nil { t.Fatalf("RecordForgeStatus: %v", err) } sts, err := db.ForgeStatuses() if err != nil { t.Fatalf("ForgeStatuses: %v", err) } if len(sts) != 2 { t.Fatalf("want 2 statuses, got %d", len(sts)) } byURL := map[string]ForgeStatus{} for _, s := range sts { byURL[s.URL] = s } if s, ok := byURL["https://example.test"]; !ok || !s.Online || s.ForgeType != "forgejo" || s.Version != "16.0.5" || s.LastError != "" { t.Errorf("unexpected online status: %+v", s) } if s, ok := byURL["https://offline.test"]; !ok || s.Online || s.LastError != "timeout" || s.Version != "" { t.Errorf("unexpected offline status: %+v", s) } // Single-forge lookup returns the row, and the version comes back. single, err := db.ForgeStatus("https://example.test") if err != nil { t.Fatalf("ForgeStatus: %v", err) } if single.Version != "16.0.5" || single.ForgeType != "forgejo" { t.Errorf("single lookup: %+v", single) } // A never-seen URL yields the zero value, not an error. missing, err := db.ForgeStatus("https://nope.test") if err != nil { t.Fatalf("ForgeStatus(missing): %v", err) } if missing.URL != "" || missing.Online { t.Errorf("miss zero-value status: %+v", missing) } } func TestUpsertRecordsForgeIdentity(t *testing.T) { db := newTestStore(t) p := scraper.Project{ ForgeIdentity: scraper.ForgeIdentity{ ForgeURL: "https://example.test", ForgeType: "forgejo", }, Name: "owner/repo", Url: "https://example.test/owner/repo", Languages: []string{"Go", "Rust"}, } if err := db.Upsert(p); err != nil { t.Fatalf("Upsert: %v", err) } repos, err := db.RepoIdentities() if err != nil { t.Fatalf("RepoIdentities: %v", err) } if len(repos) != 1 { t.Fatalf("want 1 repo, got %d", len(repos)) } r := repos[0] if r.URL != p.Url || r.ForgeURL != "https://example.test" || r.ForgeType != "forgejo" { t.Errorf("unexpected repo identity: %+v", r) } if len(r.Languages) != 2 || r.Languages[0] != "Go" || r.Languages[1] != "Rust" { t.Errorf("unexpected languages: %v", r.Languages) } } // TestOldSchemaUpgrades checks that a database created with the pre-forge // schema (with a forge_name column and no forge_url/forge_type) is migrated in // place: forge_url/forge_type are added and forge_name is dropped. func TestOldSchemaUpgrades(t *testing.T) { path := filepath.Join(t.TempDir(), "old.db") // Build an old-schema database by hand (as if it predated the metrics // feature), insert a row, close it, and reopen via the normal path. db, err := sql.Open("sqlite", path) if err != nil { t.Fatalf("raw open: %v", err) } _, err = db.Exec(`CREATE TABLE projects ( url TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', topics TEXT NOT NULL DEFAULT '[]', languages TEXT NOT NULL DEFAULT '[]', licenses TEXT NOT NULL DEFAULT '[]', open_issues INTEGER NOT NULL DEFAULT 0, open_prs INTEGER NOT NULL DEFAULT 0, latest_commit INTEGER NOT NULL DEFAULT 0, latest_release_name TEXT NOT NULL DEFAULT '', latest_release_date INTEGER NOT NULL DEFAULT 0, readme TEXT NOT NULL DEFAULT '', uses_ai INTEGER NOT NULL DEFAULT 0, scraped_at INTEGER NOT NULL DEFAULT 0, forge_name TEXT NOT NULL DEFAULT '' )`) if err != nil { t.Fatal(err) } if _, err := db.Exec(`CREATE TABLE forge_status ( url TEXT PRIMARY KEY, name TEXT NOT NULL, forge_type TEXT NOT NULL DEFAULT '', online INTEGER NOT NULL DEFAULT 0, last_scraped_at INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '' )`); err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO projects (url, name, languages) VALUES ('https://example.test/r', 'r', '["Go"]')`); err != nil { t.Fatal(err) } _ = db.Close() // Reopen via the normal path: the migrations must add forge_url/ // forge_type, drop forge_name from both tables, and leave existing rows // readable. up, err := Open(path) if err != nil { t.Fatalf("Open after migration: %v", err) } defer func() { _ = up.Close() }() repos, err := up.RepoIdentities() if err != nil { t.Fatalf("RepoIdentities after migration: %v", err) } if len(repos) != 1 { t.Fatalf("want 1 repo after migration, got %d", len(repos)) } if repos[0].URL != "https://example.test/r" || repos[0].ForgeURL != "" { t.Errorf("unexpected repo after migration: %+v", repos[0]) } // The up-to-date schema must not retain the dropped columns. for _, c := range []string{"forge_name"} { has, err := hasColumn(up.db, "projects", c) if err != nil { t.Fatal(err) } if has { t.Errorf("column %q should have been dropped from projects", c) } } for _, c := range []string{"name"} { has, err := hasColumn(up.db, "forge_status", c) if err != nil { t.Fatal(err) } if has { t.Errorf("column %q should have been dropped from forge_status", c) } } // The version column must have been added to the migrated forge_status // table, so a status recorded after upgrade persists its version. if err := up.RecordForgeStatus(ForgeStatus{ URL: "https://example.test", ForgeType: "forgejo", Version: "16.0.5", Online: true, LastScrapedAt: time.Now().UTC(), }); err != nil { t.Fatalf("RecordForgeStatus after migration: %v", err) } got, err := up.ForgeStatus("https://example.test") if err != nil { t.Fatalf("ForgeStatus after migration: %v", err) } if !got.Online || got.Version != "16.0.5" { t.Errorf("status after migration lost version: %+v", got) } }