package notify import ( "context" "encoding/json" "fmt" "net" "net/http" "strings" "testing" "time" "rocketgit.ru/rsmon/worker/app/models" ) const ( mailhogSMTPHost = "localhost" mailhogSMTPPort = 31025 mailhogAPIURL = "http://localhost:38025" networkDialTimeout = 2 * time.Second mailhogPollInterval = 100 * time.Millisecond mailhogPollDeadline = 3 * time.Second ) // mailhogMessage mirrors the subset of MailHog's /api/v2/messages payload we // care about in tests: the parsed headers and the rendered body. type mailhogMessage struct { Content struct { Headers map[string][]string `json:"Headers"` Body string `json:"Body"` } `json:"Content"` } // skipIfMailHogDown skips the test if MailHog's SMTP port is unreachable. // Tests stay green on dev machines without docker; on CI with docker they // run for real against the running MailHog container. func skipIfMailHogDown(t *testing.T) { t.Helper() addr := net.JoinHostPort(mailhogSMTPHost, fmt.Sprintf("%d", mailhogSMTPPort)) conn, err := net.DialTimeout("tcp", addr, networkDialTimeout) if err != nil { t.Skipf("mailhog not reachable at %s: %v", addr, err) } _ = conn.Close() } // mailhogMessages fetches the most recent messages from MailHog's HTTP API. func mailhogMessages(t *testing.T) []mailhogMessage { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, mailhogAPIURL+"/api/v2/messages?limit=50", nil) if err != nil { t.Fatalf("build mailhog request: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("query mailhog: %v", err) } defer func() { _ = resp.Body.Close() }() var out struct { Total int `json:"total"` Items []mailhogMessage `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode mailhog response: %v", err) } return out.Items } // mailhogDeleteAll clears the MailHog in-memory mailbox so each test starts // from a known state. func mailhogDeleteAll(t *testing.T) { t.Helper() req, err := http.NewRequest(http.MethodDelete, mailhogAPIURL+"/api/v1/messages", nil) if err != nil { t.Fatalf("build mailhog delete: %v", err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("delete mailhog messages: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { t.Fatalf("mailhog delete returned %d", resp.StatusCode) } } func TestEmail_Network_SendAndVerify(t *testing.T) { skipIfMailHogDown(t) mailhogDeleteAll(t) port := mailhogSMTPPort enabled := true cred := &models.NotificationCredential{ Kind: models.CredentialKindSMTP, Name: "mailhog-test", Server: strPtr(mailhogSMTPHost), Port: &port, Login: strPtr(""), FromName: strPtr("RSMon Tests"), FromAddr: strPtr("tests@rsmon.test"), Enabled: &enabled, // MailHog accepts any password; "plain:" prefix keeps GetSecret // working without the credential encryption key configured. SecretEnc: "plain:", } subject := "rsmon-test-subject-" + time.Now().Format("150405.000") body := "rsmon-test-body hello mailhog" to := "to@rsmon.test" if err := Email(cred, to, subject, body, ""); err != nil { t.Fatalf("Email returned error: %v", err) } deadline := time.Now().Add(mailhogPollDeadline) var found *mailhogMessage for time.Now().Before(deadline) { msgs := mailhogMessages(t) for i := range msgs { hsubj := msgs[i].Content.Headers["Subject"] if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) { found = &msgs[i] break } } if found != nil { break } time.Sleep(mailhogPollInterval) } if found == nil { t.Fatalf("message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline) } if !strings.Contains(found.Content.Body, body) { t.Errorf("body mismatch: want contains %q, got %q", body, found.Content.Body) } if hfrom := found.Content.Headers["From"]; len(hfrom) == 0 || !strings.Contains(hfrom[0], "tests@rsmon.test") { t.Errorf("from mismatch: want contains tests@rsmon.test, got %v", hfrom) } if hto := found.Content.Headers["To"]; len(hto) == 0 || !strings.Contains(hto[0], to) { t.Errorf("to mismatch: want contains %s, got %v", to, hto) } } func TestEmail_Network_HTMLAlternative(t *testing.T) { skipIfMailHogDown(t) mailhogDeleteAll(t) port := mailhogSMTPPort enabled := true cred := &models.NotificationCredential{ Kind: models.CredentialKindSMTP, Name: "mailhog-html", Server: strPtr(mailhogSMTPHost), Port: &port, Login: strPtr(""), FromAddr: strPtr("tests@rsmon.test"), Enabled: &enabled, SecretEnc: "plain:", } subject := "rsmon-html-" + time.Now().Format("150405.000") bodyText := "plain text body" bodyHTML := "

html body

" if err := Email(cred, "to@rsmon.test", subject, bodyText, bodyHTML); err != nil { t.Fatalf("Email returned error: %v", err) } deadline := time.Now().Add(mailhogPollDeadline) var found *mailhogMessage for time.Now().Before(deadline) { msgs := mailhogMessages(t) for i := range msgs { hsubj := msgs[i].Content.Headers["Subject"] if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) { found = &msgs[i] break } } if found != nil { break } time.Sleep(mailhogPollInterval) } if found == nil { t.Fatalf("html message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline) } body := found.Content.Body if !strings.Contains(body, bodyText) { t.Errorf("text part missing: want contains %q in %q", bodyText, body) } if !strings.Contains(body, bodyHTML) { t.Errorf("html part missing: want contains %q in %q", bodyHTML, body) } }