package models_test import ( "encoding/json" "errors" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/datatypes" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models" "rocketgit.ru/rsmon/worker/config/database" ) func init() { database.Init() } // seedRegion creates a Region row before a worker fixture inserts, so the FK // from worker_nodes -> regions holds. Idempotent: Drop() cleans up. func seedRegion(t *testing.T, code string) models.Region { t.Helper() r := models.Region{} err := models.DB().Where("code = ?", code).First(&r).Error if err == nil { return r } if !errors.Is(err, gorm.ErrRecordNotFound) { t.Fatalf("seed region lookup: %v", err) } r = models.Region{Code: code, Name: code, Enabled: true} require.NoError(t, models.DB().Create(&r).Error) return r } // seedAccountUserPlan returns an account with a default plan and the first user // for the FK chain that contacts/notifications walk. Cleanup is the caller's // responsibility (Drop() at end of test). func seedAccountUserPlan(t *testing.T) (models.Account, models.User) { t.Helper() plan := models.Plan{Name: "test-plan", Default: false} if err := models.DB().Create(&plan).Error; err != nil { t.Fatalf("seed plan: %v", err) } user := models.User{Name: "test-user", Email: taskStringPtr("test-" + uuid.NewString() + "@example.com"), Timezone: "UTC"} if err := models.DB().Create(&user).Error; err != nil { t.Fatalf("seed user: %v", err) } account := models.Account{Name: "test-account", Timezone: "UTC", Language: "en", PlanID: &plan.ID} if err := models.DB().Create(&account).Error; err != nil { t.Fatalf("seed account: %v", err) } return account, user } func seedNotification(t *testing.T, accountID int64) models.Notification { t.Helper() n := models.Notification{Name: "default", AccountID: accountID, Enabled: true, NotifyDown: true, NotifyRestore: true} require.NoError(t, models.DB().Create(&n).Error) return n } func seedEmailContact(t *testing.T, accountID int64) models.Contact { t.Helper() c := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &accountID} require.NoError(t, models.DB().Create(&c).Error) return c } func taskStringPtr(s string) *string { return &s } func TestTaskSchemaMigration(t *testing.T) { models.Drop() models.Migrate() require.True(t, models.DB().Migrator().HasTable(&models.Task{}), "tasks table must exist after Migrate()") require.True(t, models.DB().Migrator().HasTable(&models.NotificationDelivery{}), "notification_deliveries table must exist after Migrate()") } func TestEnqueueNotificationTask_Idempotency(t *testing.T) { models.Drop() models.Migrate() seedRegion(t, "test") account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) // Producer precheck requires at least one worker authorized for the // (method, account) pair. Add an operated-style email worker. now := time.Now() w := &models.WorkerNode{ WorkerID: "worker-idempotency-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 4, LastSeen: &now, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, })), } require.NoError(t, models.DB().Create(w).Error) payload := []byte(`{"method":"email","subject":"[rsmon] x is down","body_text":"down","body_html":"
down
","body_markdown":"**down**","language":"en","message_kind":"down"}`) input := models.EnqueueNotificationTaskInput{ AccountID: account.ID, NotificationID: notification.ID, ContactID: contact.ID, Method: "email", Subject: "[rsmon] x is down", BodyText: "down", BodyHTML: "down
", Language: "en", MessageKind: "down", EventIDs: []int64{42}, Payload: payload, } first, err := models.EnqueueNotificationTask(&input) require.NoError(t, err) require.NotZero(t, first.ID) // Second call with the same logical event must not create a duplicate row. second, err := models.EnqueueNotificationTask(&input) require.NoError(t, err) assert.Equal(t, first.ID, second.ID, "idempotency: second enqueue should return the same row") var count int64 require.NoError(t, models.DB().Model(&models.Task{}).Where("idempotency_key = ?", first.IdempotencyKey).Count(&count).Error) assert.EqualValues(t, 1, count, "exactly one Task row per idempotency key") } // TestEnqueueNotificationTask_AuthorizationSkip makes sure the producer can // observe ErrNotificationMethodNotAuthorized when no worker is eligible for // the (method, account) pair. The producer uses this to avoid enqueueing work // no operated worker could ever pick up. func TestEnqueueNotificationTask_AuthorizationSkip(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") // Register a worker that only knows telegram. An email task must fail the // precheck. w := &models.WorkerNode{ WorkerID: "worker-tg-only-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 4, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "notification_methods": []string{"telegram"}, "notification_accounts": []int64{}, })), } require.NoError(t, models.DB().Create(w).Error) _, err := models.EnqueueNotificationTask(&models.EnqueueNotificationTaskInput{ AccountID: account.ID, NotificationID: notification.ID, ContactID: contact.ID, Method: "email", EventIDs: []int64{1}, Payload: []byte(`{"method":"email"}`), }) require.ErrorIs(t, err, models.ErrNotificationMethodNotAuthorized) } func TestEnqueueNotificationTask_RequiresTaskEnvelopeWorker(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") now := time.Now() worker := &models.WorkerNode{WorkerID: "legacy-notify-" + uuid.NewString(), RegionCode: "test", Status: "active", LastSeen: &now, AuthToken: uuid.NewString(), Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, }))} require.NoError(t, models.DB().Create(worker).Error) require.NoError(t, models.DB().Model(&models.WorkerNode{}).Where("id <> ?", worker.ID).Update("status", "dead").Error) input := &models.EnqueueNotificationTaskInput{AccountID: account.ID, NotificationID: notification.ID, ContactID: contact.ID, Method: "email", EventIDs: []int64{77}, Payload: []byte(`{"method":"email"}`)} _, err := models.EnqueueNotificationTask(input) require.ErrorIs(t, err, models.ErrNotificationMethodNotAuthorized) var count int64 require.NoError(t, models.DB().Model(&models.Task{}).Count(&count).Error) assert.Zero(t, count) worker.Capabilities = datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, })) require.NoError(t, models.DB().Save(worker).Error) task, err := models.EnqueueNotificationTask(input) require.NoError(t, err) assert.Equal(t, models.TaskStateQueued, task.State) } func TestTasksForWorker_SkipsLockedAndLeases(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") now := time.Now() w := &models.WorkerNode{ WorkerID: "worker-email-only-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 4, LastSeen: &now, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, })), } require.NoError(t, models.DB().Create(w).Error) // Enqueue 3 tasks of different methods; only the email ones should be // picked up by the worker. We use the raw helper because the producer's // precheck would refuse the telegram row when no worker handles telegram — // the selector test must exercise the SELECT-side filter, not the // producer-side authorization. mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{1}) mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "telegram", []int64{2}) mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{3}) picked, err := models.TasksForWorker(w, 10) require.NoError(t, err) require.Len(t, picked, 2, "only email tasks should be leased") for _, p := range picked { assert.Equal(t, models.TaskStateLeased, p.State) assert.Equal(t, w.WorkerID, p.LeaseOwner) assert.NotEmpty(t, p.LeaseToken) assert.NotNil(t, p.LeaseExpiresAt) assert.Equal(t, 1, p.Attempts) } // A second call must not return the same rows. picked2, err := models.TasksForWorker(w, 10) require.NoError(t, err) assert.Empty(t, picked2, "second selector poll should see an empty queue while leased") } func TestEnqueueDueCheckTasks_UsesGenericEnvelope(t *testing.T) { models.Drop() models.Migrate() seedRegion(t, "test") account, _ := seedAccountUserPlan(t) group := models.Group{Name: "checks", AccountID: account.ID} require.NoError(t, models.DB().Create(&group).Error) monitor := models.Monitor{GroupID: group.ID, Host: "example.com", Enabled: true} require.NoError(t, models.DB().Create(&monitor).Error) enabled := true check := models.Check{MonitorID: monitor.ID, Enabled: &enabled, Kind: "http", Interval: 60, Settings: datatypes.JSON([]byte(`{}`))} require.NoError(t, models.DB().Create(&check).Error) worker := &models.WorkerNode{ WorkerID: "generic-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 1, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), } require.NoError(t, models.DB().Create(worker).Error) require.NoError(t, models.EnqueueDueCheckTasks(worker, []string{"http"}, 1)) picked, err := models.TasksForWorker(worker, 1) require.NoError(t, err) require.Len(t, picked, 1) assert.Equal(t, models.TaskKindCheck, picked[0].Kind) assert.Equal(t, models.TaskStateLeased, picked[0].State) assert.Equal(t, check.ID, *picked[0].CheckID) } func TestTasksForWorker_ChecksRespectPrivateAccountScope(t *testing.T) { models.Drop() models.Migrate() seedRegion(t, "test") accountA, _ := seedAccountUserPlan(t) accountB, _ := seedAccountUserPlan(t) accountID := accountA.ID private := &models.WorkerNode{ WorkerID: "private-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), AccountID: &accountID, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), } platform := &models.WorkerNode{ WorkerID: "platform-check-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"check_types": []string{"http"}, "task_envelope": true})), } require.NoError(t, models.DB().Create(private).Error) require.NoError(t, models.DB().Create(platform).Error) task := models.Task{JobID: uuid.NewString(), Kind: models.TaskKindCheck, State: models.TaskStateQueued, AccountID: accountB.ID, Payload: datatypes.JSON([]byte(`{"kind":"http"}`)), NotBefore: time.Now().Add(-time.Second), MaxAttempts: 5, IdempotencyKey: "cross-account-" + uuid.NewString()} require.NoError(t, models.DB().Create(&task).Error) picked, err := models.TasksForWorker(private, 1) require.NoError(t, err) assert.Empty(t, picked) picked, err = models.TasksForWorker(platform, 1) require.NoError(t, err) require.Len(t, picked, 1) assert.Equal(t, task.ID, picked[0].ID) } func TestChecksForWorker_RespectsPrivateAccountScope(t *testing.T) { models.Drop() models.Migrate() seedRegion(t, "test") accountA, _ := seedAccountUserPlan(t) accountB, _ := seedAccountUserPlan(t) makeCheck := func(accountID int64, host string) models.Check { group := models.Group{Name: host, AccountID: accountID} require.NoError(t, models.DB().Create(&group).Error) monitor := models.Monitor{GroupID: group.ID, Host: host, Enabled: true} require.NoError(t, models.DB().Create(&monitor).Error) enabled := true check := models.Check{MonitorID: monitor.ID, Enabled: &enabled, Kind: "http", Interval: 60, Settings: datatypes.JSON([]byte(`{}`))} require.NoError(t, models.DB().Create(&check).Error) return check } owned := makeCheck(accountA.ID, "owned.example") _ = makeCheck(accountB.ID, "other.example") accountID := accountA.ID private := &models.WorkerNode{ WorkerID: "private-legacy-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), AccountID: &accountID, Capabilities: datatypes.JSON([]byte(`{"check_types":["http"]}`)), } require.NoError(t, models.DB().Create(private).Error) checks := models.ChecksForWorker(private, []string{"http"}, 10) require.Len(t, checks, 1) assert.Equal(t, owned.ID, checks[0].ID) } func TestTasksForWorker_SkipsExpiredDeadline(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") now := time.Now() worker := &models.WorkerNode{ WorkerID: "worker-deadline-" + uuid.NewString(), RegionCode: "test", Status: "active", LastSeen: &now, AuthToken: uuid.NewString(), Concurrency: 1, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}})), } require.NoError(t, models.DB().Create(worker).Error) expired := time.Now().Add(-time.Second) task := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{91}) require.NoError(t, models.DB().Model(&task).Update("deadline", expired).Error) picked, err := models.TasksForWorker(worker, 1) require.NoError(t, err) assert.Empty(t, picked) var stored models.Task require.NoError(t, models.DB().First(&stored, task.ID).Error) assert.Equal(t, models.TaskStateQueued, stored.State) assert.Equal(t, 0, stored.Attempts) } func TestReapExpiredTasksTerminatesExpiredQueuedNotification(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) message := models.Message{NotificationID: notification.ID, ContactID: contact.ID, Kind: "down", State: models.TaskStateQueued} require.NoError(t, models.DB().Create(&message).Error) deadline := time.Now().Add(-time.Minute) // This reaper test intentionally has no eligible worker; insert directly // so it tests deadline handling rather than producer capability validation. task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{92}) require.NoError(t, models.DB().Model(&task).Updates(map[string]interface{}{"message_id": message.ID, "deadline": deadline}).Error) _, _, err := models.ReapExpiredTasks() require.NoError(t, err) var stored models.Task require.NoError(t, models.DB().First(&stored, task.ID).Error) assert.Equal(t, models.TaskStateDead, stored.State) assert.Equal(t, "notification deadline expired", stored.LastError) var storedMessage models.Message require.NoError(t, models.DB().First(&storedMessage, message.ID).Error) assert.Equal(t, "error", storedMessage.State) var auditCount int64 require.NoError(t, models.DB().Model(&models.NotificationDelivery{}).Where("task_id = ? AND status = ?", task.ID, "expired").Count(&auditCount).Error) assert.EqualValues(t, 1, auditCount) } func TestReapExpiredTasks_RecyclesLeasesAndDeadsExhaustedRetries(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") now := time.Now() w := &models.WorkerNode{ WorkerID: "worker-reap-" + uuid.NewString(), RegionCode: "test", Status: "active", LastSeen: &now, AuthToken: uuid.NewString(), Concurrency: 4, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, })), } require.NoError(t, models.DB().Create(w).Error) // 1) A leased task whose lease expired — should go back to queued. expiredLease := time.Now().Add(-time.Minute) leased := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{10}) require.NoError(t, models.DB().Model(&leased).Updates(map[string]interface{}{ "state": models.TaskStateLeased, "lease_owner": w.WorkerID, "lease_expires_at": expiredLease, "attempts": 1, }).Error) // 2) A failed_retry task past its not_before and at max_attempts — should move to dead. failedRetry := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{11}) require.NoError(t, models.DB().Model(&failedRetry).Updates(map[string]interface{}{ "state": models.TaskStateFailedRetry, "attempts": 5, "max_attempts": 5, "not_before": time.Now().Add(-time.Minute), }).Error) // 3) A failed_retry task past not_before but attempts < max_attempts — must stay failed_retry. pendingRetry := mustEnqueue(t, account.ID, notification.ID, contact.ID, "email", []int64{12}) require.NoError(t, models.DB().Model(&pendingRetry).Updates(map[string]interface{}{ "state": models.TaskStateFailedRetry, "attempts": 2, "max_attempts": 5, "not_before": time.Now().Add(-time.Minute), }).Error) reaped, deaded, err := models.ReapExpiredTasks() require.NoError(t, err) assert.Equal(t, 1, reaped, "one expired lease should be returned to queue") assert.Equal(t, 1, deaded, "one exhausted retry should move to dead") var leasedRow models.Task require.NoError(t, models.DB().First(&leasedRow, leased.ID).Error) assert.Equal(t, models.TaskStateQueued, leasedRow.State) assert.Empty(t, leasedRow.LeaseOwner) assert.Nil(t, leasedRow.LeaseExpiresAt) var deadRow models.Task require.NoError(t, models.DB().First(&deadRow, failedRetry.ID).Error) assert.Equal(t, models.TaskStateDead, deadRow.State) var pendingRow models.Task require.NoError(t, models.DB().First(&pendingRow, pendingRetry.ID).Error) assert.Equal(t, models.TaskStateFailedRetry, pendingRow.State) } func TestReapExpiredTasks_ExpiredNotificationLeaseExhaustionFinalizesMessage(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) message := models.Message{NotificationID: notification.ID, ContactID: contact.ID, Kind: "down", State: models.TaskStateQueued} require.NoError(t, models.DB().Create(&message).Error) task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{99}) require.NoError(t, models.DB().Model(&task).Updates(map[string]interface{}{ "message_id": message.ID, "state": models.TaskStateLeased, "attempts": 5, "max_attempts": 5, "lease_expires_at": time.Now().Add(-time.Minute), }).Error) _, deaded, err := models.ReapExpiredTasks() require.NoError(t, err) assert.Equal(t, 1, deaded) var storedTask models.Task require.NoError(t, models.DB().First(&storedTask, task.ID).Error) assert.Equal(t, models.TaskStateDead, storedTask.State) assert.Equal(t, "lease expired after max attempts", storedTask.LastError) var storedMessage models.Message require.NoError(t, models.DB().First(&storedMessage, message.ID).Error) assert.Equal(t, "error", storedMessage.State) require.NotNil(t, storedMessage.Error) assert.Equal(t, "lease expired after max attempts", *storedMessage.Error) var delivery models.NotificationDelivery require.NoError(t, models.DB().Where("task_id = ?", task.ID).First(&delivery).Error) assert.Equal(t, "dead", delivery.Status) assert.Equal(t, "lease expired after max attempts", delivery.Error) } func TestNotificationTaskLeaseOutlivesExecutionTimeoutAndReapsAfterExpiry(t *testing.T) { models.Drop() models.Migrate() account, _ := seedAccountUserPlan(t) notification := seedNotification(t, account.ID) contact := seedEmailContact(t, account.ID) seedRegion(t, "test") worker := &models.WorkerNode{ WorkerID: "notification-lease-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 1, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{"task_envelope": true, "notification_methods": []string{"email"}, "notification_accounts": []int64{}})), } require.NoError(t, models.DB().Create(worker).Error) task := mustEnqueueRaw(t, account.ID, notification.ID, contact.ID, "email", []int64{101}) leased, err := models.TasksForWorker(worker, 1) require.NoError(t, err) require.Len(t, leased, 1) require.NotNil(t, leased[0].LeaseExpiresAt) assert.GreaterOrEqual(t, leased[0].LeaseExpiresAt.Sub(time.Now()), models.DefaultNotificationTaskLeaseTTL-time.Second) assert.Greater(t, leased[0].LeaseExpiresAt.Sub(time.Now()), models.DefaultNotificationExecutionTimeout) withinExecution := time.Now().Add(models.DefaultNotificationExecutionTimeout) require.NoError(t, models.DB().Model(&task).Update("lease_expires_at", withinExecution).Error) reaped, _, err := models.ReapExpiredTasks() require.NoError(t, err) assert.Zero(t, reaped) var stored models.Task require.NoError(t, models.DB().First(&stored, task.ID).Error) assert.Equal(t, models.TaskStateLeased, stored.State) require.NoError(t, models.DB().Model(&task).Update("lease_expires_at", time.Now().Add(-time.Second)).Error) reaped, _, err = models.ReapExpiredTasks() require.NoError(t, err) assert.Equal(t, 1, reaped) require.NoError(t, models.DB().First(&stored, task.ID).Error) assert.Equal(t, models.TaskStateQueued, stored.State) } func mustEnqueue(t *testing.T, accountID, notificationID, contactID int64, method string, eventIDs []int64) models.Task { t.Helper() payload := []byte(`{"method":"` + method + `"}`) task, err := models.EnqueueNotificationTask(&models.EnqueueNotificationTaskInput{ AccountID: accountID, NotificationID: notificationID, ContactID: contactID, Method: method, EventIDs: eventIDs, Payload: payload, NotBefore: time.Now().Add(-time.Second), }) require.NoError(t, err) require.NotZero(t, task.ID) return *task } // mustEnqueueRaw inserts a Task row directly without going through the producer // precheck. The selector test deliberately mixes methods (email + telegram) on a // worker that only handles email; the producer would refuse the telegram row, // which is the wrong thing to assert about in a selector test. func mustEnqueueRaw(t *testing.T, accountID, notificationID, contactID int64, method string, eventIDs []int64) models.Task { t.Helper() payload := datatypes.JSON([]byte(`{"method":"` + method + `"}`)) contact := contactID task := models.Task{ JobID: uuid.New().String(), Kind: models.TaskKindNotification, State: models.TaskStateQueued, AccountID: accountID, ContactID: &contact, Payload: payload, NotBefore: time.Now().Add(-time.Second), Attempts: 0, MaxAttempts: 5, IdempotencyKey: models.NotificationIdempotencyKey(notificationID, contactID, eventIDs[0]), } require.NoError(t, models.DB().Create(&task).Error) require.NotZero(t, task.ID) return task } func mustJSON(t *testing.T, v interface{}) []byte { t.Helper() b, err := json.Marshal(v) require.NoError(t, err) return b } // TestWorkerNodeCapabilities_NotificationFlags confirms that the JSON-backed // capabilities blob correctly exposes the notification_methods and // notification_accounts arrays that the selector and credential push depend on. func TestWorkerNodeCapabilities_NotificationFlags(t *testing.T) { models.Drop() models.Migrate() seedRegion(t, "test") w := &models.WorkerNode{ WorkerID: "caps-worker-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 4, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "notification_methods": []string{"email", "telegram"}, "notification_accounts": []int64{7, 8}, })), } require.NoError(t, models.DB().Create(w).Error) got := models.WorkerNode{} require.NoError(t, models.DB().First(&got, w.ID).Error) assert.Equal(t, []string{"email", "telegram"}, got.NotificationMethods()) assert.Equal(t, []int64{7, 8}, got.NotificationAccounts()) assert.True(t, got.CanDeliverNotification("email", 7)) assert.False(t, got.CanDeliverNotification("email", 9), "account 9 is not in the allowed list") assert.False(t, got.CanDeliverNotification("mattermost", 7), "method not authorized") // Operated-style worker: empty accounts list means "all accounts". w2 := &models.WorkerNode{ WorkerID: "ops-worker-" + uuid.NewString(), RegionCode: "test", Status: "active", AuthToken: uuid.NewString(), Concurrency: 4, Capabilities: datatypes.JSON(mustJSON(t, map[string]interface{}{ "check_types": []string{"http"}, "notification_methods": []string{"email"}, "notification_accounts": []int64{}, })), } require.NoError(t, models.DB().Create(w2).Error) got2 := models.WorkerNode{} require.NoError(t, models.DB().First(&got2, w2.ID).Error) assert.True(t, got2.CanDeliverNotification("email", 9999), "empty accounts list means all accounts") } func TestWorkerNodeReportedWorkloadUsesDisjointHeartbeatFields(t *testing.T) { w := &models.WorkerNode{Capabilities: datatypes.JSON([]byte(`{"active_checks":2,"queue_depth":3,"active_notifications":5,"notification_queue_depth":7}`))} assert.Equal(t, 17, w.ReportedWorkload()) } // TestEnsureConfiguredWorkerNode_NotificationCapabilitiesDefaults verifies that // EnsureConfiguredWorkerNode (the in-cluster worker provisioner) populates // notification_methods / notification_accounts on the JSON blob so the new // selector and the new credential push work out of the box for the bundled // worker. func TestEnsureConfiguredWorkerNode_NotificationCapabilitiesDefaults(t *testing.T) { models.Drop() models.Migrate() t.Setenv("WORKER_AUTH_TOKEN", "secret-token-for-test-xyz") t.Setenv("DEPLOY_ENV", "test-env") t.Setenv("RSMON_WORKER_ID", "worker-test") t.Setenv("NOTIFICATION_METHODS", "email,telegram") t.Setenv("NOTIFICATION_ACCOUNTS", "11,22") models.EnsureConfiguredWorkerNode() var got models.WorkerNode require.NoError(t, models.DB().Where("worker_id = ?", "worker-test").First(&got).Error) assert.Equal(t, []string{"email", "telegram"}, got.NotificationMethods()) assert.Equal(t, []int64{11, 22}, got.NotificationAccounts()) } // TestNotificationIdempotencyKeyFormat pins the producer-side key shape so the // result handler can re-derive it for matching without depending on internal // package state. func TestNotificationIdempotencyKeyFormat(t *testing.T) { key := models.NotificationIdempotencyKey(7, 9, 13) assert.Equal(t, "notif:7:contact:9:event:13", key) } // guard against uuid being accidentally dropped from the imports. var _ = uuid.New