package scheduler import ( "context" "errors" "sync" "testing" "time" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/metrics" "github.com/vasyakrg/dns-autoresolver/internal/notify" "github.com/vasyakrg/dns-autoresolver/internal/store" ) // mockStore is an in-memory SchedStore double. type mockStore struct { mu sync.Mutex schedules []store.Schedule domains map[uuid.UUID][]store.Domain status map[uuid.UUID]string touchedProjects []uuid.UUID // driftCount is what CountDriftDomains returns — a canned system-wide // count, independent of what this RunOnce's due projects touched. driftCount int } func newMockStore() *mockStore { return &mockStore{ domains: make(map[uuid.UUID][]store.Domain), status: make(map[uuid.UUID]string), } } func (m *mockStore) ListDueSchedules(ctx context.Context, now time.Time) ([]store.Schedule, error) { return m.schedules, nil } func (m *mockStore) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error { m.mu.Lock() defer m.mu.Unlock() m.touchedProjects = append(m.touchedProjects, projectID) return nil } func (m *mockStore) ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) { return m.domains[projectID], nil } func (m *mockStore) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) { m.mu.Lock() defer m.mu.Unlock() if st, ok := m.status[domainID]; ok { return st, nil } return StatusUnknown, nil } func (m *mockStore) SetDomainStatus(ctx context.Context, domainID uuid.UUID, status string) error { m.mu.Lock() defer m.mu.Unlock() m.status[domainID] = status return nil } func (m *mockStore) CountDriftDomains(ctx context.Context) (int, error) { m.mu.Lock() defer m.mu.Unlock() return m.driftCount, nil } // mockChecker returns a preset Changeset or error per domainID, and records // which domain IDs it was called with. type mockChecker struct { mu sync.Mutex results map[uuid.UUID]diff.Changeset errs map[uuid.UUID]error calls []uuid.UUID } func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) { c.mu.Lock() c.calls = append(c.calls, domainID) c.mu.Unlock() if err, ok := c.errs[domainID]; ok { return diff.Changeset{}, err } return c.results[domainID], nil } // mockNotifier records every Event it is asked to Send, and returns a // canned set of per-channel results (results defaults to nil, i.e. no // channels) so tests can assert on scheduler.checkDomain's per-channel // metric recording. type mockNotifier struct { mu sync.Mutex events []notify.Event results []notify.ChannelResult } func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error) { n.mu.Lock() defer n.mu.Unlock() n.events = append(n.events, ev) return n.results, nil } func (n *mockNotifier) count() int { n.mu.Lock() defer n.mu.Unlock() return len(n.events) } func driftChangeset() diff.Changeset { return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Update, Name: "www"}}} } func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) { projectID := uuid.New() templateID := uuid.New() domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} domainB := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} st.domains[projectID] = []store.Domain{domainA, domainB} checker := &mockChecker{ results: map[uuid.UUID]diff.Changeset{ domainA.ID: driftChangeset(), domainB.ID: {}, }, } notifier := &mockNotifier{} m := metrics.New() // CountDriftDomains is the real system-wide count, independent of what // this tick touched — set it to something that would NOT match a local // per-tick accumulator (only 1 of 2 domains here drifted) to prove the // gauge comes from the store call, not a local tally. st.driftCount = 7 sched := New(st, checker, notifier, m) if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("RunOnce: %v", err) } if st.status[domainA.ID] != StatusDrift { t.Fatalf("domain A status = %q, want drift", st.status[domainA.ID]) } if st.status[domainB.ID] != StatusInSync { t.Fatalf("domain B status = %q, want in_sync", st.status[domainB.ID]) } if got := notifier.count(); got != 1 { t.Fatalf("notifications sent = %d, want 1 (only domain A)", got) } if notifier.events[0].Domain != domainA.ID.String() { t.Fatalf("notified domain = %q, want domain A (%s)", notifier.events[0].Domain, domainA.ID) } if notifier.events[0].Status != StatusDrift { t.Fatalf("notified status = %q, want drift", notifier.events[0].Status) } if len(st.touchedProjects) != 1 || st.touchedProjects[0] != projectID { t.Fatalf("TouchScheduleRun calls = %v, want [%s]", st.touchedProjects, projectID) } if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusDrift)); got != 1 { t.Fatalf("ChecksTotal{drift} = %v, want 1", got) } if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 { t.Fatalf("ChecksTotal{in_sync} = %v, want 1", got) } if got := testutil.ToFloat64(m.DriftDomains); got != float64(st.driftCount) { t.Fatalf("DriftDomains gauge = %v, want %d (from CountDriftDomains)", got, st.driftCount) } } func TestRunOnce_Idempotent_NoRepeatNotifyOnUnchangedDrift(t *testing.T) { projectID := uuid.New() templateID := uuid.New() domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} st.domains[projectID] = []store.Domain{domainA} checker := &mockChecker{ results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()}, } notifier := &mockNotifier{} m := metrics.New() sched := New(st, checker, notifier, m) if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("first RunOnce: %v", err) } if got := notifier.count(); got != 1 { t.Fatalf("after first run notifications = %d, want 1", got) } if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("second RunOnce: %v", err) } if got := notifier.count(); got != 1 { t.Fatalf("after second run (drift->drift) notifications = %d, want still 1 (no repeat)", got) } } func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) { projectID := uuid.New() templateID := uuid.New() domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} st.domains[projectID] = []store.Domain{domainA} checker := &mockChecker{ errs: map[uuid.UUID]error{domainA.ID: errors.New("provider timeout")}, } notifier := &mockNotifier{} m := metrics.New() sched := New(st, checker, notifier, m) if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("RunOnce: %v", err) } if st.status[domainA.ID] != StatusError { t.Fatalf("domain A status = %q, want error", st.status[domainA.ID]) } if got := notifier.count(); got != 1 { t.Fatalf("notifications = %d, want 1 (unknown->error)", got) } if notifier.events[0].Status != StatusError { t.Fatalf("notified status = %q, want error", notifier.events[0].Status) } if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusError)); got != 1 { t.Fatalf("ChecksTotal{error} = %v, want 1", got) } } func TestRunOnce_SkipsDomainWithoutTemplate(t *testing.T) { projectID := uuid.New() templateID := uuid.New() domainNoTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: nil} domainWithTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} st.domains[projectID] = []store.Domain{domainNoTemplate, domainWithTemplate} checker := &mockChecker{ results: map[uuid.UUID]diff.Changeset{domainWithTemplate.ID: {}}, } notifier := &mockNotifier{} m := metrics.New() sched := New(st, checker, notifier, m) if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("RunOnce: %v", err) } for _, id := range checker.calls { if id == domainNoTemplate.ID { t.Fatalf("Checker.Check was called for templateless domain %s, want skipped", id) } } if len(checker.calls) != 1 || checker.calls[0] != domainWithTemplate.ID { t.Fatalf("Checker.Check calls = %v, want exactly [%s]", checker.calls, domainWithTemplate.ID) } if _, ok := st.status[domainNoTemplate.ID]; ok { t.Fatalf("templateless domain status = %q, want no status set (never checked)", st.status[domainNoTemplate.ID]) } if st.status[domainWithTemplate.ID] != StatusInSync { t.Fatalf("domain with template status = %q, want in_sync", st.status[domainWithTemplate.ID]) } if got := notifier.count(); got != 0 { t.Fatalf("notifications sent = %d, want 0 (templateless skip is silent, and template domain unknown->in_sync is not news)", got) } if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 { t.Fatalf("ChecksTotal{in_sync} = %v, want 1 (only the templated domain was checked)", got) } } // TestRunOnce_RecordsPerChannelNotificationMetrics exercises the fix for the // "IncNotification('dispatch', newStatus) unconditionally" bug: the // scheduler must record one NotificationsTotal increment per channel result, // labeled by that channel's actual type and its actual sent/failed outcome // — not a single "dispatch" placeholder blind to per-channel delivery. func TestRunOnce_RecordsPerChannelNotificationMetrics(t *testing.T) { projectID := uuid.New() templateID := uuid.New() domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID} st := newMockStore() st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}} st.domains[projectID] = []store.Domain{domainA} checker := &mockChecker{ results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()}, } notifier := &mockNotifier{ results: []notify.ChannelResult{ {Type: "telegram"}, {Type: "webhook", Err: errors.New("x")}, }, } m := metrics.New() sched := New(st, checker, notifier, m) // unknown -> drift: shouldNotify is true, so notifier.Send fires once. if err := sched.RunOnce(context.Background(), time.Now()); err != nil { t.Fatalf("RunOnce: %v", err) } if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "sent")); got != 1 { t.Fatalf("NotificationsTotal{telegram,sent} = %v, want 1", got) } if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("webhook", "failed")); got != 1 { t.Fatalf("NotificationsTotal{webhook,failed} = %v, want 1", got) } } func TestShouldNotify(t *testing.T) { cases := []struct { name string prev string new string want bool }{ {"unknown->drift notifies", StatusUnknown, StatusDrift, true}, {"unknown->error notifies", StatusUnknown, StatusError, true}, {"unknown->in_sync is silent (first sync is not news)", StatusUnknown, StatusInSync, false}, {"drift->drift does not repeat", StatusDrift, StatusDrift, false}, {"error->error does not repeat", StatusError, StatusError, false}, {"drift->in_sync notifies (resolved)", StatusDrift, StatusInSync, true}, {"in_sync->drift notifies", StatusInSync, StatusDrift, true}, {"in_sync->error notifies", StatusInSync, StatusError, true}, {"in_sync->in_sync is silent", StatusInSync, StatusInSync, false}, {"error->drift notifies (still bad, different bad)", StatusError, StatusDrift, true}, {"error->in_sync notifies (resolved after failure)", StatusError, StatusInSync, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := shouldNotify(tc.prev, tc.new); got != tc.want { t.Fatalf("shouldNotify(%q, %q) = %v, want %v", tc.prev, tc.new, got, tc.want) } }) } }