Files
yatm/internal/executor/scan/read_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

177 lines
6.9 KiB
Go

package scan
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"syscall"
"testing"
"github.com/samuelncui/acp"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/internal/executor"
"github.com/samuelncui/yatm/internal/library"
mediapkg "github.com/samuelncui/yatm/internal/media"
"github.com/stretchr/testify/require"
)
func TestVerifyReadPreparationErrorCompletesAsUnreadable(t *testing.T) {
// Permission errors during ACP preparation do not produce a sink result but remain per-file findings.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "restricted", []byte("content"))
saveVerifyFile(t, exe, volume, media, "z-readable", []byte("saved"))
filename := filepath.Join(volume.Root, position.Path)
require.NoError(t, os.Chmod(filename, 0))
t.Cleanup(func() { _ = os.Chmod(filename, 0644) })
opened, err := os.Open(filename)
if err == nil {
require.NoError(t, opened.Close())
t.Skip("current user can read files without permission bits")
}
id, run := createVerifyJob(t, exe, media.ID, volume)
run()
waitVerifyIdle(t, exe, id, entity.JobStatus_JOB_STATUS_COMPLETED)
// Finalized results retain the independent expected baseline and publish only an unreadable observation.
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNREADABLE, stored.Health)
require.Equal(t, position.Hash, stored.Hash)
progress, err := (&service{exe: exe}).GetProgress(context.Background(), &entity.GetScanJobProgressRequest{Id: id})
require.NoError(t, err)
require.EqualValues(t, 1, progress.UnreadableCount)
require.EqualValues(t, 1, progress.MatchedCount)
require.Zero(t, progress.Progress.TotalFileCount-progress.MatchedCount-progress.UnreadableCount)
}
type sequentialCheckSession struct{ checkSession }
func (*sequentialCheckSession) Capabilities() mediapkg.Capabilities {
return mediapkg.Capabilities{Read: mediapkg.AccessSequential}
}
func TestVerifySequentialManifestCursorUsesStorageOrder(t *testing.T) {
ctx := context.Background()
exe, volume, media := setupVerifyExecutor(t)
var expected []*library.Position
for index := 0; index < batchSize+5; index++ {
position := saveVerifyFile(t, exe, volume, media, fmt.Sprintf("file-%04d", index), nil)
position.StorageOrder = make([]byte, 8)
binary.BigEndian.PutUint64(position.StorageOrder, uint64((batchSize+6-index)/2))
require.NoError(t, exe.Lib().SavePosition(ctx, position))
expected = append(expected, position)
}
sort.Slice(expected, func(i, j int) bool {
first, second := binary.BigEndian.Uint64(expected[i].StorageOrder), binary.BigEndian.Uint64(expected[j].StorageOrder)
if first != second {
return first < second
}
return expected[i].Path < expected[j].Path
})
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(ctx, id)
require.NoError(t, err)
r := value.(*runner)
var config Config
require.NoError(t, r.db.First(&config, 1).Error)
session := &sequentialCheckSession{checkSession{root: volume.Root, media: media}}
require.NoError(t, r.enumerateMedia(ctx, &config, &Scope{}, session))
stream, err := openTestContentStream(t, ctx, r, &config, &Scope{}, session)
require.NoError(t, err)
defer func() { _ = stream.writer.Close() }()
var sources []string
for {
batch, err := stream.nextPage(ctx)
if errors.Is(err, io.EOF) {
break
}
require.NoError(t, err)
for _, item := range batch {
sources = append(sources, item.Source())
}
}
ordered := make([]string, 0, len(expected))
for _, position := range expected {
ordered = append(ordered, filepath.Join(volume.Root, position.Path))
}
require.Equal(t, ordered, sources)
}
func TestVerifyCancellationFinalizesWithoutInventingFindings(t *testing.T) {
// A canceled attempt must release its physical Session while leaving unseen inventory untouched.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "file", []byte("content"))
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(context.Background(), id)
require.NoError(t, err)
r := value.(*runner)
var config Config
err = r.db.First(&config, 1).Error
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
finalized := 0
err = r.runPipeline(ctx, &config, &Scope{}, nil, &checkSession{root: volume.Root, media: media, finalize: func(ctx context.Context) error {
finalized++
return ctx.Err()
}})
require.ErrorIs(t, err, context.Canceled)
require.Equal(t, 1, finalized)
// Cancellation is a Job lifecycle event, not a missing or unreadable observation for each remaining file.
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNKNOWN, stored.Health)
var entry Entry
require.NoError(t, r.db.First(&entry, position.ID).Error)
require.Equal(t, entity.ScanFinding_SCAN_FINDING_NOT_CHECKED, entry.Finding)
require.False(t, entry.Published)
}
type failedDeviceSession struct{ checkSession }
func (*failedDeviceSession) SourcePath(name string) (string, error) {
return "", &os.PathError{Op: "stat", Path: name, Err: syscall.EIO}
}
func TestVerifyDeviceFailureKeepsRemainingEntriesUnchecked(t *testing.T) {
// A device-level failure is not an independent unreadable observation of every remaining path.
exe, volume, media := setupVerifyExecutor(t)
position := saveVerifyFile(t, exe, volume, media, "file", []byte("content"))
id, _ := createVerifyJob(t, exe, media.ID, volume)
value, err := exe.GetJobRunner(context.Background(), id)
require.NoError(t, err)
r := value.(*runner)
var config Config
err = r.db.First(&config, 1).Error
require.NoError(t, err)
finalized := 0
err = r.runPipeline(testAttemptContext(context.Background()), &config, &Scope{}, nil, &failedDeviceSession{checkSession{media: media,
finalize: func(context.Context) error { finalized++; return nil }}})
require.ErrorIs(t, err, syscall.EIO)
require.Equal(t, 1, finalized)
// Finalization releases resources, but neither Job findings nor Library health are fabricated.
var entry Entry
require.NoError(t, r.db.First(&entry, position.ID).Error)
require.Equal(t, entity.ScanFinding_SCAN_FINDING_NOT_CHECKED, entry.Finding)
stored, err := exe.Lib().GetPosition(context.Background(), position.ID)
require.NoError(t, err)
require.Equal(t, entity.PositionHealth_POSITION_HEALTH_UNKNOWN, stored.Health)
}
func TestVerifyByteProgressDoesNotRequireCompletedFile(t *testing.T) {
progress := executor.NewProgress()
progress.SetGlobalCopied(100, 1)
stream := &contentStream{runner: &runner{progress: progress}, processed: 0}
handler := stream.eventHandler()
handler(&acp.EventUpdateProgress{Bytes: 50})
require.EqualValues(t, 150, progress.ToEntity().CopiedBytes)
require.EqualValues(t, 1, progress.ToEntity().CopiedFileCount)
}