Files
yatm/internal/executor/fileops/operation_test.go
T
Samuel CuiandClaude Opus 5.5 63eef7bea9 Keep disposable files under owned Work directories
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>
2026-10-30 00:01:27 +08:00

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)
}