diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..5edbf41 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,133 @@ +package cache + +import ( + "context" + "os" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/testsupport" +) + +var testRedis *Redis + +func TestMain(m *testing.M) { + ctx := context.Background() + url, terminate, err := testsupport.StartRedis(ctx) + if err != nil { + os.Exit(m.Run()) + } + r, err := NewRedis(url) + if err != nil { + terminate() + panic(err) + } + testRedis = r + code := m.Run() + r.Close() + terminate() + if code != 0 { + os.Exit(code) + } +} + +func requireRedis(t *testing.T) { + t.Helper() + if testRedis == nil { + t.Skip("Docker unavailable; skipping cache integration test") + } +} + +func TestNewRedisInvalid(t *testing.T) { + if _, err := NewRedis("://bad-url"); err == nil { + t.Error("expected error for invalid redis URL") + } +} + +func TestTTL(t *testing.T) { + requireRedis(t) + ctx := context.Background() + if fresh, _ := testRedis.CheckTTL(ctx, "r", "missing"); fresh { + t.Error("missing key should not be fresh") + } + if err := testRedis.SetTTL(ctx, "r", "p", time.Minute); err != nil { + t.Fatal(err) + } + if fresh, err := testRedis.CheckTTL(ctx, "r", "p"); err != nil || !fresh { + t.Errorf("expected fresh after SetTTL: %v %v", fresh, err) + } +} + +func TestLock(t *testing.T) { + requireRedis(t) + ctx := context.Background() + ok, err := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute) + if err != nil || !ok { + t.Fatalf("first acquire should succeed: %v %v", ok, err) + } + if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); ok { + t.Error("second acquire should fail while held") + } + if err := testRedis.ReleaseLock(ctx, "r", "lockpath"); err != nil { + t.Fatal(err) + } + if ok, _ := testRedis.AcquireLock(ctx, "r", "lockpath", time.Minute); !ok { + t.Error("acquire should succeed after release") + } +} + +func TestETagAndToken(t *testing.T) { + requireRedis(t) + ctx := context.Background() + if v, _ := testRedis.GetETag(ctx, "r", "missing"); v != "" { + t.Error("missing etag should be empty") + } + testRedis.SetETag(ctx, "r", "p", `"abc"`, time.Minute) + if v, _ := testRedis.GetETag(ctx, "r", "p"); v != `"abc"` { + t.Errorf("etag = %q", v) + } + + if v, _ := testRedis.GetToken(ctx, "missing"); v != "" { + t.Error("missing token should be empty") + } + testRedis.SetToken(ctx, "key", "tok", time.Minute) + if v, _ := testRedis.GetToken(ctx, "key"); v != "tok" { + t.Errorf("token = %q", v) + } +} + +func TestCircuit(t *testing.T) { + requireRedis(t) + ctx := context.Background() + if n, _ := testRedis.GetCircuitFailures(ctx, "cr"); n != 0 { + t.Errorf("initial failures = %d", n) + } + n1, err := testRedis.IncrCircuitFailure(ctx, "cr", time.Minute) + if err != nil || n1 != 1 { + t.Fatalf("first incr = %d %v", n1, err) + } + n2, _ := testRedis.IncrCircuitFailure(ctx, "cr", time.Minute) + if n2 != 2 { + t.Errorf("second incr = %d", n2) + } + if n, _ := testRedis.GetCircuitFailures(ctx, "cr"); n != 2 { + t.Errorf("get failures = %d", n) + } + testRedis.ResetCircuit(ctx, "cr") + if n, _ := testRedis.GetCircuitFailures(ctx, "cr"); n != 0 { + t.Errorf("failures after reset = %d", n) + } +} + +func TestFlushRemote(t *testing.T) { + requireRedis(t) + ctx := context.Background() + testRedis.SetTTL(ctx, "flushme", "a", time.Hour) + testRedis.SetETag(ctx, "flushme", "a", "x", time.Hour) + if err := testRedis.FlushRemote(ctx, "flushme"); err != nil { + t.Fatal(err) + } + if fresh, _ := testRedis.CheckTTL(ctx, "flushme", "a"); fresh { + t.Error("expected keys flushed") + } +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..ce0f18b --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,134 @@ +package storage + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/testsupport" +) + +var testS3 *S3 + +func TestMain(m *testing.M) { + ctx := context.Background() + conn, terminate, err := testsupport.StartMinio(ctx) + if err != nil { + os.Exit(m.Run()) + } + s3, err := NewS3(conn.Endpoint, conn.AccessKey, conn.SecretKey, "test-bucket", false, "") + if err != nil { + terminate() + panic(err) + } + testS3 = s3 + code := m.Run() + terminate() + if code != 0 { + os.Exit(code) + } +} + +func requireS3(t *testing.T) { + t.Helper() + if testS3 == nil { + t.Skip("Docker unavailable; skipping storage integration test") + } +} + +func TestKeys(t *testing.T) { + if BlobKey("abc") != "blobs/sha256/abc" { + t.Error("BlobKey") + } + if IndexKey("remote", "path/to/x") != "indexes/remote/path/to/x" { + t.Error("IndexKey") + } +} + +func TestS3RoundTrip(t *testing.T) { + requireS3(t) + ctx := context.Background() + key := "blobs/sha256/test1" + content := []byte("hello storage") + + if err := testS3.Upload(ctx, key, bytes.NewReader(content), int64(len(content)), "text/plain"); err != nil { + t.Fatalf("upload: %v", err) + } + + exists, err := testS3.Exists(ctx, key) + if err != nil || !exists { + t.Fatalf("exists after upload: %v %v", exists, err) + } + + reader, info, err := testS3.Download(ctx, key) + if err != nil { + t.Fatalf("download: %v", err) + } + got, _ := io.ReadAll(reader) + reader.Close() + if !bytes.Equal(got, content) { + t.Errorf("content mismatch: %q", got) + } + if info.Size != int64(len(content)) || info.ContentType != "text/plain" { + t.Errorf("stat info wrong: size=%d ct=%s", info.Size, info.ContentType) + } + + if _, err := testS3.Stat(ctx, key); err != nil { + t.Errorf("stat: %v", err) + } + + if err := testS3.Delete(ctx, key); err != nil { + t.Fatalf("delete: %v", err) + } + if exists, _ := testS3.Exists(ctx, key); exists { + t.Error("expected object gone after delete") + } +} + +func TestS3DownloadMissing(t *testing.T) { + requireS3(t) + if _, _, err := testS3.Download(context.Background(), "does/not/exist"); err == nil { + t.Error("expected error downloading missing key") + } +} + +func TestCASStore(t *testing.T) { + requireS3(t) + ctx := context.Background() + cas := NewCAS(testS3) + content := "content-addressed payload" + + res, err := cas.Store(ctx, strings.NewReader(content), "text/plain") + if err != nil { + t.Fatalf("store: %v", err) + } + if res.AlreadyExists { + t.Error("first store should not report AlreadyExists") + } + if res.SizeBytes != int64(len(content)) || !strings.HasPrefix(res.ContentHash, "sha256:") { + t.Errorf("unexpected result: %+v", res) + } + + // Storing identical content again is deduplicated. + res2, err := cas.Store(ctx, strings.NewReader(content), "text/plain") + if err != nil { + t.Fatalf("store again: %v", err) + } + if !res2.AlreadyExists || res2.ContentHash != res.ContentHash { + t.Errorf("second store should dedup: %+v", res2) + } + + // The stored blob is retrievable. + reader, _, err := testS3.Download(ctx, res.S3Key) + if err != nil { + t.Fatalf("download stored blob: %v", err) + } + got, _ := io.ReadAll(reader) + reader.Close() + if string(got) != content { + t.Errorf("stored content mismatch: %q", got) + } +}