152 lines
5.4 KiB
Go
152 lines
5.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func testCanvasHistory(t *testing.T, db *gorm.DB) {
|
|
t.Helper()
|
|
if db.Dialector.Name() == "sqlite" {
|
|
if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := db.AutoMigrate(&model.CanvasSnapshot{}, &model.CanvasSnapshotResource{}, &model.CanvasShare{}, &model.Task{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := New(db)
|
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
|
project := model.CanvasProject{ID: "history-canvas", UserID: "owner", Title: "original", PayloadJSON: `{"nodes":[{"id":"video"}]}`, UpdatedAt: now}
|
|
if err := repo.UpsertCanvasProject(&project); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resource := model.Resource{ID: "history-video", UserID: "owner", Status: model.ResourceStatusReady}
|
|
if err := db.Create(&resource).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot := func(p model.CanvasProject, at time.Time) *model.CanvasSnapshot {
|
|
return &model.CanvasSnapshot{ID: fmt.Sprintf("history-%d", p.Revision), CanvasID: p.ID, UserID: p.UserID, Revision: p.Revision, Title: p.Title, PayloadJSON: p.PayloadJSON, NodeCount: 1, CreatedAt: at}
|
|
}
|
|
save := func(at time.Time, force bool) {
|
|
t.Helper()
|
|
old := project
|
|
project.Title = fmt.Sprintf("edit-%d", old.Revision)
|
|
if err := repo.SaveCanvasWithSnapshot(&project, snapshot(old, at), []string{resource.ID}, nil, at.Add(-5*time.Minute), 20, force); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
save(now, false)
|
|
save(now.Add(4*time.Minute), false)
|
|
metadata, err := repo.CanvasProjectMetadata(project.UserID, project.ID)
|
|
if err != nil || metadata.Revision != project.Revision || metadata.UserID != project.UserID || metadata.PayloadJSON != "" {
|
|
t.Fatalf("canvas metadata: %#v %v", metadata, err)
|
|
}
|
|
items, err := repo.CanvasSnapshots("owner", project.ID, 20)
|
|
if err != nil || len(items) != 1 || items[0].PayloadJSON != "" {
|
|
t.Fatalf("summary/throttle: %#v %v", items, err)
|
|
}
|
|
save(now.Add(5*time.Minute), false)
|
|
items, _ = repo.CanvasSnapshots("owner", project.ID, 20)
|
|
if len(items) != 2 {
|
|
t.Fatalf("5 minute boundary = %d", len(items))
|
|
}
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID})
|
|
}); !errors.Is(err, ErrCanvasHistoryResourceReferenced) {
|
|
t.Fatalf("history protection = %v", err)
|
|
}
|
|
refs, err := repo.CanvasHistoryResourceReferences([]string{resource.ID})
|
|
if err != nil || len(refs) == 0 || refs[0].Kind != "画布历史版本" {
|
|
t.Fatalf("references = %#v %v", refs, err)
|
|
}
|
|
if err := db.Delete(&resource).Error; err == nil {
|
|
t.Fatal("database allowed deletion of a history resource")
|
|
}
|
|
if _, err := repo.CanvasSnapshot("other", project.ID, items[0].ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
t.Fatalf("wrong owner = %v", err)
|
|
}
|
|
|
|
// Snapshot failure must roll back the successful content CAS as well.
|
|
before := project
|
|
failed := project
|
|
failed.Title = "must rollback"
|
|
err = repo.SaveCanvasWithSnapshot(&failed, snapshot(project, now.Add(10*time.Minute)), []string{"missing"}, nil, now, 20, true)
|
|
if !errors.Is(err, ErrCanvasHistoryResourceMissing) {
|
|
t.Fatalf("missing resource = %v", err)
|
|
}
|
|
stored, _ := repo.CanvasProjectForUser("owner", project.ID)
|
|
if stored.Revision != before.Revision || stored.Title != before.Title || failed.Revision != before.Revision {
|
|
t.Fatal("failed snapshot changed content/revision")
|
|
}
|
|
|
|
// Concurrent writers create exactly one history entry along with one new revision.
|
|
start := make(chan struct{})
|
|
results := make(chan error, 2)
|
|
var workers sync.WaitGroup
|
|
for i := 0; i < 2; i++ {
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
copy := project
|
|
<-start
|
|
results <- repo.SaveCanvasWithSnapshot(©, snapshot(project, now.Add(10*time.Minute)), []string{resource.ID}, nil, now, 20, true)
|
|
}()
|
|
}
|
|
close(start)
|
|
workers.Wait()
|
|
close(results)
|
|
success, conflict := 0, 0
|
|
for err := range results {
|
|
if err == nil {
|
|
success++
|
|
} else if errors.Is(err, ErrCanvasRevisionConflict) {
|
|
conflict++
|
|
} else {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if success != 1 || conflict != 1 {
|
|
t.Fatalf("concurrent saves: success=%d conflict=%d", success, conflict)
|
|
}
|
|
stored, _ = repo.CanvasProjectForUser("owner", project.ID)
|
|
project = *stored
|
|
for i := 0; i < 23; i++ {
|
|
save(now.Add(time.Duration(20+i*5)*time.Minute), false)
|
|
}
|
|
var count, refCount int64
|
|
db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count)
|
|
db.Model(&model.CanvasSnapshotResource{}).Count(&refCount)
|
|
if count != 20 || refCount != 20 {
|
|
t.Fatalf("retention: snapshots=%d refs=%d", count, refCount)
|
|
}
|
|
save(now.Add(131*time.Minute), true)
|
|
items, _ = repo.CanvasSnapshots("owner", project.ID, 20)
|
|
if items[0].Revision != project.Revision-1 || len(items) != 20 {
|
|
t.Fatal("forced pre-restore backup missing or retention exceeded")
|
|
}
|
|
if err := repo.DeleteCanvasProject("owner", project.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Model(&model.CanvasSnapshot{}).Where("canvas_id = ?", project.ID).Count(&count)
|
|
db.Model(&model.CanvasSnapshotResource{}).Count(&refCount)
|
|
if count != 0 || refCount != 0 {
|
|
t.Fatal("canvas deletion left orphan history")
|
|
}
|
|
if err := db.Transaction(func(tx *gorm.DB) error {
|
|
return New(tx).RequireNoCanvasHistoryReferences([]string{resource.ID})
|
|
}); err != nil {
|
|
t.Fatalf("expired reference not released: %v", err)
|
|
}
|
|
if err := db.Delete(&resource).Error; err != nil {
|
|
t.Fatalf("expired foreign key not released: %v", err)
|
|
}
|
|
}
|