v0.6.0: strengthen backup integrity
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 00:34:32 +02:00
parent 0a905758cc
commit 05188e5451
13 changed files with 840 additions and 97 deletions
+270 -1
View File
@@ -3815,7 +3815,7 @@ func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) {
t.Fatal(err)
}
m.Close()
if err := os.WriteFile(filepath.Join(dir, "file.jpg"), []byte("x"), 0644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "file.jpg"), []byte("0123456789"), 0644); err != nil {
t.Fatal(err)
}
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: dir, album: "Album"}, fmt.Errorf("boom"))
@@ -4043,3 +4043,272 @@ func TestNewFeatureRemainingBranches(t *testing.T) {
t.Fatalf("expected retry partial, got %d", rc)
}
}
func TestFailuresAndStatusCommands(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.NewEntry("x1", "file.jpg", "Album/file.jpg", 4, "local"))
m.Close()
if err := os.MkdirAll(filepath.Join(dir, "Album"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Album", "file.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom"))
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}, fmt.Errorf("boom2"))
out, stderr, rc := runWith([]string{"status", "--out", dir, "--json"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "failures") || stderr != "" {
t.Fatalf("status json rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"status", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "failures\t1") || stderr != "" {
t.Fatalf("status rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"failures", "list", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "bad") || !strings.Contains(out, "2") || stderr != "" {
t.Fatalf("failures list rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"failures"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires") {
t.Fatalf("failures missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "bogus", "--out", dir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "unknown") {
t.Fatalf("failures bogus rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" {
t.Fatalf("failures clear rc=%d stderr=%q", rc, stderr)
}
if len(loadFailures(dir)) != 0 {
t.Fatal("expected failures cleared")
}
}
func TestAtomicExportHelpers(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
}
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}
result, err := exportOneAtomic(b, pa, 1024, 85, false, 0)
if err != nil || result.Filename != "photo.jpg" {
t.Fatalf("atomic export result=%+v err=%v", result, err)
}
if _, err := os.Stat(filepath.Join(dir, "Album", "photo.jpg")); err != nil {
t.Fatalf("expected final file: %v", err)
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "empty.jpg"}, nil
}
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 1); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected zero-byte error, got %v", err)
}
}
func TestMoreIntegrityBranches(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "zero", Filename: "zero.jpg", Path: "zero.jpg", Size: 1, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "mismatch", Filename: "mismatch.jpg", Path: "mismatch.jpg", Size: 10, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
t.Fatalf("verify rc=%d out=%q", rc, out)
}
_, stderr, rc := runWith([]string{"status"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"status", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("status bad manifest rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"status", "--out", "/proc/cannot-create"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("status load error rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"failures", "list"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("failures missing out rc=%d stderr=%q", rc, stderr)
}
}
func TestRetryFailedClearOnSuccess(t *testing.T) {
dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom"))
out, _, rc := runWith([]string{"retry-failed", "--out", dir, "--clear-on-success"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "exported") {
t.Fatalf("retry clear rc=%d out=%q", rc, out)
}
if len(loadFailures(dir)) != 0 {
t.Fatal("expected successful retry to clear failure")
}
}
func TestAtomicSlotExportHelper(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "slot.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "slot.jpg", Size: 4, Cloud: "local"}, nil
}
pa := pendingAsset{asset: photos.Asset{ID: "slot", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Album"), album: "Album"}
result, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0)
if err != nil || result.Filename != "slot.jpg" {
t.Fatalf("slot atomic result=%+v err=%v", result, err)
}
}
func TestInjectedErrorBranchesForCoverage(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{}
pa := pendingAsset{asset: photos.Asset{ID: "x", Filename: "x.jpg"}, root: dir, path: filepath.Join(dir, "Album")}
oldMkdirTemp := mkdirTempFunc
mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("mkdirtemp") }
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "mkdirtemp") {
t.Fatalf("expected mkdirtemp error, got %v", err)
}
mkdirTempFunc = oldMkdirTemp
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil || !strings.Contains(err.Error(), "rename") {
t.Fatalf("expected rename error, got %v", err)
}
renameFunc = oldRename
oldOpen := openFileFunc
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
if err := saveFailures(dir, map[string]failureEntry{"x": {ID: "x"}}); err == nil || !strings.Contains(err.Error(), "open") {
t.Fatalf("expected open error, got %v", err)
}
openFileFunc = oldOpen
oldRemove := removeFunc
removeFunc = func(string) error { return fmt.Errorf("remove") }
_, stderr, rc := runWith([]string{"failures", "clear", "--out", dir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "remove") {
t.Fatalf("expected remove error, rc=%d stderr=%q", rc, stderr)
}
removeFunc = oldRemove
mf := &mockManifest{}
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1})
if mf.last.Path != "file.jpg" {
t.Fatalf("expected fallback rel path, got %+v", mf.last)
}
badPathRoot := t.TempDir()
badPath := filepath.Join(badPathRoot, "notdir")
if err := os.WriteFile(badPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "x2", Filename: "x2.jpg"}, root: badPathRoot, path: badPath}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
if _, err := exportOneAtomic(b, pa, 1024, 85, false, 0); err == nil {
t.Fatal("expected final mkdir error")
}
slotRootFile := filepath.Join(t.TempDir(), "rootfile")
if err := os.WriteFile(slotRootFile, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotfallback", Filename: "slot.jpg"}, root: slotRootFile, path: t.TempDir()}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, true, 0, 0); err != nil {
t.Fatalf("unexpected original fallback error: %v", err)
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err != nil {
t.Fatalf("unexpected preview fallback error: %v", err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotzero", Filename: "slot.jpg"}, root: dir, path: filepath.Join(dir, "Slot")}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "empty.jpg"), nil, 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "empty.jpg"}, nil
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected slot zero-byte error, got %v", err)
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
renameFunc = func(string, string) error { return fmt.Errorf("slot rename") }
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot rename") {
t.Fatalf("expected slot rename error, got %v", err)
}
renameFunc = oldRename
mkdirTempFunc = func(string, string) (string, error) { return "", fmt.Errorf("slot mkdirtemp") }
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil || !strings.Contains(err.Error(), "slot mkdirtemp") {
t.Fatalf("expected slot mkdirtemp error, got %v", err)
}
mkdirTempFunc = oldMkdirTemp
badSlotRoot := t.TempDir()
badSlotPath := filepath.Join(badSlotRoot, "notdir")
if err := os.WriteFile(badSlotPath, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pa = pendingAsset{asset: photos.Asset{ID: "slotbadpath", Filename: "slot.jpg"}, root: badSlotRoot, path: badSlotPath}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "file.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "file.jpg", Size: 4}, nil
}
if _, err := exportOneWithSlotAtomic(b, pa, 1024, 85, false, 0, 0); err == nil {
t.Fatal("expected slot final mkdir error")
}
}
type mockManifest struct{ last manifest.Entry }
func (m *mockManifest) Has(string) bool { return false }
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
}
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
func (m *mockManifest) Save() error { return nil }
func (m *mockManifest) Close() {}
func (m *mockManifest) OpenAppend() error { return nil }