package database import ( "context" "os" "testing" "time" "git.unkin.net/unkin/artifactapi/internal/provider" "git.unkin.net/unkin/artifactapi/internal/testsupport" "git.unkin.net/unkin/artifactapi/pkg/models" ) var testDB *DB func TestMain(m *testing.M) { c := context.Background() dsn, terminate, err := testsupport.StartPostgres(c) if err != nil { // Docker unavailable: run anyway so tests self-skip via requireDB. os.Exit(m.Run()) } db, err := New(dsn) if err != nil { terminate() panic(err) } testDB = db code := m.Run() db.Close() terminate() // Return normally on success so the coverage profile is flushed; os.Exit // would truncate it. if code != 0 { os.Exit(code) } } func requireDB(t *testing.T) { t.Helper() if testDB == nil { t.Skip("Docker unavailable; skipping database integration test") } } func ctx() context.Context { return context.Background() } func seedRemote(t *testing.T, name string) { t.Helper() if err := testDB.CreateRemote(ctx(), &models.Remote{ Name: name, PackageType: models.PackageGeneric, RepoType: models.RepoTypeRemote, BaseURL: "https://example.com", MutableTTL: 3600, }); err != nil { t.Fatalf("seed remote: %v", err) } } // seedBlob inserts a blob and returns its full content hash (sha256:), // matching the reference convention used by artifacts and local files. func seedBlob(t *testing.T, hash string) string { t.Helper() full := "sha256:" + hash if err := testDB.UpsertBlob(ctx(), full, "blobs/sha256/"+hash, 10, "application/octet-stream"); err != nil { t.Fatalf("seed blob: %v", err) } return full } func TestRemotesCRUD(t *testing.T) { requireDB(t) seedRemote(t, "r-crud") got, err := testDB.GetRemote(ctx(), "r-crud") if err != nil || got.BaseURL != "https://example.com" { t.Fatalf("get: %v %v", got, err) } got.BaseURL = "https://updated.example.com" if err := testDB.UpdateRemote(ctx(), got); err != nil { t.Fatalf("update: %v", err) } got, _ = testDB.GetRemote(ctx(), "r-crud") if got.BaseURL != "https://updated.example.com" { t.Errorf("update not applied: %v", got.BaseURL) } list, err := testDB.ListRemotes(ctx()) if err != nil || len(list) == 0 { t.Fatalf("list: %v %v", len(list), err) } if err := testDB.DeleteRemote(ctx(), "r-crud"); err != nil { t.Fatalf("delete: %v", err) } if _, err := testDB.GetRemote(ctx(), "r-crud"); err == nil { t.Error("expected error after delete") } } func TestArtifactsAndBlobs(t *testing.T) { requireDB(t) seedRemote(t, "r-art") seedBlob(t, "aaaa") hash := "sha256:aaaa" if err := testDB.UpsertBlob(ctx(), hash, "blobs/sha256/aaaa", 10, "text/plain"); err != nil { t.Fatal(err) } if err := testDB.UpsertArtifact(ctx(), "r-art", "path/a.txt", hash, "etag1"); err != nil { t.Fatal(err) } // Upsert again to exercise the ON CONFLICT update branch. if err := testDB.UpsertArtifact(ctx(), "r-art", "path/a.txt", hash, "etag2"); err != nil { t.Fatal(err) } art, err := testDB.GetArtifact(ctx(), "r-art", "path/a.txt") if err != nil || art.ContentHash != hash { t.Fatalf("get artifact: %v %v", art, err) } if err := testDB.TouchArtifactAccess(ctx(), "r-art", "path/a.txt"); err != nil { t.Fatal(err) } arts, err := testDB.ListArtifacts(ctx(), "r-art", 10, 0) if err != nil || len(arts) != 1 { t.Fatalf("list artifacts: %v %v", len(arts), err) } if err := testDB.InsertAccessLog(ctx(), "r-art", "path/a.txt", true, 10, 5, "1.2.3.4"); err != nil { t.Fatal(err) } if err := testDB.InsertAccessLogBatch(ctx(), []AccessLogEntry{ {RemoteName: "r-art", Path: "b", CacheHit: false, SizeBytes: 20, UpstreamMS: 3}, }); err != nil { t.Fatal(err) } if err := testDB.InsertAccessLogBatch(ctx(), nil); err != nil { t.Fatalf("empty batch should be a no-op: %v", err) } if err := testDB.DeleteArtifact(ctx(), "r-art", "path/a.txt"); err != nil { t.Fatal(err) } } func TestOrphanAndColdCleanup(t *testing.T) { requireDB(t) seedBlob(t, "orphanhash") // A blob with no artifact/local_file reference is orphaned, but only past // the grace period. if got, _ := testDB.FindOrphanedBlobs(ctx(), time.Hour); containsHash(got, "sha256:orphanhash") { t.Error("fresh orphan should be excluded by grace period") } orphans, err := testDB.FindOrphanedBlobs(ctx(), -time.Hour) // cutoff in the future => include fresh if err != nil { t.Fatal(err) } if !containsHash(orphans, "sha256:orphanhash") { t.Error("expected orphan to be found with zero grace") } if err := testDB.DeleteBlob(ctx(), "sha256:orphanhash"); err != nil { t.Fatal(err) } seedRemote(t, "r-cold") seedBlob(t, "coldhash") testDB.UpsertArtifact(ctx(), "r-cold", "cold.txt", "sha256:coldhash", "") n, err := testDB.DeleteColdArtifacts(ctx(), "r-cold", -time.Hour) // negative => everything is "cold" if err != nil || n < 1 { t.Fatalf("delete cold: n=%d err=%v", n, err) } } func containsHash(blobs []models.Blob, hash string) bool { for _, b := range blobs { if b.ContentHash == hash { return true } } return false } func TestLocalFiles(t *testing.T) { requireDB(t) seedRemote(t, "r-local") seedBlob(t, "localhash") hash := "sha256:localhash" if err := testDB.CreateLocalFile(ctx(), "r-local", "foo/foo-1.0.whl", hash); err != nil { t.Fatal(err) } // Duplicate create must be rejected. if err := testDB.CreateLocalFile(ctx(), "r-local", "foo/foo-1.0.whl", hash); err == nil { t.Error("expected duplicate local file error") } f, err := testDB.GetLocalFile(ctx(), "r-local", "foo/foo-1.0.whl") if err != nil || f == nil { t.Fatalf("get local file: %v %v", f, err) } if files, err := testDB.ListLocalFiles(ctx(), "r-local", 10, 0); err != nil || len(files) != 1 { t.Fatalf("list: %v %v", len(files), err) } if files, err := testDB.ListLocalFilesByPrefix(ctx(), "r-local", "foo/"); err != nil || len(files) != 1 { t.Fatalf("list by prefix: %v %v", len(files), err) } if entries, err := testDB.ListFilesByPrefix(ctx(), "r-local", "foo/"); err != nil || len(entries) != 1 { t.Fatalf("provider list by prefix: %v %v", len(entries), err) } if pkgs, err := testDB.ListLocalFilePackages(ctx(), "r-local"); err != nil || len(pkgs) == 0 { t.Fatalf("list packages: %v %v", pkgs, err) } if pkgs, err := testDB.ListPackages(ctx(), "r-local"); err != nil || len(pkgs) == 0 { t.Fatalf("provider list packages: %v %v", pkgs, err) } if err := testDB.DeleteLocalFile(ctx(), "r-local", "foo/foo-1.0.whl"); err != nil { t.Fatal(err) } } func TestVirtualsCRUD(t *testing.T) { requireDB(t) if err := testDB.CreateVirtual(ctx(), &models.Virtual{ Name: "v-crud", PackageType: models.PackageHelm, Members: []string{"a", "b"}, }); err != nil { t.Fatal(err) } v, err := testDB.GetVirtual(ctx(), "v-crud") if err != nil || len(v.Members) != 2 { t.Fatalf("get virtual: %v %v", v, err) } v.Members = []string{"a"} if err := testDB.UpdateVirtual(ctx(), v); err != nil { t.Fatal(err) } if vs, err := testDB.ListVirtuals(ctx()); err != nil || len(vs) == 0 { t.Fatalf("list virtuals: %v %v", len(vs), err) } if err := testDB.DeleteVirtual(ctx(), "v-crud"); err != nil { t.Fatal(err) } } func TestStats(t *testing.T) { requireDB(t) seedRemote(t, "r-stats") seedBlob(t, "statshash") testDB.UpsertArtifact(ctx(), "r-stats", "s.txt", "sha256:statshash", "") testDB.InsertAccessLog(ctx(), "r-stats", "s.txt", true, 100, 2, "") if _, err := testDB.GetOverviewStats(ctx()); err != nil { t.Fatalf("overview: %v", err) } if _, err := testDB.GetTopRemotes(ctx(), 5); err != nil { t.Fatalf("top remotes: %v", err) } if _, err := testDB.GetTopFilesByHits(ctx(), 5); err != nil { t.Fatalf("top files by hits: %v", err) } if _, err := testDB.GetTopFilesByBandwidth(ctx(), 5); err != nil { t.Fatalf("top files by bandwidth: %v", err) } } func TestRPMMetadata(t *testing.T) { requireDB(t) seedRemote(t, "r-rpm") meta := &provider.RPMMetadata{ RepoName: "r-rpm", FilePath: "Packages/x.rpm", ContentHash: "sha256:rpm", Name: "x", Version: "1.0", Release: "1", Arch: "noarch", Requires: []provider.RPMDep{{Name: "libc"}}, Provides: []provider.RPMDep{{Name: "x"}}, Files: []provider.RPMFile{}, } if err := testDB.InsertRPMMetadata(ctx(), meta); err != nil { t.Fatal(err) } entries, err := testDB.ListRPMMetadataEntries(ctx(), "r-rpm") if err != nil || len(entries) != 1 { t.Fatalf("list rpm entries: %v %v", len(entries), err) } if rows, err := testDB.ListRPMMetadata(ctx(), "r-rpm"); err != nil || len(rows) != 1 { t.Fatalf("list rpm rows: %v %v", len(rows), err) } }