feat(store): add blob, file, folder, and upload stores

Add BlobStore (ref counting), FileStore (soft delete + pagination), FolderStore (materialized path), UploadStore (idempotent upsert), and update AutoMigrate.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-15 09:22:44 +08:00
parent c861ff3adf
commit bf89de12f0
9 changed files with 1442 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
package store
import (
"context"
"testing"
"gcy_hpc_server/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupBlobTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.FileBlob{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestBlobStore_Create(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
blob := &model.FileBlob{
SHA256: "abc123",
MinioKey: "files/abc123",
FileSize: 1024,
MimeType: "application/octet-stream",
RefCount: 0,
}
if err := store.Create(ctx, blob); err != nil {
t.Fatalf("Create() error = %v", err)
}
if blob.ID == 0 {
t.Error("Create() did not set ID")
}
}
func TestBlobStore_GetBySHA256(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
store.Create(ctx, &model.FileBlob{SHA256: "abc", MinioKey: "files/abc", FileSize: 100, RefCount: 0})
blob, err := store.GetBySHA256(ctx, "abc")
if err != nil {
t.Fatalf("GetBySHA256() error = %v", err)
}
if blob == nil {
t.Fatal("GetBySHA256() returned nil")
}
if blob.SHA256 != "abc" {
t.Errorf("SHA256 = %q, want %q", blob.SHA256, "abc")
}
if blob.RefCount != 0 {
t.Errorf("RefCount = %d, want 0", blob.RefCount)
}
}
func TestBlobStore_GetBySHA256_NotFound(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
blob, err := store.GetBySHA256(ctx, "nonexistent")
if err != nil {
t.Fatalf("GetBySHA256() error = %v", err)
}
if blob != nil {
t.Error("GetBySHA256() should return nil for not found")
}
}
func TestBlobStore_IncrementDecrementRef(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
store.Create(ctx, &model.FileBlob{SHA256: "abc", MinioKey: "files/abc", FileSize: 100, RefCount: 0})
if err := store.IncrementRef(ctx, "abc"); err != nil {
t.Fatalf("IncrementRef() error = %v", err)
}
blob, _ := store.GetBySHA256(ctx, "abc")
if blob.RefCount != 1 {
t.Errorf("RefCount after 1st increment = %d, want 1", blob.RefCount)
}
store.IncrementRef(ctx, "abc")
blob, _ = store.GetBySHA256(ctx, "abc")
if blob.RefCount != 2 {
t.Errorf("RefCount after 2nd increment = %d, want 2", blob.RefCount)
}
refCount, err := store.DecrementRef(ctx, "abc")
if err != nil {
t.Fatalf("DecrementRef() error = %v", err)
}
if refCount != 1 {
t.Errorf("DecrementRef() returned %d, want 1", refCount)
}
refCount, _ = store.DecrementRef(ctx, "abc")
if refCount != 0 {
t.Errorf("DecrementRef() returned %d, want 0", refCount)
}
}
func TestBlobStore_Delete(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
store.Create(ctx, &model.FileBlob{SHA256: "abc", MinioKey: "files/abc", FileSize: 100, RefCount: 0})
if err := store.Delete(ctx, "abc"); err != nil {
t.Fatalf("Delete() error = %v", err)
}
blob, _ := store.GetBySHA256(ctx, "abc")
if blob != nil {
t.Error("Delete() did not remove blob")
}
}
func TestBlobStore_SHA256_UniqueConstraint(t *testing.T) {
db := setupBlobTestDB(t)
store := NewBlobStore(db)
ctx := context.Background()
store.Create(ctx, &model.FileBlob{SHA256: "dup", MinioKey: "files/dup1", FileSize: 100})
err := store.Create(ctx, &model.FileBlob{SHA256: "dup", MinioKey: "files/dup2", FileSize: 200})
if err == nil {
t.Error("expected error for duplicate SHA256, got nil")
}
}