package backup import ( "context" "strings" "testing" ) // v0.147.0 slice 4c — live progress for a MANUAL offsite run. // // These tests exercise the WHOLE path a real run takes: a fake restic emits canned `--json` status // lines through the streaming seam, and the assertion is on what the poll surface // (OffboxProgressSnapshot) reports — not on the parser in isolation. A parser that works but is // never wired to the snapshot would leave the customer looking at the same silent spinner, which is // the bug being fixed. // // RED-PROOF (run manually, both confirmed to fail): // 1. break the parser — flip `s.MessageType != "status"` to `== "status"` in parseResticStatus: // TestManualRunReportsParsedProgress fails ("percent = 0, want 42"). // 2. drop the wiring — make resticBackupStep always delegate to resticStep: // the same test fails (no --json, no stream, no snapshot). // jsonStatus is one restic --json status line. func jsonStatus(pct float64, done, total int64) string { return `{"message_type":"status","percent_done":` + ftoa(pct) + `,"total_bytes":` + itoa(total) + `,"bytes_done":` + itoa(done) + `}` } func ftoa(f float64) string { // small helper — avoids strconv import noise in the canned lines switch f { case 0: return "0" case 0.42: return "0.42" case 1: return "1" } return "0.5" } func itoa(i int64) string { if i == 0 { return "0" } var b []byte neg := i < 0 if neg { i = -i } for i > 0 { b = append([]byte{byte('0' + i%10)}, b...) i /= 10 } if neg { return "-" + string(b) } return string(b) } // TestManualRunReportsParsedProgress is the headline: a manual run driven by a fake restic that emits // --json status lines must make OffboxProgressSnapshot report the parsed percentage, byte counts and // the app currently being pushed. func TestManualRunReportsParsedProgress(t *testing.T) { e := newSharesOffboxEnv(t, "immich") if err := e.sett.SetSMBEnabled(false); err != nil { // keep this test to the app leg only t.Fatal(err) } var seen []OffboxProgress var sawJSONFlag bool e.m.SetOffboxStreamRunner(func(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error) { for _, a := range args { if a == "--json" { sawJSONFlag = true } } // Emit a scan phase (total not yet known), then real progress, sampling the published // snapshot after each line exactly as the polling page would. onLine(jsonStatus(0, 0, 0)) seen = append(seen, e.m.OffboxProgressSnapshot()) onLine(`{"message_type":"verbose_status","action":"unchanged"}`) // must be ignored, not fatal onLine(jsonStatus(0.42, 4200, 10000)) seen = append(seen, e.m.OffboxProgressSnapshot()) return []byte(`{"message_type":"summary","snapshot_id":"abc"}`), nil }) // The non-streaming seam still serves every other restic call (cat config, forget, snapshots…). e.m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { if contains(args, "snapshots") { return []byte(`[]`), nil } return []byte(""), nil }) if err := e.m.RunOffboxBackupWithProgress(context.Background()); err != nil { t.Fatalf("manual run: %v", err) } if !sawJSONFlag { t.Fatal("the manual backup leg did not pass --json to restic — nothing could ever be parsed") } if len(seen) != 2 { t.Fatalf("expected 2 sampled snapshots, got %d", len(seen)) } // While restic is still scanning, total is unknown: report 0 rather than inventing a percentage. if seen[0].TotalBytes != 0 || seen[0].Percent != 0 { t.Errorf("scan phase: got %+v, want zeroed counters", seen[0]) } if seen[0].CurrentApp != "immich" { t.Errorf("scan phase: current_app = %q, want %q", seen[0].CurrentApp, "immich") } got := seen[1] if got.Percent != 42 { t.Errorf("percent = %v, want 42", got.Percent) } if got.BytesDone != 4200 || got.TotalBytes != 10000 { t.Errorf("bytes = %d/%d, want 4200/10000", got.BytesDone, got.TotalBytes) } if got.CurrentApp != "immich" { t.Errorf("current_app = %q, want %q", got.CurrentApp, "immich") } if got.TotalHuman == "" || got.DoneHuman == "" { t.Errorf("humanized byte strings not populated: %+v", got) } if !got.Active { t.Error("progress reported inactive during a manual run") } // After the run the sink must be torn down, or the page would keep rendering a stale bar. if after := e.m.OffboxProgressSnapshot(); after.Active || after.Percent != 0 { t.Errorf("progress still published after the run: %+v", after) } } // TestNightlyRunStaysSilent pins the scope decision: the scheduled run must neither install the sink // nor pass --json, so its output format (and everything that greps it) is untouched. func TestNightlyRunStaysSilent(t *testing.T) { e := newSharesOffboxEnv(t, "immich") if err := e.sett.SetSMBEnabled(false); err != nil { t.Fatal(err) } e.m.SetOffboxStreamRunner(func(ctx context.Context, env []string, onLine func(string), args ...string) ([]byte, error) { t.Fatal("the nightly run used the streaming runner — progress must be manual-only") return nil, nil }) var backupArgs []string e.m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { if contains(args, "backup") { backupArgs = append([]string{}, args...) } if contains(args, "snapshots") { return []byte(`[]`), nil } return []byte(""), nil }) if err := e.m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("nightly run: %v", err) } if backupArgs == nil { t.Fatal("no backup call was made") } if contains(backupArgs, "--json") { t.Errorf("the nightly run passed --json: %v", backupArgs) } if p := e.m.OffboxProgressSnapshot(); p.Active { t.Errorf("the nightly run published progress: %+v", p) } } // TestParseResticStatusIgnoresNonStatus covers the lines restic actually interleaves with status // output. An unknown message_type must be ignored, never mistaken for progress — restic adds new // types between versions. func TestParseResticStatusIgnoresNonStatus(t *testing.T) { for _, line := range []string{ `{"message_type":"summary","total_bytes_processed":123}`, `{"message_type":"verbose_status","action":"new"}`, `{"message_type":"error","error":{"message":"boom"}}`, `not json at all`, ``, `{}`, } { if _, _, _, ok := parseResticStatus(line); ok { t.Errorf("parsed a non-status line as progress: %q", line) } } pct, done, total, ok := parseResticStatus(jsonStatus(0.42, 4200, 10000)) if !ok { t.Fatal("a real status line was not parsed") } if pct != 42 || done != 4200 || total != 10000 { t.Errorf("got %v %d %d, want 42 4200 10000", pct, done, total) } } // TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1 // near completion. A bar wider than its track is a visible bug. func TestParseResticStatusClampsPercent(t *testing.T) { pct, _, _, ok := parseResticStatus(`{"message_type":"status","percent_done":1.04}`) if !ok || pct != 100 { t.Errorf("percent = %v (ok=%v), want clamped to 100", pct, ok) } pct, _, _, ok = parseResticStatus(`{"message_type":"status","percent_done":-0.2}`) if !ok || pct != 0 { t.Errorf("percent = %v (ok=%v), want clamped to 0", pct, ok) } } // TestLineTailKeepsOnlyTheTail — the --json stream of a large backup is megabytes of status spam, and // every caller uses this output for error diagnosis and lock-pattern matching. Buffering all of it // would be a memory leak proportional to backup size. func TestLineTailKeepsOnlyTheTail(t *testing.T) { var tl lineTail for i := 0; i < 500; i++ { tl.add("line-" + itoa(int64(i))) } out := string(tl.bytes()) if strings.Contains(out, "line-0\n") { t.Error("the oldest line survived — the tail is unbounded") } if !strings.Contains(out, "line-499") { t.Error("the newest line was dropped") } if n := strings.Count(out, "\n"); n > 64 { t.Errorf("tail kept %d lines, want a small bounded number", n) } }