package reconcile import ( "context" "errors" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) func seedInFlight(t *testing.T, j *Journal, e JournalEntry) { t.Helper() e.State = OpTaskRunning if e.At.IsZero() { e.At = time.Now().UTC() } if err := j.Append(e); err != nil { t.Fatalf("seed: %v", err) } } func TestRecover_TaskCompletedOKMarksSucceeded(t *testing.T) { api := &fakeAPI{statusFunc: func(string) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, nil }} e, j, _ := newEngine(t, api, EmptyProvider{}) seedInFlight(t, j, JournalEntry{OpID: "op1", VMID: 100, Kind: "set_config", UPID: "UPID:x:", IdempKey: "k1"}) res := e.Recover(context.Background()) if res.Examined != 1 || res.Resumed != 1 { t.Fatalf("want 1 resumed, got %+v", res) } if len(j.InFlight()) != 0 { t.Errorf("resolved op should not be in-flight: %+v", j.InFlight()) } // A resumed one-shot op marks its idempotency key applied (it really completed) — // this is the case idempotency-alone could not cover (Note 1). if !j.AlreadyApplied("k1") { t.Error("a recovered-succeeded op must mark its idempotency key applied") } } func TestRecover_TaskEndedNonOKMarksFailed(t *testing.T) { api := &fakeAPI{statusFunc: func(string) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{Status: "stopped", ExitStatus: "got 403"}, nil }} e, j, _ := newEngine(t, api, EmptyProvider{}) seedInFlight(t, j, JournalEntry{OpID: "op2", VMID: 100, Kind: "guest_destroy", UPID: "UPID:x:", IdempKey: "k2"}) res := e.Recover(context.Background()) if res.Failed != 1 { t.Fatalf("want 1 failed, got %+v", res) } if j.AlreadyApplied("k2") { t.Error("a failed op must NOT mark its key applied (it may be retried)") } } func TestRecover_TaskStillRunningLeftInFlight(t *testing.T) { api := &fakeAPI{statusFunc: func(string) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{Status: "running"}, nil }} e, j, _ := newEngine(t, api, EmptyProvider{}) seedInFlight(t, j, JournalEntry{OpID: "op3", VMID: 100, Kind: "set_config", UPID: "UPID:x:"}) res := e.Recover(context.Background()) if res.StillRunning != 1 || len(j.InFlight()) != 1 { t.Fatalf("still-running task must be left in-flight, got res=%+v inflight=%d", res, len(j.InFlight())) } } func TestRecover_NoTaskIDRolledBack(t *testing.T) { // OpStarted with no UPID: the POST was never confirmed → abandon (fail-safe). e, j, _ := newEngine(t, &fakeAPI{}, EmptyProvider{}) if err := j.Append(JournalEntry{OpID: "op4", VMID: 100, Kind: "start", State: OpStarted, At: time.Now().UTC()}); err != nil { t.Fatal(err) } res := e.Recover(context.Background()) if res.RolledBack != 1 || len(j.InFlight()) != 0 { t.Fatalf("no-task op must be rolled back, got res=%+v inflight=%d", res, len(j.InFlight())) } } func TestRecover_UnreadableStatusLeftInFlight(t *testing.T) { api := &fakeAPI{statusFunc: func(string) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{}, errors.New("api unreachable") }} e, j, _ := newEngine(t, api, EmptyProvider{}) seedInFlight(t, j, JournalEntry{OpID: "op5", VMID: 100, Kind: "set_config", UPID: "UPID:x:"}) res := e.Recover(context.Background()) if res.Unresolved != 1 || len(j.InFlight()) != 1 { t.Fatalf("unreadable status must leave op in-flight, got res=%+v inflight=%d", res, len(j.InFlight())) } } func TestRecover_EmptyJournalNoop(t *testing.T) { e, _, _ := newEngine(t, &fakeAPI{}, EmptyProvider{}) if res := e.Recover(context.Background()); res.Examined != 0 { t.Errorf("empty journal recover should be a no-op, got %+v", res) } }