Files
ttstd-infinite-canvas/backend/internal/canvas/repair_asset_bytes_test.go

182 lines
5.1 KiB
Go

package canvas
import (
"encoding/json"
"errors"
"fmt"
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
)
func TestRepairAssetBytes(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
sqlDB.SetMaxOpenConns(1)
testRepairAssetBytes(t, db)
}
func TestRepairAssetBytesPostgres(t *testing.T) {
dsn := os.Getenv("CANVAS_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("CANVAS_TEST_POSTGRES_DSN is not configured")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
rollback := errors.New("rollback isolated test schema")
err = db.Transaction(func(tx *gorm.DB) error {
schema := fmt.Sprintf("repair_asset_bytes_%d", time.Now().UnixNano())
if err := tx.Exec("CREATE SCHEMA " + schema).Error; err != nil {
return err
}
if err := tx.Exec("SET LOCAL search_path TO " + schema).Error; err != nil {
return err
}
testRepairAssetBytes(t, tx)
return rollback
})
if !errors.Is(err, rollback) {
t.Fatal(err)
}
}
func testRepairAssetBytes(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.AutoMigrate(&model.Asset{}, &model.Resource{}); err != nil {
t.Fatal(err)
}
resource := model.Resource{ID: "resource-1", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Size: 12345}
if err := db.Create(&resource).Error; err != nil {
t.Fatal(err)
}
originals := map[string]string{}
for i := 0; i < 106; i++ {
id := fmt.Sprintf("asset-%03d", i)
payload := testUserAssetPayload("image", nil)
payload["id"] = id
payload["customExactInteger"] = json.RawMessage("9007199254740993")
data := payload["data"].(map[string]any)
data["storageKey"] = "resource:resource-1"
data["bytes"] = nil
userID := "user-1"
switch i {
case 0:
data["bytes"] = 55 // Valid values must remain untouched.
case 1:
userID = "another-user"
case 2:
delete(data, "storageKey")
case 3:
data["bytes"] = "123"
case 4:
delete(data, "bytes")
case 5:
data["bytes"] = -1
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
originals[id] = string(raw)
asset := model.Asset{ID: id, UserID: userID, Kind: "image", PayloadJSON: string(raw)}
if err := db.Create(&asset).Error; err != nil {
t.Fatal(err)
}
}
report, err := RepairAssetBytes(db, false)
if err != nil || report.Scanned != 106 || report.Repairable != 103 || report.Repaired != 0 || len(report.Unresolved) != 2 {
t.Fatalf("dry run: %+v, %v", report, err)
}
var before []model.Asset
if err := db.Find(&before).Error; err != nil {
t.Fatal(err)
}
for _, asset := range before {
if asset.PayloadJSON != originals[asset.ID] {
t.Fatal("dry run modified data")
}
}
report, err = RepairAssetBytes(db, true)
if err != nil || report.Repaired != 103 || len(report.Unresolved) != 2 {
t.Fatalf("apply: %+v, %v", report, err)
}
var after []model.Asset
if err := db.Find(&after).Error; err != nil {
t.Fatal(err)
}
for _, asset := range after {
if asset.ID <= "asset-002" {
if asset.PayloadJSON != originals[asset.ID] {
t.Fatal("valid or unresolved asset modified")
}
continue
}
var payload map[string]json.RawMessage
var data map[string]json.RawMessage
if err := json.Unmarshal([]byte(asset.PayloadJSON), &payload); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(payload["data"], &data); err != nil {
t.Fatal(err)
}
if string(data["bytes"]) != "12345" || string(payload["customExactInteger"]) != "9007199254740993" {
t.Fatal("repair corrupted payload")
}
var stamp string
_ = json.Unmarshal(payload["updatedAt"], &stamp)
parsed, err := time.Parse(time.RFC3339Nano, stamp)
// PostgreSQL stores microseconds; compare at that precision.
if err != nil || parsed.Sub(asset.UpdatedAt).Abs() >= time.Microsecond {
t.Fatal("timestamps disagree")
}
}
report, err = RepairAssetBytes(db, true)
if err != nil || report.Repairable != 0 || report.Repaired != 0 {
t.Fatalf("not idempotent: %+v, %v", report, err)
}
for _, id := range []string{"asset-003", "asset-004"} {
if err := db.Model(&model.Asset{}).Where("id = ?", id).Update("payload_json", originals[id]).Error; err != nil {
t.Fatal(err)
}
}
updates := 0
const callback = "test:fail-second-asset-repair"
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
updates++
if updates == 2 {
tx.AddError(errors.New("injected update failure"))
}
}); err != nil {
t.Fatal(err)
}
report, err = RepairAssetBytes(db, true)
if removeErr := db.Callback().Update().Remove(callback); removeErr != nil {
t.Fatal(removeErr)
}
if err == nil || report.Repaired != 0 {
t.Fatalf("failed repair reported success: %+v, %v", report, err)
}
for _, id := range []string{"asset-003", "asset-004"} {
var asset model.Asset
if err := db.First(&asset, "id = ?", id).Error; err != nil {
t.Fatal(err)
}
if asset.PayloadJSON != originals[id] {
t.Fatal("failed batch was not rolled back")
}
}
}