going full courtside
This commit is contained in:
99
backend-go/internal/notifications/discord_test.go
Normal file
99
backend-go/internal/notifications/discord_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendDiscordNotification(t *testing.T) {
|
||||
var receivedPayload discordPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendDiscordNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
if len(receivedPayload.Embeds) != 1 {
|
||||
t.Fatalf("expected 1 embed, got %d", len(receivedPayload.Embeds))
|
||||
}
|
||||
embed := receivedPayload.Embeds[0]
|
||||
if embed.Title != "Koin Ping Alert" {
|
||||
t.Errorf("title = %q, want %q", embed.Title, "Koin Ping Alert")
|
||||
}
|
||||
if embed.Color != 0x00ff00 {
|
||||
t.Errorf("color = %x, want %x (green for incoming_tx)", embed.Color, 0x00ff00)
|
||||
}
|
||||
if len(embed.Fields) != 3 {
|
||||
t.Errorf("expected 3 fields (address, blockchain address, tx), got %d", len(embed.Fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDiscordNotification_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sent, err := SendDiscordNotification(server.URL, "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad response")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestDiscordWebhook(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ok, err := TestDiscordWebhook(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorForAlertType(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"incoming_tx": 0x00ff00,
|
||||
"outgoing_tx": 0xff9900,
|
||||
"large_transfer": 0xff0000,
|
||||
"balance_below": 0xff0000,
|
||||
"unknown": 0x0099ff,
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := colorForAlertType(alertType); got != want {
|
||||
t.Errorf("colorForAlertType(%q) = %x, want %x", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
backend-go/internal/notifications/email.go
Normal file
111
backend-go/internal/notifications/email.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
}
|
||||
|
||||
func (c *SMTPConfig) IsConfigured() bool {
|
||||
return c.Host != "" && c.From != ""
|
||||
}
|
||||
|
||||
func (c *SMTPConfig) addr() string {
|
||||
return fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
}
|
||||
|
||||
func SendEmailNotification(cfg SMTPConfig, toEmail, message string, meta AlertMetadata) (bool, error) {
|
||||
if !cfg.IsConfigured() {
|
||||
return false, fmt.Errorf("SMTP is not configured")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Koin Ping Alert: %s", humanAlertType(meta.AlertType))
|
||||
|
||||
body := buildEmailBody(message, meta)
|
||||
|
||||
msg := strings.Join([]string{
|
||||
fmt.Sprintf("From: %s", cfg.From),
|
||||
fmt.Sprintf("To: %s", toEmail),
|
||||
fmt.Sprintf("Subject: %s", subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
var auth smtp.Auth
|
||||
if cfg.Username != "" {
|
||||
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
|
||||
err := smtp.SendMail(cfg.addr(), auth, cfg.From, []string{toEmail}, []byte(msg))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send email notification to %s: %v", toEmail, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestEmailNotification(cfg SMTPConfig, toEmail string) (bool, error) {
|
||||
meta := AlertMetadata{AlertType: "test"}
|
||||
return SendEmailNotification(cfg, toEmail,
|
||||
"This is a test notification from Koin Ping. Your email is configured correctly!", meta)
|
||||
}
|
||||
|
||||
func buildEmailBody(message string, meta AlertMetadata) string {
|
||||
txSection := ""
|
||||
if meta.TxHash != "" {
|
||||
txSection = fmt.Sprintf(
|
||||
`<tr><td style="padding:8px 0;color:#666">Transaction</td>`+
|
||||
`<td style="padding:8px 0"><a href="https://etherscan.io/tx/%s" style="color:#4f46e5">View on Etherscan</a></td></tr>`,
|
||||
meta.TxHash,
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f5f5f5">
|
||||
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)">
|
||||
<div style="background:#4f46e5;padding:20px 24px">
|
||||
<h1 style="margin:0;color:#fff;font-size:18px">Koin Ping Alert</h1>
|
||||
</div>
|
||||
<div style="padding:24px">
|
||||
<p style="margin:0 0 16px;font-size:15px;color:#333">%s</p>
|
||||
<table style="width:100%%;border-collapse:collapse;font-size:14px">
|
||||
<tr><td style="padding:8px 0;color:#666">Address</td><td style="padding:8px 0">%s</td></tr>
|
||||
<tr><td style="padding:8px 0;color:#666">Blockchain Address</td><td style="padding:8px 0;font-family:monospace;font-size:12px">%s</td></tr>
|
||||
%s
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding:16px 24px;background:#fafafa;font-size:12px;color:#999;text-align:center">
|
||||
Sent by Koin Ping
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, message, meta.AddressLabel, meta.Address, txSection)
|
||||
}
|
||||
|
||||
func humanAlertType(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "Incoming Transaction"
|
||||
case "outgoing_tx":
|
||||
return "Outgoing Transaction"
|
||||
case "large_transfer":
|
||||
return "Large Transfer"
|
||||
case "balance_below":
|
||||
return "Balance Below Threshold"
|
||||
default:
|
||||
return "Alert"
|
||||
}
|
||||
}
|
||||
102
backend-go/internal/notifications/email_test.go
Normal file
102
backend-go/internal/notifications/email_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSMTPConfig_IsConfigured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg SMTPConfig
|
||||
want bool
|
||||
}{
|
||||
{"fully configured", SMTPConfig{Host: "smtp.example.com", Port: 587, From: "noreply@example.com"}, true},
|
||||
{"missing host", SMTPConfig{Port: 587, From: "noreply@example.com"}, false},
|
||||
{"missing from", SMTPConfig{Host: "smtp.example.com", Port: 587}, false},
|
||||
{"empty", SMTPConfig{}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.cfg.IsConfigured(); got != tt.want {
|
||||
t.Errorf("IsConfigured() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPConfig_Addr(t *testing.T) {
|
||||
cfg := SMTPConfig{Host: "smtp.example.com", Port: 587}
|
||||
if got := cfg.addr(); got != "smtp.example.com:587" {
|
||||
t.Errorf("addr() = %q, want %q", got, "smtp.example.com:587")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmailBody(t *testing.T) {
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
body := buildEmailBody("Incoming transaction: 5.5 ETH received", meta)
|
||||
|
||||
if !strings.Contains(body, "Koin Ping Alert") {
|
||||
t.Error("body should contain title")
|
||||
}
|
||||
if !strings.Contains(body, "Incoming transaction: 5.5 ETH received") {
|
||||
t.Error("body should contain message")
|
||||
}
|
||||
if !strings.Contains(body, "Treasury") {
|
||||
t.Error("body should contain address label")
|
||||
}
|
||||
if !strings.Contains(body, "0x1234567890abcdef") {
|
||||
t.Error("body should contain blockchain address")
|
||||
}
|
||||
if !strings.Contains(body, "etherscan.io/tx/0xabc123") {
|
||||
t.Error("body should contain etherscan link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmailBody_NoTxHash(t *testing.T) {
|
||||
meta := AlertMetadata{
|
||||
AddressLabel: "Cold Storage",
|
||||
AlertType: "balance_below",
|
||||
Address: "0xabcdef",
|
||||
}
|
||||
|
||||
body := buildEmailBody("Balance dropped below threshold", meta)
|
||||
|
||||
if strings.Contains(body, "etherscan.io") {
|
||||
t.Error("body should not contain etherscan link when no tx hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanAlertType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"incoming_tx": "Incoming Transaction",
|
||||
"outgoing_tx": "Outgoing Transaction",
|
||||
"large_transfer": "Large Transfer",
|
||||
"balance_below": "Balance Below Threshold",
|
||||
"unknown": "Alert",
|
||||
"test": "Alert",
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := humanAlertType(alertType); got != want {
|
||||
t.Errorf("humanAlertType(%q) = %q, want %q", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmailNotification_NotConfigured(t *testing.T) {
|
||||
cfg := SMTPConfig{}
|
||||
sent, err := SendEmailNotification(cfg, "user@example.com", "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unconfigured SMTP")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
128
backend-go/internal/notifications/slack.go
Normal file
128
backend-go/internal/notifications/slack.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type slackBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text *slackText `json:"text,omitempty"`
|
||||
Fields []slackText `json:"fields,omitempty"`
|
||||
Elements []slackText `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
type slackText struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type slackPayload struct {
|
||||
Blocks []slackBlock `json:"blocks"`
|
||||
}
|
||||
|
||||
func SendSlackNotification(webhookURL, message string, meta AlertMetadata) (bool, error) {
|
||||
etherscanLink := ""
|
||||
if meta.TxHash != "" {
|
||||
etherscanLink = fmt.Sprintf("<%s|View on Etherscan>", "https://etherscan.io/tx/"+meta.TxHash)
|
||||
}
|
||||
|
||||
blocks := []slackBlock{
|
||||
{
|
||||
Type: "header",
|
||||
Text: &slackText{Type: "plain_text", Text: emojiForAlertType(meta.AlertType) + " Koin Ping Alert"},
|
||||
},
|
||||
{
|
||||
Type: "section",
|
||||
Text: &slackText{Type: "mrkdwn", Text: message},
|
||||
},
|
||||
{
|
||||
Type: "section",
|
||||
Fields: []slackText{
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Address:*\n%s", meta.AddressLabel)},
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("*Blockchain Address:*\n`%s`", meta.Address)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if etherscanLink != "" {
|
||||
blocks = append(blocks, slackBlock{
|
||||
Type: "section",
|
||||
Text: &slackText{Type: "mrkdwn", Text: fmt.Sprintf("*Transaction:* %s", etherscanLink)},
|
||||
})
|
||||
}
|
||||
|
||||
blocks = append(blocks, slackBlock{
|
||||
Type: "context",
|
||||
Elements: []slackText{
|
||||
{Type: "mrkdwn", Text: fmt.Sprintf("Koin Ping | %s", time.Now().UTC().Format(time.RFC3339))},
|
||||
},
|
||||
})
|
||||
|
||||
payload := slackPayload{Blocks: blocks}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal slack payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Failed to send Slack notification: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
log.Printf("Slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
return false, fmt.Errorf("slack webhook failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestSlackWebhook(webhookURL string) (bool, error) {
|
||||
payload := slackPayload{
|
||||
Blocks: []slackBlock{
|
||||
{
|
||||
Type: "section",
|
||||
Text: &slackText{
|
||||
Type: "mrkdwn",
|
||||
Text: "Koin Ping test notification — Your Slack webhook is configured correctly!",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("Slack webhook test failed: %v", err)
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 300, nil
|
||||
}
|
||||
|
||||
func emojiForAlertType(alertType string) string {
|
||||
switch alertType {
|
||||
case "incoming_tx":
|
||||
return "📥"
|
||||
case "outgoing_tx":
|
||||
return "📤"
|
||||
case "large_transfer":
|
||||
return "🚨"
|
||||
case "balance_below":
|
||||
return "⚠️"
|
||||
default:
|
||||
return "🔔"
|
||||
}
|
||||
}
|
||||
128
backend-go/internal/notifications/slack_test.go
Normal file
128
backend-go/internal/notifications/slack_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendSlackNotification(t *testing.T) {
|
||||
var receivedPayload slackPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json content type")
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
TxHash: "0xabc123",
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "incoming_tx",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "Incoming transaction: 5.5 ETH received", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
if len(receivedPayload.Blocks) < 3 {
|
||||
t.Fatalf("expected at least 3 blocks, got %d", len(receivedPayload.Blocks))
|
||||
}
|
||||
if receivedPayload.Blocks[0].Type != "header" {
|
||||
t.Errorf("first block should be header, got %s", receivedPayload.Blocks[0].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSlackNotification_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "test", AlertMetadata{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server error response")
|
||||
}
|
||||
if sent {
|
||||
t.Fatal("expected sent to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSlackNotification_NoTxHash(t *testing.T) {
|
||||
var receivedPayload slackPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &receivedPayload)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
meta := AlertMetadata{
|
||||
AddressLabel: "Treasury",
|
||||
AlertType: "balance_below",
|
||||
Address: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
}
|
||||
|
||||
sent, err := SendSlackNotification(server.URL, "Balance dropped below threshold", meta)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("expected sent to be true")
|
||||
}
|
||||
|
||||
for _, block := range receivedPayload.Blocks {
|
||||
if block.Text != nil && block.Type == "section" {
|
||||
if block.Text.Text == "*Transaction:*" {
|
||||
t.Error("should not include transaction block when TxHash is empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestSlackWebhook(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ok, err := TestSlackWebhook(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected ok to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiForAlertType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"incoming_tx": "📥",
|
||||
"outgoing_tx": "📤",
|
||||
"large_transfer": "🚨",
|
||||
"balance_below": "⚠️",
|
||||
"unknown": "🔔",
|
||||
}
|
||||
for alertType, want := range tests {
|
||||
if got := emojiForAlertType(alertType); got != want {
|
||||
t.Errorf("emojiForAlertType(%q) = %q, want %q", alertType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user