package sync import ( "context" "log" "os" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) func newTestSyncer(t *testing.T, repoURL string) *Syncer { t.Helper() dir := t.TempDir() cfg := &config.Config{} cfg.Git.RepoURL = repoURL cfg.Git.Branch = "main" cfg.Git.SyncInterval = "15m" cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Paths.StacksDir = filepath.Join(dir, "stacks") if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil { t.Fatal(err) } logger := log.New(os.Stderr, "", 0) return New(cfg, logger, func() error { return nil }, nil) } // T-C1 (campaign F3): a git subprocess must die with its context — the pre-fix // exec.Command shape ignored the context entirely, so a hung remote parked the sync // goroutine in cmd.Run() forever, holding `syncing=true` until a controller restart. // Effect asserted: the returned error NAMES the context cause (not a generic git error), // and the call returns promptly instead of running the command to completion. func TestRunGitInDir_CancelledContextKillsSubprocess(t *testing.T) { s := newTestSyncer(t, "https://example.invalid/repo.git") ctx, cancel := context.WithCancel(context.Background()) cancel() // already cancelled — the subprocess must not be allowed to run to completion start := time.Now() err := s.runGitInDir(ctx, "", "--version") elapsed := time.Since(start) if err == nil { t.Fatalf("expected an error from a cancelled context, got nil (git --version ran to completion — the pre-fix behavior)") } if !strings.Contains(err.Error(), context.Canceled.Error()) { t.Fatalf("error must name the context cause; got: %v", err) } if !strings.Contains(err.Error(), "deadline") && !strings.Contains(err.Error(), "killed") { t.Fatalf("error must say the subprocess was killed at the deadline; got: %v", err) } if elapsed > 5*time.Second { t.Fatalf("cancelled-context git call took %v — subprocess not killed promptly", elapsed) } } // T-C2 (campaign F3): a failed sync must release the single-flight `syncing` flag and a // later TriggerSync (past the debounce) must EXECUTE — the campaign symptom was every // subsequent sync refused with "Szinkronizálás már folyamatban". Uses a nonexistent local // repo_url so git fails fast with no network. func TestTriggerSync_FailureReleasesSyncingAndAllowsRetry(t *testing.T) { s := newTestSyncer(t, filepath.Join(t.TempDir(), "no-such-repo")) res := s.TriggerSync() if res.OK { t.Fatalf("sync against a nonexistent repo must fail; got OK with message %q", res.Message) } if !strings.Contains(res.Message, "Git hiba") { t.Fatalf("failure must carry the git error to the caller (no silent swallow); got %q", res.Message) } st := s.Status() if st.Syncing { t.Fatalf("`syncing` still true after a failed sync — the single-flight flag leaked (the F3 lockup)") } if st.LastStatus != "error" || st.LastError == "" { t.Fatalf("failed sync must be recorded: LastStatus=%q LastError=%q", st.LastStatus, st.LastError) } // Debounce window: an immediate retry is refused (existing behavior, unchanged). res2 := s.TriggerSync() if res2.OK || !strings.Contains(res2.Message, "Túl gyakori") { t.Fatalf("immediate retry should hit the 30s debounce; got OK=%v message=%q", res2.OK, res2.Message) } // Past the debounce (rewind lastSync directly — same-package seam), the retry must // EXECUTE: it reaches git again and fails with the git error, not a refusal message. s.mu.Lock() s.lastSync = time.Now().Add(-time.Minute) s.mu.Unlock() res3 := s.TriggerSync() if res3.OK { t.Fatalf("retry against the same nonexistent repo should still fail: %q", res3.Message) } if !strings.Contains(res3.Message, "Git hiba") { t.Fatalf("retry past the debounce must EXECUTE (git error expected); got refusal/other: %q", res3.Message) } if s.Status().Syncing { t.Fatal("`syncing` leaked after the retry") } }