feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
233
internal/distworker/notification_test.go
Обычный файл
233
internal/distworker/notification_test.go
Обычный файл
@@ -0,0 +1,233 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// runnerWithCreds is the smallest fixture that yields a Runner with a
|
||||
// credentials block already applied (no DB, no init websocket).
|
||||
func runnerWithCreds(creds *wire.NotificationCredentials) *Runner {
|
||||
r := NewRunner(&Config{MaxConcurrency: 4})
|
||||
r.credentialsMu.Lock()
|
||||
r.credentials = creds
|
||||
r.credentialsMu.Unlock()
|
||||
return r
|
||||
}
|
||||
|
||||
// TestExecuteNotification_Email_NoCredentials verifies that the executor
|
||||
// returns a permanent failure when no SMTP credential is pushed.
|
||||
func TestExecuteNotification_Email_NoCredentials(t *testing.T) {
|
||||
r := runnerWithCreds(nil)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-1",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-1","message_id":1,"notification_id":1,
|
||||
"method":"email","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"email","value":"ops@example.com","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
assert.NotNil(t, report.Error)
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status, "must be permanent so the producer does not loop")
|
||||
}
|
||||
|
||||
func TestClassifyResult_NilEmailErrorDoesNotPanic(t *testing.T) {
|
||||
status, _, text, retry, err := classifyResult(nil, "")
|
||||
assert.Equal(t, wire.NotificationResultDelivered, status)
|
||||
assert.Empty(t, text)
|
||||
assert.Nil(t, retry)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_UnsupportedMethod covers sms/voice returning
|
||||
// permanent + unsupported_method.
|
||||
func TestExecuteNotification_UnsupportedMethod(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
|
||||
Telegram: []wire.TelegramCredential{{Name: "bot", Token: "123:abc"}},
|
||||
})
|
||||
for _, method := range []string{"sms", "voice"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-" + method,
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-` + method + `","message_id":1,"notification_id":1,
|
||||
"method":"` + method + `","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"` + method + `","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
require.NotNil(t, report.Error)
|
||||
assert.Contains(t, *report.Error, "unsupported_method")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteNotification_UnknownMethod classifies unknown methods as
|
||||
// permanent so we never loop on bad producer output.
|
||||
func TestExecuteNotification_UnknownMethod(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-x",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-x","message_id":1,"notification_id":1,
|
||||
"method":"pigeon","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"pigeon","value":"ops@example.com","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_EmptyPayloadPermanent: a malformed task must be
|
||||
// rejected permanently so the operator can spot it on the admin page.
|
||||
func TestExecuteNotification_EmptyPayloadPermanent(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
|
||||
})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "job-empty"})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
require.NotNil(t, report.Error)
|
||||
}
|
||||
|
||||
func TestExecuteNotification_ExpiredDeadlineDoesNotDeliver(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
expired := time.Now().Add(-time.Second)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "expired", Deadline: &expired, Payload: []byte(`{"job_id":"expired","message_id":1,"method":"email","contact":{"id":1,"kind":"email"}}`)})
|
||||
require.NotNil(t, report.Error)
|
||||
assert.Contains(t, *report.Error, "deadline expired")
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_DurationPositive: every report carries a non-zero
|
||||
// duration_ms even when the work happens instantly. This is what the
|
||||
// notification_deliveries audit row expects.
|
||||
func TestExecuteNotification_DurationPositive(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-d",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-d","message_id":1,"notification_id":1,
|
||||
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.GreaterOrEqual(t, report.DurationMs, 0)
|
||||
}
|
||||
|
||||
// TestClassifyResult verifies the error -> status mapping.
|
||||
func TestClassifyResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus string
|
||||
wantRetryAfter *int
|
||||
}{
|
||||
{"nil error", nil, wire.NotificationResultDelivered, nil},
|
||||
{"permanent provider error", errors.New("550 mailbox not found"), wire.NotificationResultPermanent, nil},
|
||||
{"smtp 421 retryable", errors.New("smtp: 421 try again later"), wire.NotificationResultRetryable, intPtr(30)},
|
||||
{"smtp 452 retryable", errors.New("452 insufficient storage"), wire.NotificationResultRetryable, intPtr(30)},
|
||||
{"network timeout retryable", errors.New("dial tcp: i/o timeout"), wire.NotificationResultRetryable, intPtr(15)},
|
||||
{"tls handshake retryable", errors.New("tls: handshake failure"), wire.NotificationResultRetryable, intPtr(15)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, _, errStr, retry, _ := classifyResult(tc.err, "")
|
||||
assert.Equal(t, tc.wantStatus, status)
|
||||
if tc.err != nil {
|
||||
assert.NotEmpty(t, errStr)
|
||||
}
|
||||
if tc.wantRetryAfter == nil {
|
||||
assert.Nil(t, retry)
|
||||
} else {
|
||||
require.NotNil(t, retry)
|
||||
assert.Equal(t, *tc.wantRetryAfter, *retry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireTelegramToModel_RoundTrip sanity-checks the wire->DB credential
|
||||
// translation used by the telegram branch.
|
||||
func TestWireTelegramToModel_RoundTrip(t *testing.T) {
|
||||
wire := &wire.TelegramCredential{
|
||||
ID: 1,
|
||||
Name: "main-bot",
|
||||
BotName: "rsmon_bot",
|
||||
Token: "123456:ABCDEFG",
|
||||
APIURL: "https://api.telegram.org",
|
||||
}
|
||||
nc := wireTelegramToModel(wire)
|
||||
require.NotNil(t, nc)
|
||||
assert.Equal(t, models.CredentialKindTelegram, nc.Kind)
|
||||
assert.Equal(t, "main-bot", nc.Name)
|
||||
require.NotNil(t, nc.BotName)
|
||||
assert.Equal(t, "rsmon_bot", *nc.BotName)
|
||||
require.NotNil(t, nc.APIURL)
|
||||
assert.Equal(t, "https://api.telegram.org", *nc.APIURL)
|
||||
got, err := nc.GetSecret()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "123456:ABCDEFG", got)
|
||||
}
|
||||
|
||||
func TestSelectNotificationCredentialByID(t *testing.T) {
|
||||
id := int64(2)
|
||||
smtp, ok := selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "second", smtp.Name)
|
||||
|
||||
tg, ok := selectTelegramCredential([]wire.TelegramCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "second", tg.Name)
|
||||
|
||||
missingID := int64(3)
|
||||
_, ok = selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}}, &missingID)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// TestEnqueueNotification_BoundedQueue ensures the notifyQueue provides
|
||||
// backpressure: the channel capacity is queueCapacity() and EnqueueNotification
|
||||
// blocks once it is full.
|
||||
func TestEnqueueNotification_BoundedQueue(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 4})
|
||||
r.notifyQueue = make(chan wire.NotificationTask, 4)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
require.True(t, r.EnqueueNotification(wire.NotificationTask{JobID: "x"}))
|
||||
}
|
||||
// Channel is full; a non-blocking send must fail. We cannot truly verify
|
||||
// the blocking case in unit tests, so we just assert the depth counter.
|
||||
assert.Equal(t, int64(4), r.notifyDepth)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_ReportsJobIDFromPayload verifies the executor
|
||||
// falls back to the task's JobID when the payload has none.
|
||||
func TestExecuteNotification_ReportsJobIDFromPayload(t *testing.T) {
|
||||
r := runnerWithCreds(nil)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "outer-job",
|
||||
Payload: []byte(`{
|
||||
"job_id":"","message_id":1,"notification_id":1,
|
||||
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, "outer-job", report.JobID)
|
||||
}
|
||||
|
||||
// guard against time import being unused if the above compile-time helpers
|
||||
// are dropped in a future refactor.
|
||||
var _ = time.Second
|
||||
Ссылка в новой задаче
Block a user