mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-01 13:35:46 +00:00
384 lines
12 KiB
Go
384 lines
12 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// setupBatchTestDB spins up a fresh in-memory libsql database with the full
|
|
// schema applied, so every batch test can write realistic data without
|
|
// stubbing individual tables.
|
|
func setupBatchTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
safeName := strings.ReplaceAll(t.Name(), "/", "_")
|
|
d, err := InitDB(fmt.Sprintf("file:%s?mode=memory&cache=shared", safeName), LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
// Single conn to avoid cross-test contention in the shared in-memory cache.
|
|
d.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { d.Close() })
|
|
return d
|
|
}
|
|
|
|
func createBatchTestUser(t *testing.T, d *sql.DB, did string) {
|
|
t.Helper()
|
|
_, err := d.Exec(`
|
|
INSERT OR IGNORE INTO users (did, handle, pds_endpoint, last_seen)
|
|
VALUES (?, ?, ?, datetime('now'))
|
|
`, did, did+".bsky.social", "https://pds.example.com")
|
|
if err != nil {
|
|
t.Fatalf("seed user: %v", err)
|
|
}
|
|
}
|
|
|
|
func countRows(t *testing.T, d *sql.DB, query string, args ...any) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := d.QueryRow(query, args...).Scan(&n); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestBuildPlaceholders(t *testing.T) {
|
|
cases := []struct {
|
|
rows, cols int
|
|
want string
|
|
}{
|
|
{1, 1, "(?)"},
|
|
{2, 1, "(?),(?)"},
|
|
{1, 3, "(?,?,?)"},
|
|
{3, 2, "(?,?),(?,?),(?,?)"},
|
|
{0, 5, ""},
|
|
{5, 0, ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := buildPlaceholders(c.rows, c.cols)
|
|
if got != c.want {
|
|
t.Errorf("buildPlaceholders(%d,%d) = %q, want %q", c.rows, c.cols, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBatchInsertManifests_InsertsAndReturnsIDs(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
now := time.Now()
|
|
manifests := []Manifest{
|
|
{DID: "did:plc:alice", Repository: "app1", Digest: "sha256:aaa", HoldEndpoint: "did:web:hold", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", ArtifactType: "container-image", CreatedAt: now},
|
|
{DID: "did:plc:alice", Repository: "app2", Digest: "sha256:bbb", HoldEndpoint: "did:web:hold", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", ArtifactType: "container-image", CreatedAt: now},
|
|
}
|
|
|
|
ids, err := BatchInsertManifests(d, manifests)
|
|
if err != nil {
|
|
t.Fatalf("batch insert: %v", err)
|
|
}
|
|
if len(ids) != 2 {
|
|
t.Fatalf("expected 2 ids, got %d", len(ids))
|
|
}
|
|
if ids[ManifestKey("did:plc:alice", "app1", "sha256:aaa")] == 0 {
|
|
t.Errorf("missing id for app1")
|
|
}
|
|
if ids[ManifestKey("did:plc:alice", "app2", "sha256:bbb")] == 0 {
|
|
t.Errorf("missing id for app2")
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM manifests`); got != 2 {
|
|
t.Errorf("row count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchInsertManifests_Idempotent(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
now := time.Now()
|
|
m := []Manifest{{
|
|
DID: "did:plc:alice", Repository: "app", Digest: "sha256:aaa",
|
|
HoldEndpoint: "did:web:hold", SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
ArtifactType: "container-image", CreatedAt: now,
|
|
}}
|
|
if _, err := BatchInsertManifests(d, m); err != nil {
|
|
t.Fatalf("first insert: %v", err)
|
|
}
|
|
if _, err := BatchInsertManifests(d, m); err != nil {
|
|
t.Fatalf("second insert: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM manifests`); got != 1 {
|
|
t.Errorf("expected idempotent; row count = %d", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchInsertManifests_Chunking(t *testing.T) {
|
|
// Exceed one sub-batch to exercise the chunk loop.
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
const n = BatchSize + 17
|
|
now := time.Now()
|
|
manifests := make([]Manifest, n)
|
|
for i := 0; i < n; i++ {
|
|
manifests[i] = Manifest{
|
|
DID: "did:plc:alice", Repository: "app", Digest: fmt.Sprintf("sha256:%04d", i),
|
|
HoldEndpoint: "did:web:hold", SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
ArtifactType: "container-image", CreatedAt: now,
|
|
}
|
|
}
|
|
ids, err := BatchInsertManifests(d, manifests)
|
|
if err != nil {
|
|
t.Fatalf("batch insert: %v", err)
|
|
}
|
|
if len(ids) != n {
|
|
t.Errorf("ids len = %d, want %d", len(ids), n)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM manifests`); got != n {
|
|
t.Errorf("row count = %d, want %d", got, n)
|
|
}
|
|
}
|
|
|
|
func TestBatchInsertLayers_RespectsFK(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
now := time.Now()
|
|
ids, err := BatchInsertManifests(d, []Manifest{{
|
|
DID: "did:plc:alice", Repository: "app", Digest: "sha256:aaa",
|
|
HoldEndpoint: "did:web:hold", SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
ArtifactType: "container-image", CreatedAt: now,
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("insert manifest: %v", err)
|
|
}
|
|
mid := ids[ManifestKey("did:plc:alice", "app", "sha256:aaa")]
|
|
|
|
layers := []Layer{
|
|
{ManifestID: mid, Digest: "sha256:L0", Size: 100, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 0},
|
|
{ManifestID: mid, Digest: "sha256:L1", Size: 200, MediaType: "application/vnd.oci.image.layer.v1.tar+gzip", LayerIndex: 1},
|
|
}
|
|
if err := BatchInsertLayers(d, layers); err != nil {
|
|
t.Fatalf("batch insert layers: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM layers`); got != 2 {
|
|
t.Errorf("layers count = %d, want 2", got)
|
|
}
|
|
// Re-run to confirm ON CONFLICT DO NOTHING doesn't error.
|
|
if err := BatchInsertLayers(d, layers); err != nil {
|
|
t.Fatalf("idempotent layers: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM layers`); got != 2 {
|
|
t.Errorf("layers after re-insert = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertTags_Idempotent(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
now := time.Now()
|
|
tags := []Tag{
|
|
{DID: "did:plc:alice", Repository: "app", Tag: "v1", Digest: "sha256:aaa", CreatedAt: now},
|
|
{DID: "did:plc:alice", Repository: "app", Tag: "v2", Digest: "sha256:bbb", CreatedAt: now},
|
|
}
|
|
if err := BatchUpsertTags(d, tags); err != nil {
|
|
t.Fatalf("batch upsert: %v", err)
|
|
}
|
|
if err := BatchUpsertTags(d, tags); err != nil {
|
|
t.Fatalf("rerun: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM tags`); got != 2 {
|
|
t.Errorf("tags count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertStars(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
createBatchTestUser(t, d, "did:plc:bob")
|
|
|
|
now := time.Now()
|
|
stars := []StarInput{
|
|
{StarrerDID: "did:plc:bob", OwnerDID: "did:plc:alice", Repository: "app", CreatedAt: now},
|
|
}
|
|
if err := BatchUpsertStars(d, stars); err != nil {
|
|
t.Fatalf("batch upsert stars: %v", err)
|
|
}
|
|
// Re-insert to confirm ON CONFLICT DO NOTHING.
|
|
if err := BatchUpsertStars(d, stars); err != nil {
|
|
t.Fatalf("rerun: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM stars`); got != 1 {
|
|
t.Errorf("stars count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertRepoPages(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
now := time.Now()
|
|
pages := []RepoPage{
|
|
{DID: "did:plc:alice", Repository: "app", Description: "desc", CreatedAt: now, UpdatedAt: now},
|
|
}
|
|
if err := BatchUpsertRepoPages(d, pages); err != nil {
|
|
t.Fatalf("batch upsert: %v", err)
|
|
}
|
|
// Update with new description.
|
|
pages[0].Description = "new desc"
|
|
if err := BatchUpsertRepoPages(d, pages); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
var desc string
|
|
if err := d.QueryRow(`SELECT description FROM repo_pages WHERE did=? AND repository=?`,
|
|
"did:plc:alice", "app").Scan(&desc); err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if desc != "new desc" {
|
|
t.Errorf("description = %q, want %q", desc, "new desc")
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertDailyStats(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
stats := []DailyStats{
|
|
{DID: "did:plc:alice", Repository: "app", Date: "2026-04-19", PullCount: 5, PushCount: 2},
|
|
}
|
|
if err := BatchUpsertDailyStats(d, stats); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
stats[0].PullCount = 10
|
|
if err := BatchUpsertDailyStats(d, stats); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
var pull int
|
|
if err := d.QueryRow(`SELECT pull_count FROM repository_stats_daily WHERE did=? AND repository=? AND date=?`,
|
|
"did:plc:alice", "app", "2026-04-19").Scan(&pull); err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if pull != 10 {
|
|
t.Errorf("pull = %d, want 10", pull)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertRepositoryAnnotations_DropsStaleKeys(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
rows := []AnnotationRow{
|
|
{DID: "did:plc:alice", Repository: "app", Key: "a", Value: "1"},
|
|
{DID: "did:plc:alice", Repository: "app", Key: "b", Value: "2"},
|
|
}
|
|
if err := BatchUpsertRepositoryAnnotations(d, rows); err != nil {
|
|
t.Fatalf("initial: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM repository_annotations WHERE did=? AND repository=?`,
|
|
"did:plc:alice", "app"); got != 2 {
|
|
t.Errorf("initial count = %d, want 2", got)
|
|
}
|
|
|
|
// Second call drops stale key "b".
|
|
rows = []AnnotationRow{
|
|
{DID: "did:plc:alice", Repository: "app", Key: "a", Value: "1-updated"},
|
|
}
|
|
if err := BatchUpsertRepositoryAnnotations(d, rows); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM repository_annotations WHERE did=? AND repository=?`,
|
|
"did:plc:alice", "app"); got != 1 {
|
|
t.Errorf("after update = %d, want 1", got)
|
|
}
|
|
var val string
|
|
if err := d.QueryRow(`SELECT value FROM repository_annotations WHERE key=? AND did=? AND repository=?`,
|
|
"a", "did:plc:alice", "app").Scan(&val); err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if val != "1-updated" {
|
|
t.Errorf("value = %q, want 1-updated", val)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertCaptainRecords(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
|
|
now := time.Now()
|
|
records := []HoldCaptainRecord{
|
|
{HoldDID: "did:web:hold1", OwnerDID: "did:plc:alice", Public: true, AllowAllCrew: false, UpdatedAt: now},
|
|
}
|
|
if err := BatchUpsertCaptainRecords(d, records); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
if got := countRows(t, d, `SELECT COUNT(*) FROM hold_captain_records`); got != 1 {
|
|
t.Errorf("count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchUpsertCrewMembers(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
|
|
members := []CrewMember{
|
|
{HoldDID: "did:web:hold1", MemberDID: "did:plc:alice", Rkey: "rkey1", Role: "owner"},
|
|
}
|
|
if err := BatchUpsertCrewMembers(d, members); err != nil {
|
|
t.Fatalf("upsert: %v", err)
|
|
}
|
|
// Update the rkey: triggers the ON CONFLICT path.
|
|
members[0].Rkey = "rkey2"
|
|
if err := BatchUpsertCrewMembers(d, members); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
var rkey string
|
|
if err := d.QueryRow(`SELECT rkey FROM hold_crew_members WHERE hold_did=? AND member_did=?`,
|
|
"did:web:hold1", "did:plc:alice").Scan(&rkey); err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if rkey != "rkey2" {
|
|
t.Errorf("rkey = %q, want rkey2", rkey)
|
|
}
|
|
}
|
|
|
|
func TestBatchEmptySlices(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
// Every batch function must tolerate an empty input slice without erroring.
|
|
if _, err := BatchInsertManifests(d, nil); err != nil {
|
|
t.Errorf("manifests: %v", err)
|
|
}
|
|
if err := BatchInsertLayers(d, nil); err != nil {
|
|
t.Errorf("layers: %v", err)
|
|
}
|
|
if err := BatchInsertManifestReferences(d, nil); err != nil {
|
|
t.Errorf("refs: %v", err)
|
|
}
|
|
if err := BatchUpsertTags(d, nil); err != nil {
|
|
t.Errorf("tags: %v", err)
|
|
}
|
|
if err := BatchUpsertStars(d, nil); err != nil {
|
|
t.Errorf("stars: %v", err)
|
|
}
|
|
if err := BatchUpsertRepoPages(d, nil); err != nil {
|
|
t.Errorf("repo pages: %v", err)
|
|
}
|
|
if err := BatchUpsertDailyStats(d, nil); err != nil {
|
|
t.Errorf("daily: %v", err)
|
|
}
|
|
if err := BatchUpsertRepositoryStats(d, nil); err != nil {
|
|
t.Errorf("repo stats: %v", err)
|
|
}
|
|
if err := BatchUpsertCaptainRecords(d, nil); err != nil {
|
|
t.Errorf("captain: %v", err)
|
|
}
|
|
if err := BatchUpsertCrewMembers(d, nil); err != nil {
|
|
t.Errorf("crew: %v", err)
|
|
}
|
|
if err := BatchUpsertRepositoryAnnotations(d, nil); err != nil {
|
|
t.Errorf("annotations: %v", err)
|
|
}
|
|
}
|