Files
yatm/internal/executor/fileops/operation_test.go
T
Samuel Cui 4fef653b71 Prepare YATM v1.0.0-alpha.2
Unify file queries and Scan execution, retain indexed Identical results,
improve Job and Preview workflows, and verify release packaging.
2026-10-05 19:08:49 +08:00

394 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.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(work)
if os.IsNotExist(err) {
return
}
require.NoError(t, err)
require.Empty(t, entries, "ordinary file operations must not create Job bundles")
})
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 TestActiveOperationManifestIsHiddenByDefaultIgnore(t *testing.T) {
// The temporary database retains the operation namespace used by default Location Ignore.
f := setup(t)
op, err := newOperation(context.Background(), f.exe)
require.NoError(t, err)
defer op.Close()
directory, err := os.MkdirTemp(f.root, filepath.Base(op.Directory)+"-")
require.NoError(t, err)
defer os.RemoveAll(directory)
require.NoError(t, os.WriteFile(filepath.Join(directory, "content"), []byte("unfinished"), 0600))
// The default Location Ignore rules hide the operation namespace from ordinary reads.
relative := filepath.Base(directory) + "/content"
require.True(t, f.location.Excluded(relative, false))
// Ignore is visibility, not permission: an explicit observation stays available.
_, err = f.exe.ObserveLocationEntry(context.Background(), f.location.ID, relative)
require.NoError(t, err)
}