Files
summercms/lagoon/attach/lifecycle_test.go
Jakub Zych d8ecb41981 feat(05-04): add attach delete lifecycle and static file handler
- DeleteForOwner removes system_files rows in-tx; blobs after commit
- Soft-delete of an owner keeps rows and blobs
- StaticHandler serves exact partition+disk_name keys and 404s traversal
2026-09-18 20:00:31 +02:00

200 lines
5.4 KiB
Go

package attach_test
import (
"context"
"database/sql"
"fmt"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/summercms/lagoon"
"git.golem15.com/golem15/summercms/lagoon/attach"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
type lifecycleOwner struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (lifecycleOwner) TableName() string { return "attach_lifecycle_owners" }
func (lifecycleOwner) MorphName() string {
return `Golem15\Fonoteka\Models\Album`
}
func TestFileLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`
CREATE TABLE attach_lifecycle_owners (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
deleted_at TIMESTAMPTZ
)`).Error; err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
owner := lifecycleOwner{Name: "album"}
if err := gdb.Create(&owner).Error; err != nil {
t.Fatal(err)
}
file := attach.File{
DiskName: "abc123xyz.jpg",
FileName: "cover.jpg",
FileSize: 12,
ContentType: "image/jpeg",
Field: "photos",
AttachmentID: strconv.FormatUint(uint64(owner.ID), 10),
AttachmentType: owner.MorphName(),
IsPublic: true,
}
if err := gdb.Create(&file).Error; err != nil {
t.Fatal(err)
}
origKey := attach.BlobKey(file.DiskName)
thumbKey := attach.PartitionDirectory(file.DiskName) + attach.ThumbFilename(file.ID, 200, 200, 0, 0, "crop", "jpg")
if err := bucket.WriteAll(ctx, origKey, []byte("original"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
if err := gdb.Delete(&owner).Error; err != nil {
t.Fatal(err)
}
var softOwner lifecycleOwner
if err := gdb.Unscoped().First(&softOwner, owner.ID).Error; err != nil {
t.Fatal(err)
}
if !softOwner.DeletedAt.Valid {
t.Fatal("soft-delete must set deleted_at")
}
assertFileRow(t, gdb, file.ID, true)
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
if err := gdb.Transaction(func(tx *gorm.DB) error {
return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error {
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
return fmt.Errorf("rollback after collecting keys")
})
}); err == nil {
t.Fatal("expected rollback")
}
assertFileRow(t, gdb, file.ID, true)
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
var pending []string
if err := gdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&owner).Error; err != nil {
return err
}
return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error {
pending = append([]string(nil), keys...)
var n int64
if err := tx.Model(&attach.File{}).Where("id = ?", file.ID).Count(&n).Error; err != nil {
return err
}
if n != 0 {
return fmt.Errorf("system_files row must be gone inside the force-delete transaction")
}
exists, err := bucket.Exists(ctx, origKey)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("blob must still exist before commit")
}
return nil
})
}); err != nil {
t.Fatal(err)
}
assertFileRow(t, gdb, file.ID, false)
assertBlob(t, ctx, bucket, origKey, true)
if err := attach.DeleteKeys(ctx, bucket, pending); err != nil {
t.Fatal(err)
}
assertBlob(t, ctx, bucket, origKey, false)
assertBlob(t, ctx, bucket, thumbKey, false)
}
func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) {
t.Helper()
var n int64
if err := gdb.Model(&attach.File{}).Where("id = ?", id).Count(&n).Error; err != nil {
t.Fatal(err)
}
if got := n > 0; got != want {
t.Fatalf("system_files id %d exists=%v, want %v", id, got, want)
}
}
func assertBlob(t *testing.T, ctx context.Context, bucket *blob.Bucket, key string, want bool) {
t.Helper()
got, err := bucket.Exists(ctx, key)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("blob %q exists=%v, want %v", key, got, want)
}
}
func attachGorm(t *testing.T) *gorm.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("attach"),
postgres.WithUsername("attach"),
postgres.WithPassword("attach"),
postgres.BasicWaitStrategies(),
testcontainers.WithEnv(map[string]string{
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
}),
)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.PingContext(ctx); err != nil {
t.Fatal(err)
}
gdb, err := lagoon.Use(ctx, db)
if err != nil {
t.Fatalf("lagoon.Use: %v", err)
}
return gdb
}