package report import ( "errors" "io" "log" "testing" ) // recorder collects the side effects a Reconcile would cause, so each case asserts exactly what // happened (pull/record/restart) without a real hub, filesystem, or process exit. type recorder struct { applied int recorded []int refreshes int refreshErr error restarts int recordErr error } func (r *recorder) refresher() *ConfigRefresher { return &ConfigRefresher{ Applied: func() int { return r.applied }, Record: func(v int) error { if r.recordErr != nil { return r.recordErr } r.recorded = append(r.recorded, v) r.applied = v return nil }, Refresh: func() error { r.refreshes++ return r.refreshErr }, Restart: func() { r.restarts++ }, Logger: log.New(io.Discard, "", 0), } } // A version change re-pulls, records the new version, then restarts (record BEFORE restart). func TestReconcile_VersionChange_RefreshRecordRestart(t *testing.T) { r := &recorder{applied: 1} r.refresher().Reconcile(2) if r.refreshes != 1 { t.Errorf("refreshes = %d, want 1", r.refreshes) } if len(r.recorded) != 1 || r.recorded[0] != 2 { t.Errorf("recorded = %v, want [2]", r.recorded) } if r.restarts != 1 { t.Errorf("restarts = %d, want 1", r.restarts) } } // RED-PROOF for the no-restart-storm guard: when the ACK version == the applied version, Reconcile // must do nothing — no refresh, no restart. (Drop the `ackVersion == applied` guard in Reconcile and // this test fails: it would refresh + restart on every report.) func TestReconcile_SameVersion_NoOp(t *testing.T) { r := &recorder{applied: 5} r.refresher().Reconcile(5) if r.refreshes != 0 { t.Errorf("refreshes = %d, want 0 (unchanged version must not re-pull)", r.refreshes) } if r.restarts != 0 { t.Errorf("restarts = %d, want 0 (unchanged version must NOT restart — restart storm)", r.restarts) } if len(r.recorded) != 0 { t.Errorf("recorded = %v, want [] (nothing to record)", r.recorded) } } // First-ever ACK (nothing recorded yet): record the baseline WITHOUT restarting or re-pulling — the // box already came up on the first-boot pull. func TestReconcile_FirstRun_RecordsBaselineNoRestart(t *testing.T) { r := &recorder{applied: 0} r.refresher().Reconcile(3) if r.refreshes != 0 { t.Errorf("refreshes = %d, want 0 (baseline must not re-pull)", r.refreshes) } if r.restarts != 0 { t.Errorf("restarts = %d, want 0 (baseline must not restart)", r.restarts) } if len(r.recorded) != 1 || r.recorded[0] != 3 { t.Errorf("recorded = %v, want [3] (baseline recorded)", r.recorded) } } // A failed pull keeps the current config: do NOT record, do NOT restart (retried next cycle). func TestReconcile_FailedPull_NoRecordNoRestart(t *testing.T) { r := &recorder{applied: 1, refreshErr: errors.New("hub unreachable")} r.refresher().Reconcile(2) if r.refreshes != 1 { t.Errorf("refreshes = %d, want 1 (attempted)", r.refreshes) } if r.restarts != 0 { t.Errorf("restarts = %d, want 0 (failed pull must not restart)", r.restarts) } if len(r.recorded) != 0 { t.Errorf("recorded = %v, want [] (failed pull must not record — version stays so it retries)", r.recorded) } } // ackVersion == 0 (old hub / report-only customer) is a no-op. func TestReconcile_ZeroVersion_NoOp(t *testing.T) { r := &recorder{applied: 4} r.refresher().Reconcile(0) if r.refreshes != 0 || r.restarts != 0 || len(r.recorded) != 0 { t.Errorf("zero version should be a no-op; got refreshes=%d restarts=%d recorded=%v", r.refreshes, r.restarts, r.recorded) } } // If recording the applied version fails after a successful pull, skip the restart (avoid a loop: // a restart without a recorded version would re-pull + restart forever). func TestReconcile_RecordFails_SkipsRestart(t *testing.T) { r := &recorder{applied: 1, recordErr: errors.New("disk full")} r.refresher().Reconcile(2) if r.refreshes != 1 { t.Errorf("refreshes = %d, want 1", r.refreshes) } if r.restarts != 0 { t.Errorf("restarts = %d, want 0 (must not restart if the version couldn't be recorded)", r.restarts) } }