package ddns import ( "context" "testing" "time" ) func TestNewRunner_ZeroStatus(t *testing.T) { rn := NewRunner() s := rn.GetStatus() if s.Enabled { t.Error("new runner should have Enabled=false") } if s.CurrentIP != "" { t.Errorf("new runner should have empty CurrentIP, got %q", s.CurrentIP) } if !s.LastChecked.IsZero() { t.Error("new runner should have zero LastChecked") } } func TestTrigger_NonBlocking(t *testing.T) { rn := NewRunner() done := make(chan struct{}) go func() { rn.Trigger() rn.Trigger() close(done) }() select { case <-done: case <-time.After(time.Second): t.Error("Trigger blocked unexpectedly") } } func TestRun_ExitsOnContextCancel(t *testing.T) { rn := NewRunner() ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { rn.Run(ctx, Config{}) // not enabled — returns immediately close(done) }() cancel() select { case <-done: case <-time.After(time.Second): t.Error("Run did not exit after context cancel") } } func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) { rn := NewRunner() ctx := context.Background() done := make(chan struct{}) go func() { rn.Run(ctx, Config{Enabled: false}) close(done) }() select { case <-done: case <-time.After(time.Second): t.Error("Run should exit immediately when disabled") } } func TestRun_NoToken_ExitsImmediately(t *testing.T) { rn := NewRunner() ctx := context.Background() done := make(chan struct{}) go func() { rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}}) close(done) }() select { case <-done: case <-time.After(time.Second): t.Error("Run should exit immediately when no API token") } } func TestRun_NoRecords_ExitsImmediately(t *testing.T) { rn := NewRunner() ctx := context.Background() done := make(chan struct{}) go func() { rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil}) close(done) }() select { case <-done: case <-time.After(time.Second): t.Error("Run should exit immediately when no records configured") } } // TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop. // Uses Start (not Run directly) since Start owns the Enabled lifecycle. func TestStart_SetsEnabledStatus(t *testing.T) { rn := NewRunner() started := make(chan struct{}, 1) rn.ipFetcher = func() (string, error) { select { case started <- struct{}{}: default: } return "1.2.3.4", nil } ctx := context.Background() rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}) select { case <-started: case <-time.After(time.Second): t.Fatal("goroutine did not start in time") } if !rn.GetStatus().Enabled { t.Error("expected Enabled=true after Start with valid config") } rn.Stop() if rn.GetStatus().Enabled { t.Error("expected Enabled=false after Stop()") } } // TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config. func TestStart_InvalidConfig_SetsDisabled(t *testing.T) { for _, cfg := range []Config{ {Enabled: false}, {Enabled: true, APIToken: ""}, {Enabled: true, APIToken: "tok", Records: nil}, } { rn := NewRunner() rn.Start(context.Background(), cfg) if rn.GetStatus().Enabled { t.Errorf("expected Enabled=false for config %+v", cfg) } } } // TestStart_Restart_KeepsEnabled verifies that calling Start twice does not // clear Enabled — the race where the old goroutine's exit sets Enabled=false. func TestStart_Restart_KeepsEnabled(t *testing.T) { rn := NewRunner() reached := make(chan struct{}, 2) rn.ipFetcher = func() (string, error) { select { case reached <- struct{}{}: default: } return "1.2.3.4", nil } ctx := context.Background() cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} rn.Start(ctx, cfg) // Wait for first goroutine to actually start select { case <-reached: case <-time.After(time.Second): t.Fatal("first goroutine did not start") } // Restart — old goroutine is cancelled, new one starts rn.Start(ctx, cfg) // Enabled must still be true regardless of old goroutine's exit timing if !rn.GetStatus().Enabled { t.Error("expected Enabled=true after restart") } rn.Stop() } // TestStop_ClearsEnabled verifies Stop sets Enabled=false. func TestStop_ClearsEnabled(t *testing.T) { rn := NewRunner() rn.mu.Lock() rn.status.Enabled = true rn.mu.Unlock() rn.Stop() if rn.GetStatus().Enabled { t.Error("expected Enabled=false after Stop()") } } // TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine. func TestStart_CancelsAndRestarts(t *testing.T) { rn := NewRunner() block := make(chan struct{}) firstReady := make(chan struct{}, 1) rn.ipFetcher = func() (string, error) { select { case firstReady <- struct{}{}: default: } <-block return "1.2.3.4", nil } ctx := context.Background() cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} rn.Start(ctx, cfg) select { case <-firstReady: case <-time.After(time.Second): t.Fatal("first goroutine did not start") } // Unblock the first goroutine so the second Start can cancel it close(block) // Second Start with disabled config must not deadlock done := make(chan struct{}) go func() { rn.Start(ctx, Config{Enabled: false}) close(done) }() select { case <-done: case <-time.After(time.Second): t.Error("second Start blocked unexpectedly") } } // TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch. // Note: this calls updateCloudflareRecord which fails with a fake token, // but CurrentIP is still updated to reflect the fetched IP. func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) { rn := NewRunner() rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil } rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}}) if rn.GetStatus().CurrentIP != "5.6.7.8" { t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP) } } // TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are // always synced on the first check, even when the public IP hasn't changed. // This catches the bug where adding a new record (e.g. dev.payne.io) would be // skipped because currentIP already matched the fetched IP. func TestStart_ClearsCurrentIP(t *testing.T) { rn := NewRunner() rn.mu.Lock() rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine rn.mu.Unlock() rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}) rn.Stop() if rn.GetStatus().CurrentIP != "" { t.Error("Start should clear CurrentIP so the first check syncs all records") } } // TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches. func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) { rn := NewRunner() rn.mu.Lock() rn.status.CurrentIP = "9.9.9.9" rn.mu.Unlock() calls := 0 rn.ipFetcher = func() (string, error) { calls++ return "9.9.9.9", nil } prevUpdated := rn.GetStatus().LastUpdated rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}}) if rn.GetStatus().LastUpdated != prevUpdated { t.Error("LastUpdated should not change when IP is unchanged") } if calls != 1 { t.Errorf("expected 1 ipFetcher call, got %d", calls) } } func TestGetStatus_ReflectsManualUpdate(t *testing.T) { rn := NewRunner() rn.mu.Lock() rn.status.CurrentIP = "1.2.3.4" rn.status.LastChecked = time.Now() rn.status.Enabled = true rn.mu.Unlock() s := rn.GetStatus() if s.CurrentIP != "1.2.3.4" { t.Errorf("got CurrentIP %q, want 1.2.3.4", s.CurrentIP) } if !s.Enabled { t.Error("expected Enabled=true") } }