package stacks import ( "context" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // newAppJob builds a scope="app" job (no non-app merge unit — app scope has no merge walk). func newAppJob(srcNS, dstNS, source, target string, apps ...string) *MigrationJob { j := &MigrationJob{ Scope: "app", Phase: PhaseStop, Source: source, Target: target, SourceNS: srcNS, TargetNS: dstNS, Apps: apps, Units: map[string]*MigUnit{}, } for _, a := range apps { j.Units[a] = &MigUnit{App: a, State: UnitPending} } return j } // TestMigrate_PaperlessShape_ScopeApp is Scenario D / RP-4: a scope="app" migration of paperless-ngx // (stack name) must copy, verify, and clean the REAL appdata dir (paperless), never appdata/paperless-ngx. // Every leg is asserted by captured src/dst pairs and on-disk effects — never mere absence of error. // Companion RP-4: dropping the resolveNames loop (keying by stack name) makes the copy seam receive // appdata/paperless-ngx and every assertion below fails. func TestMigrate_PaperlessShape_ScopeApp(t *testing.T) { m := newMigManager(t, "/mnt/target") srcNS := t.TempDir() dstNS := t.TempDir() // Real source data under the REAL dir name (paperless), plus the recovery unit. writeFile(t, filepath.Join(appbackup.AppDataDir(srcNS, "paperless"), "media", "doc.pdf"), "PDF") writeFile(t, filepath.Join(appbackup.RecoveryUnitPath(srcNS, "paperless-ngx"), "manifest.json"), "{}") type pair struct{ src, dst string } var copied, verified []pair m.testSeams = &migSeams{ resolveNames: func(app string) []string { if app == "paperless-ngx" { return []string{"paperless"} } return []string{app} }, stop: func(string) error { return nil }, copy: func(_ context.Context, s, d string, _ func(int64)) error { copied = append(copied, pair{s, d}) return nil }, verify: func(_ context.Context, s, d string) error { verified = append(verified, pair{s, d}) return nil }, flipRedeploy: func(string, string) error { return nil }, } // --- direct-call assertions (accounting legs) before the destructive run --- wantSrc := appbackup.AppDataDir(srcNS, "paperless") // migSourceSize must probe appdata/paperless (non-zero: the PDF exists there). if got := m.migSourceSize(newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx")); got == 0 { t.Error("migSourceSize = 0 — it probed appdata/paperless-ngx (empty) instead of appdata/paperless") } // appDataSkipSet must contain the resolved source dir. skip := m.appDataSkipSet(newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx")) if !skip[filepath.Clean(wantSrc)] { t.Errorf("skip-set = %v, want it to contain %q", skip, filepath.Clean(wantSrc)) } // Collision check must probe appdata/paperless at the target. Target must be the schedulable path // so migValidate reaches the collision check (TargetNS stays the temp dir where the dir exists). writeFile(t, filepath.Join(appbackup.AppDataDir(dstNS, "paperless"), "x"), "x") cj := newAppJob(srcNS, dstNS, "/s", "/mnt/target", "paperless-ngx") if err := m.migValidate(cj); err == nil || !contains(err.Error(), "paperless-ngx") { t.Errorf("collision must fire on the resolved target dir, got %v", err) } // --- full pipeline run (copy → verify → cleanup) --- j := newAppJob(srcNS, dstNS, "/s", "/t", "paperless-ngx") m.runJobSync(j) if j.Phase != PhaseDone { t.Fatalf("phase = %s (err=%s), want done", j.Phase, j.Error) } wantDst := appbackup.AppDataDir(dstNS, "paperless") assertHasPair := func(name string, got []pair) { t.Helper() for _, p := range got { if p.src == wantSrc && p.dst == wantDst { return } } t.Errorf("%s seam never saw appdata/paperless: src=%q dst=%q, got %v", name, wantSrc, wantDst, got) } assertHasPair("copy", copied) assertHasPair("verify", verified) // Cleanup removed the REAL source dir. if pathExists(wantSrc) { t.Errorf("cleanup left the source appdata/paperless behind: %s", wantSrc) } // And it never probed / created appdata/paperless-ngx. if pathExists(appbackup.AppDataDir(srcNS, "paperless-ngx")) { t.Errorf("stale appdata/paperless-ngx dir should never exist") } }