mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 12:26:33 +00:00
Use the common temporary root for Library, Restore probes, Preview and script output. Preserve active ownership, ordinary outputs and retryable cleanup failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
17 KiB
Go
388 lines
17 KiB
Go
package fileops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/samuelncui/yatm/entity"
|
|
"github.com/samuelncui/yatm/internal/executor"
|
|
"github.com/samuelncui/yatm/internal/library"
|
|
"github.com/samuelncui/yatm/internal/resource"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/protobuf/proto"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type fixture struct {
|
|
exe *executor.Executor
|
|
location *library.Location
|
|
libDB, executorDB *gorm.DB
|
|
root, work string
|
|
}
|
|
|
|
func setup(t *testing.T) *fixture {
|
|
t.Helper()
|
|
// Real operations use only an isolated original directory and temporary databases.
|
|
base := t.TempDir()
|
|
root, work := filepath.Join(base, "originals"), filepath.Join(base, "work")
|
|
require.NoError(t, os.Mkdir(root, 0755))
|
|
executorDB, err := resource.OpenSQLite(filepath.Join(base, "executor.db"))
|
|
require.NoError(t, err)
|
|
libDB, err := resource.OpenSQLite(filepath.Join(base, "library.db"))
|
|
require.NoError(t, err)
|
|
for _, db := range []*gorm.DB{libDB, executorDB} {
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
|
}
|
|
lib := library.New(libDB)
|
|
require.NoError(t, lib.PrepareTemporary(work))
|
|
require.NoError(t, lib.AutoMigrate())
|
|
exe := executor.New(executorDB, lib, nil, executor.Paths{Work: work, Access: []executor.AccessRange{{Root: root}}}, executor.Scripts{}, nil)
|
|
require.NoError(t, exe.AutoMigrate())
|
|
root, err = exe.LocationRoot(root)
|
|
require.NoError(t, err)
|
|
location := &library.Location{Name: "Documents", ExecutorID: "local", RootPath: root}
|
|
require.NoError(t, lib.CreateLocation(context.Background(), location))
|
|
f := &fixture{exe: exe, location: location, libDB: libDB, executorDB: executorDB, root: root, work: work}
|
|
t.Cleanup(func() {
|
|
var count int64
|
|
require.NoError(t, executorDB.Model(executor.ModelJob).Count(&count).Error)
|
|
require.Zero(t, count, "ordinary file operations must never create catalog Jobs")
|
|
entries, err := os.ReadDir(resource.TemporaryRoot(work))
|
|
if os.IsNotExist(err) {
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Empty(t, entries, "ordinary file operations must immediately remove disposable manifests")
|
|
})
|
|
return f
|
|
}
|
|
|
|
func (f *fixture) ref(t *testing.T, name string) *entity.FileOperationRef {
|
|
t.Helper()
|
|
entry, err := f.exe.ObserveLocationEntry(context.Background(), f.location.ID, name)
|
|
require.NoError(t, err)
|
|
return &entity.FileOperationRef{Target: &entity.FileOperationRef_Location{Location: entry.Reference}}
|
|
}
|
|
|
|
func (f *fixture) write(t *testing.T, name, content string) {
|
|
t.Helper()
|
|
full := filepath.Join(f.root, name)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0755))
|
|
require.NoError(t, os.WriteFile(full, []byte(content), 0644))
|
|
}
|
|
|
|
func (f *fixture) original(t *testing.T, name string) *library.File {
|
|
t.Helper()
|
|
file := &library.File{Name: "Logical " + filepath.Base(name), Kind: entity.FileKind_FILE_KIND_REGULAR, Note: "keep this note"}
|
|
require.NoError(t, f.exe.Lib().SaveFile(context.Background(), file))
|
|
info, err := os.Stat(filepath.Join(f.root, name))
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.libDB.Create(&library.FileLocation{FileID: file.ID, LocationID: f.location.ID, Path: name,
|
|
Size: info.Size(), Mode: uint32(info.Mode()), MtimeNS: info.ModTime().UnixNano()}).Error)
|
|
return file
|
|
}
|
|
|
|
type operationStream struct {
|
|
grpc.ServerStream
|
|
ctx context.Context
|
|
updates []*entity.FileOperationResult
|
|
onSend func(*entity.FileOperationResult) error
|
|
}
|
|
|
|
type operationRequest struct {
|
|
Spec *entity.FileOperationSpec
|
|
Dryrun bool
|
|
}
|
|
|
|
func (s *service) executeRequest(req *operationRequest, stream ResultStream) error {
|
|
return s.execute(req.Spec, req.Dryrun, stream)
|
|
}
|
|
|
|
func (s *operationStream) Context() context.Context { return s.ctx }
|
|
func (s *operationStream) Send(update *entity.FileOperationResult) error {
|
|
s.updates = append(s.updates, proto.Clone(update).(*entity.FileOperationResult))
|
|
if s.onSend != nil {
|
|
return s.onSend(update)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fixture) execute(t *testing.T, spec *entity.FileOperationSpec, prepared func()) *operationStream {
|
|
t.Helper()
|
|
stream := &operationStream{ctx: context.Background()}
|
|
stream.onSend = func(update *entity.FileOperationResult) error {
|
|
if update.Entry == nil && update.Summary != nil && !update.Summary.Completed && prepared != nil {
|
|
prepared()
|
|
}
|
|
return nil
|
|
}
|
|
require.NoError(t, (&service{exe: f.exe}).executeRequest(&operationRequest{Spec: spec}, stream))
|
|
require.True(t, stream.updates[len(stream.updates)-1].GetSummary().GetCompleted())
|
|
// Streaming summaries account for each item exactly once, including deferred directory results.
|
|
seen := make(map[int64]bool)
|
|
var settled int64
|
|
for _, update := range stream.updates {
|
|
if update.Entry == nil {
|
|
continue
|
|
}
|
|
require.False(t, seen[update.Entry.Id])
|
|
seen[update.Entry.Id] = true
|
|
if update.Entry.Outcome != entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_UNPROCESSED {
|
|
settled++
|
|
}
|
|
require.Equal(t, settled, update.Summary.SucceededCount+update.Summary.FailedCount+update.Summary.PublicationPendingCount)
|
|
}
|
|
require.Equal(t, stream.summary().TotalItemCount, int64(len(seen)))
|
|
return stream
|
|
}
|
|
|
|
func (s *operationStream) summary() *entity.FileOperationSummary {
|
|
return s.updates[len(s.updates)-1].GetSummary()
|
|
}
|
|
|
|
func TestRequestRenamePreservesOrganizationAndNestedOriginals(t *testing.T) {
|
|
f := setup(t)
|
|
f.write(t, "照片/one.txt", "one")
|
|
f.write(t, "照片/nested/two.txt", "two")
|
|
first, second := f.original(t, "照片/one.txt"), f.original(t, "照片/nested/two.txt")
|
|
stream := f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE, Sources: []*entity.FileOperationRef{f.ref(t, "照片")}, Destination: f.ref(t, ""), Name: "Renamed"}, nil)
|
|
require.Equal(t, int64(1), stream.summary().SucceededCount)
|
|
require.NoFileExists(t, filepath.Join(f.root, "照片"))
|
|
for _, expected := range []struct {
|
|
file *library.File
|
|
path string
|
|
}{{first, "Renamed/one.txt"}, {second, "Renamed/nested/two.txt"}} {
|
|
original, err := f.exe.Lib().GetFileLocation(context.Background(), expected.file.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected.path, original.Path)
|
|
stored, err := f.exe.Lib().GetFile(context.Background(), expected.file.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected.file.Name, stored.Name)
|
|
require.Equal(t, "keep this note", stored.Note)
|
|
}
|
|
}
|
|
|
|
func TestRequestDeleteRecyclesEntriesThatAppearedAfterPlanning(t *testing.T) {
|
|
// Removal recycles the selected directory as it is now; nothing is permanently deleted.
|
|
f := setup(t)
|
|
f.write(t, "folder/old.txt", "old")
|
|
file := f.original(t, "folder/old.txt")
|
|
version := &library.FileVersion{FileID: file.ID, Signature: []byte("opaque-saved"), Size: 3}
|
|
require.NoError(t, f.libDB.Create(version).Error)
|
|
stream := f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "folder")}}, func() { f.write(t, "folder/new.txt", "must stay") })
|
|
require.Equal(t, int64(1), stream.summary().SucceededCount)
|
|
require.NoFileExists(t, filepath.Join(f.root, "folder"))
|
|
recycled := ""
|
|
require.NoError(t, filepath.WalkDir(f.root, func(current string, entry os.DirEntry, err error) error {
|
|
if err != nil || entry.IsDir() || entry.Name() != "new.txt" {
|
|
return err
|
|
}
|
|
recycled = current
|
|
return nil
|
|
}))
|
|
require.NotEmpty(t, recycled, "an entry that appeared after planning stays recoverable in Trash")
|
|
data, err := os.ReadFile(recycled)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "must stay", string(data))
|
|
require.NoError(t, f.libDB.First(&library.FileVersion{}, version.ID).Error)
|
|
_, err = f.exe.Lib().GetFile(context.Background(), file.ID)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestRequestOperatesOnCurrentSourceAndNeverOverwrites(t *testing.T) {
|
|
// A selected path resolves to whatever is there now; an occupied target still fails alone.
|
|
f := setup(t)
|
|
f.write(t, "old.txt", "before")
|
|
stream := f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "old.txt")}}, func() { f.write(t, "old.txt", "replacement content") })
|
|
require.Equal(t, int64(1), stream.summary().SucceededCount)
|
|
require.NoFileExists(t, filepath.Join(f.root, "old.txt"))
|
|
|
|
// The same rule protects an occupied target: it fails alone and keeps its own content.
|
|
f.write(t, "replacement-again.txt", "before")
|
|
stream = f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE, Sources: []*entity.FileOperationRef{f.ref(t, "replacement-again.txt")}, Destination: f.ref(t, ""), Name: "new.txt"}, func() { f.write(t, "new.txt", "not owned") })
|
|
require.Equal(t, int64(1), stream.summary().FailedCount)
|
|
data, err := os.ReadFile(filepath.Join(f.root, "new.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "not owned", string(data))
|
|
require.FileExists(t, filepath.Join(f.root, "replacement-again.txt"))
|
|
require.Error(t, renameNoReplace(filepath.Join(f.root, "replacement-again.txt"), filepath.Join(f.root, "new.txt")))
|
|
}
|
|
|
|
func TestRequestPublicationFailurePreservesPhysicalMove(t *testing.T) {
|
|
// A metadata failure preserves moved bytes and reports publication pending without an automatic retry.
|
|
f := setup(t)
|
|
f.write(t, "a.txt", "successful")
|
|
f.write(t, "b.txt", "publication failure")
|
|
first, second := f.original(t, "a.txt"), f.original(t, "b.txt")
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "target"), 0755))
|
|
publications := 0
|
|
require.NoError(t, f.libDB.Callback().Update().Before("gorm:update").Register("reject-second-publication", func(tx *gorm.DB) {
|
|
if _, ok := tx.Statement.Dest.(*library.Location); ok {
|
|
publications++
|
|
if publications == 2 {
|
|
tx.AddError(errors.New("injected metadata failure"))
|
|
}
|
|
}
|
|
}))
|
|
stream := f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE, Sources: []*entity.FileOperationRef{f.ref(t, "a.txt"), f.ref(t, "b.txt")}, Destination: f.ref(t, "target")}, nil)
|
|
require.Equal(t, 2, publications)
|
|
require.Equal(t, int64(1), stream.summary().SucceededCount)
|
|
require.FileExists(t, filepath.Join(f.root, "target/a.txt"))
|
|
require.Equal(t, int64(1), stream.summary().PublicationPendingCount)
|
|
require.FileExists(t, filepath.Join(f.root, "target/b.txt"))
|
|
require.NoFileExists(t, filepath.Join(f.root, "b.txt"))
|
|
|
|
// The failed metadata transaction rolls back its reference move without undoing physical work.
|
|
for _, expected := range []struct {
|
|
id int64
|
|
path string
|
|
}{{first.ID, "target/a.txt"}, {second.ID, "b.txt"}} {
|
|
original, err := f.exe.Lib().GetFileLocation(context.Background(), expected.id)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected.path, original.Path)
|
|
}
|
|
}
|
|
|
|
func TestRequestCancellationStopsBeforeNextMutation(t *testing.T) {
|
|
f := setup(t)
|
|
f.write(t, "a.txt", "a")
|
|
f.write(t, "b.txt", "b")
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
stream := &operationStream{ctx: ctx, onSend: func(update *entity.FileOperationResult) error {
|
|
if update.Entry != nil {
|
|
cancel()
|
|
}
|
|
return nil
|
|
}}
|
|
err := (&service{exe: f.exe}).executeRequest(&operationRequest{Spec: &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "a.txt"), f.ref(t, "b.txt")}}}, stream)
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
require.NoFileExists(t, filepath.Join(f.root, "a.txt"))
|
|
require.FileExists(t, filepath.Join(f.root, "b.txt"))
|
|
require.Len(t, stream.updates, 2)
|
|
require.Equal(t, entity.FileOperationOutcome_FILE_OPERATION_OUTCOME_SUCCEEDED, stream.updates[1].Entry.Outcome)
|
|
}
|
|
|
|
func TestRequestSafetyAdmissionAndBoundedManifest(t *testing.T) {
|
|
f := setup(t)
|
|
f.write(t, "nested/one", "one")
|
|
server, stream := &service{exe: f.exe}, &operationStream{ctx: context.Background()}
|
|
err := server.executeRequest(&operationRequest{Spec: &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "")}}}, stream)
|
|
require.ErrorContains(t, err, "roots")
|
|
nested := &library.Location{Name: "Nested", ExecutorID: f.location.ExecutorID, RootPath: filepath.Join(f.root, "nested")}
|
|
require.NoError(t, f.exe.Lib().CreateLocation(context.Background(), nested))
|
|
err = server.executeRequest(&operationRequest{Spec: &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "nested")}}}, stream)
|
|
require.Error(t, err)
|
|
require.FileExists(t, filepath.Join(f.root, "nested/one"))
|
|
for i := 0; i < batchSize+3; i++ {
|
|
f.write(t, fmt.Sprintf("many/%03d", i), "")
|
|
}
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "many/empty"), 0755))
|
|
stream = f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_REMOVE, Sources: []*entity.FileOperationRef{f.ref(t, "many")}}, nil)
|
|
require.Equal(t, int64(1), stream.summary().SucceededCount)
|
|
require.Len(t, stream.updates, 3)
|
|
}
|
|
|
|
func TestRequestTemporaryManifestIsRemoved(t *testing.T) {
|
|
// Keep the database open throughout the operation, then release both handle and directory.
|
|
f := setup(t)
|
|
op, err := newOperation(context.Background(), f.exe)
|
|
require.NoError(t, err)
|
|
directory := op.Directory
|
|
db, err := op.DB.DB()
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Ping())
|
|
|
|
// Repeated sequential cleanup is harmless after the original owned resources disappear.
|
|
require.NoError(t, op.Close())
|
|
require.NoDirExists(t, directory)
|
|
require.Error(t, db.Ping())
|
|
require.NoError(t, op.Close())
|
|
}
|
|
|
|
func TestReplacedDestinationReceivesTheSelectedItem(t *testing.T) {
|
|
// The frozen relative target resolves under the current destination directory.
|
|
f := setup(t)
|
|
f.write(t, "source.txt", "content")
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "target"), 0755))
|
|
stream := &operationStream{ctx: context.Background(), onSend: func(update *entity.FileOperationResult) error {
|
|
if update.Entry != nil || update.Summary.GetCompleted() {
|
|
return nil
|
|
}
|
|
require.NoError(t, os.Rename(filepath.Join(f.root, "target"), filepath.Join(f.root, "previous-target")))
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "target"), 0755))
|
|
return nil
|
|
}}
|
|
err := (&service{exe: f.exe}).executeRequest(&operationRequest{Spec: &entity.FileOperationSpec{
|
|
Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE, Sources: []*entity.FileOperationRef{f.ref(t, "source.txt")}, Destination: f.ref(t, "target"),
|
|
}}, stream)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 1, stream.summary().SucceededCount)
|
|
require.FileExists(t, filepath.Join(f.root, "target/source.txt"))
|
|
require.NoFileExists(t, filepath.Join(f.root, "source.txt"))
|
|
require.NoFileExists(t, filepath.Join(f.root, "previous-target/source.txt"))
|
|
}
|
|
|
|
func TestMoveAncestorConflictNeverMergesChildrenIntoForeignDirectory(t *testing.T) {
|
|
f := setup(t)
|
|
f.write(t, "source/one.txt", "one")
|
|
f.write(t, "source/nested/two.txt", "two")
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "destination"), 0755))
|
|
stream := f.execute(t, &entity.FileOperationSpec{Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE,
|
|
Sources: []*entity.FileOperationRef{f.ref(t, "source")}, Destination: f.ref(t, "destination")}, func() {
|
|
f.write(t, "destination/source/sentinel.txt", "foreign content")
|
|
})
|
|
require.Equal(t, int64(1), stream.summary().FailedCount)
|
|
require.Zero(t, stream.summary().SucceededCount)
|
|
entries, err := os.ReadDir(filepath.Join(f.root, "destination/source"))
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 1)
|
|
require.Equal(t, "sentinel.txt", entries[0].Name())
|
|
data, err := os.ReadFile(filepath.Join(f.root, "destination/source/sentinel.txt"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "foreign content", string(data))
|
|
}
|
|
|
|
func TestDestinationReplacementBetweenItemsStaysOrdinaryConcurrency(t *testing.T) {
|
|
// Replacing the destination directory mid-request changes where later items land, not their outcome.
|
|
f := setup(t)
|
|
f.write(t, "a.txt", "a")
|
|
f.write(t, "b.txt", "b")
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "destination"), 0755))
|
|
stream := &operationStream{ctx: context.Background(), onSend: func(update *entity.FileOperationResult) error {
|
|
if update.Entry == nil || update.Entry.SourcePath != "a.txt" {
|
|
return nil
|
|
}
|
|
require.NoError(t, os.Rename(filepath.Join(f.root, "destination"), filepath.Join(f.root, "previous")))
|
|
require.NoError(t, os.Mkdir(filepath.Join(f.root, "destination"), 0755))
|
|
return nil
|
|
}}
|
|
err := (&service{exe: f.exe}).executeRequest(&operationRequest{Spec: &entity.FileOperationSpec{
|
|
Kind: entity.FileOperationKind_FILE_OPERATION_KIND_MOVE, Sources: []*entity.FileOperationRef{f.ref(t, "a.txt"), f.ref(t, "b.txt")}, Destination: f.ref(t, "destination"),
|
|
}}, stream)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(2), stream.summary().SucceededCount)
|
|
require.FileExists(t, filepath.Join(f.root, "previous/a.txt"))
|
|
require.FileExists(t, filepath.Join(f.root, "destination/b.txt"))
|
|
}
|
|
|
|
func TestActiveOperationManifestUsesProtectedWork(t *testing.T) {
|
|
// The operation owns a disposable manifest inside protected Work storage.
|
|
f := setup(t)
|
|
op, err := newOperation(context.Background(), f.exe)
|
|
require.NoError(t, err)
|
|
defer op.Close()
|
|
require.Equal(t, resource.TemporaryRoot(f.work), filepath.Dir(op.Directory))
|
|
|
|
// A runtime temporary directory cannot become an authorized original Location.
|
|
_, err = f.exe.LocationRoot(op.Directory)
|
|
require.Error(t, err)
|
|
}
|