From b061eb0fbda5b862e7665f5bbf1fc84456dae4dd Mon Sep 17 00:00:00 2001 From: rainy-mathew Date: Sat, 18 Apr 2026 16:25:35 +0530 Subject: [PATCH] system: notifications: add transport testing functionality and related UI components --- .../handlers/notifications/notifications.go | 74 ++++++++++ .../notifications/notifications_test.go | 134 ++++++++++++++++++ internal/handlers/routes.go | 1 + internal/services/notifications/service.go | 57 ++++++-- .../services/notifications/service_test.go | 95 +++++++++++++ web/src/lib/api/notifications.ts | 4 + .../custom/Notifications/CreateOrEdit.svelte | 83 ++++++++--- .../system/notifications/+page.svelte | 46 +++++- .../settings/system/notifications/+page.ts | 14 +- 9 files changed, 470 insertions(+), 38 deletions(-) diff --git a/internal/handlers/notifications/notifications.go b/internal/handlers/notifications/notifications.go index dc4876f6..9dfe2f71 100644 --- a/internal/handlers/notifications/notifications.go +++ b/internal/handlers/notifications/notifications.go @@ -290,6 +290,59 @@ func DeleteTransport(service *notifications.Service) gin.HandlerFunc { } } +func TestTransport(service *notifications.Service) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "invalid_transport_id", + Error: "invalid_transport_id", + Data: nil, + }) + return + } + + err = service.TestTransport(c.Request.Context(), uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, internal.APIResponse[any]{ + Status: "error", + Message: "transport_not_found", + Error: "transport_not_found", + Data: nil, + }) + return + } + + if isTestTransportBadRequest(err) { + c.JSON(http.StatusBadRequest, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_test_transport", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{ + Status: "error", + Message: "failed_to_test_transport", + Error: err.Error(), + Data: nil, + }) + return + } + + c.JSON(http.StatusOK, internal.APIResponse[any]{ + Status: "success", + Message: "transport_test_sent", + Error: "", + Data: nil, + }) + } +} + func parseInt(value string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil { @@ -297,3 +350,24 @@ func parseInt(value string, fallback int) int { } return v } + +func isTestTransportBadRequest(err error) bool { + if err == nil { + return false + } + + msg := err.Error() + + switch { + case strings.HasPrefix(msg, "invalid_"): + return true + case strings.HasPrefix(msg, "transport_name_required"): + return true + case strings.HasPrefix(msg, "ntfy_"): + return true + case strings.HasPrefix(msg, "smtp_"): + return true + default: + return false + } +} diff --git a/internal/handlers/notifications/notifications_test.go b/internal/handlers/notifications/notifications_test.go index 43e46731..28c51790 100644 --- a/internal/handlers/notifications/notifications_test.go +++ b/internal/handlers/notifications/notifications_test.go @@ -13,6 +13,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "github.com/alchemillahq/sylve/internal/db/models" @@ -106,3 +107,136 @@ func TestNotificationsListHandlerReturnsItems(t *testing.T) { t.Fatalf("expected_1_item got: %d", len(items)) } } + +func TestTestTransportHandlerSendsNtfyTransport(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newHandlerTestService(t) + ntfyCalls := 0 + svc.SetNtfySender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error { + ntfyCalls++ + return nil + }) + + view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{ + Transports: []notifications.TransportConfigEntryUpdate{ + { + Name: "Ntfy", + Type: notifications.TransportTypeNtfy, + Enabled: false, + Ntfy: ¬ifications.NtfyTransportConfigUpdate{ + BaseURL: "https://ntfy.sh", + Topic: "alerts", + }, + }, + }, + }) + if err != nil { + t.Fatalf("failed_to_seed_ntfy_transport: %v", err) + } + + r := gin.New() + r.POST("/api/notifications/transports/:id/test", TestTransport(svc)) + + req := httptest.NewRequest( + http.MethodPost, + "/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test", + nil, + ) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected_200 got: %d body=%s", rec.Code, rec.Body.String()) + } + if ntfyCalls != 1 { + t.Fatalf("expected_ntfy_called_once got: %d", ntfyCalls) + } +} + +func TestTestTransportHandlerSendsSMTPTransport(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newHandlerTestService(t) + emailCalls := 0 + svc.SetEmailSender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error { + emailCalls++ + return nil + }) + + view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{ + Transports: []notifications.TransportConfigEntryUpdate{ + { + Name: "SMTP", + Type: notifications.TransportTypeSMTP, + Enabled: false, + Email: ¬ifications.EmailTransportConfigUpdate{ + SMTPHost: "smtp.example.com", + SMTPPort: 587, + SMTPFrom: "alerts@example.com", + Recipients: []string{"alerts@example.com"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("failed_to_seed_smtp_transport: %v", err) + } + + r := gin.New() + r.POST("/api/notifications/transports/:id/test", TestTransport(svc)) + + req := httptest.NewRequest( + http.MethodPost, + "/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test", + nil, + ) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected_200 got: %d body=%s", rec.Code, rec.Body.String()) + } + if emailCalls != 1 { + t.Fatalf("expected_email_called_once got: %d", emailCalls) + } +} + +func TestTestTransportHandlerReturnsBadRequestForInvalidTransportConfig(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newHandlerTestService(t) + view, err := svc.UpdateTransportConfig(context.Background(), notifications.TransportConfigUpdate{ + Transports: []notifications.TransportConfigEntryUpdate{ + { + Name: "Broken SMTP", + Type: notifications.TransportTypeSMTP, + Enabled: true, + Email: ¬ifications.EmailTransportConfigUpdate{ + SMTPHost: "", + SMTPPort: 587, + SMTPFrom: "alerts@example.com", + Recipients: []string{"alerts@example.com"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("failed_to_seed_broken_smtp_transport: %v", err) + } + + r := gin.New() + r.POST("/api/notifications/transports/:id/test", TestTransport(svc)) + + req := httptest.NewRequest( + http.MethodPost, + "/api/notifications/transports/"+strconv.FormatUint(uint64(view.Transports[0].ID), 10)+"/test", + nil, + ) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected_400 got: %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go index ca5ec87e..4f594e37 100644 --- a/internal/handlers/routes.go +++ b/internal/handlers/routes.go @@ -517,6 +517,7 @@ func RegisterRoutes(r *gin.Engine, notifications.GET("/transports", notificationsHandlers.GetConfig(notificationService)) notifications.PUT("/transports", notificationsHandlers.UpdateConfig(notificationService)) notifications.DELETE("/transports/:id", notificationsHandlers.DeleteTransport(notificationService)) + notifications.POST("/transports/:id/test", notificationsHandlers.TestTransport(notificationService)) } users := auth.Group("/users") diff --git a/internal/services/notifications/service.go b/internal/services/notifications/service.go index 7a50cf19..788103f6 100644 --- a/internal/services/notifications/service.go +++ b/internal/services/notifications/service.go @@ -32,11 +32,10 @@ import ( ) const ( - defaultNtfyBaseURL = "https://ntfy.sh" - defaultSMTPPort = 587 - defaultTransportName = "Default" - defaultListLimit = 50 - maxListLimit = 500 + defaultNtfyBaseURL = "https://ntfy.sh" + defaultSMTPPort = 587 + defaultListLimit = 50 + maxListLimit = 500 ) const ( @@ -391,6 +390,44 @@ func (s *Service) DeleteTransport(ctx context.Context, id uint) error { return nil } +func (s *Service) TestTransport(ctx context.Context, id uint) error { + if s == nil || s.DB == nil { + return fmt.Errorf("notifications_service_not_initialized") + } + if id == 0 { + return fmt.Errorf("invalid_transport_id") + } + + cfg, err := s.resolveTransportForUpdate(s.DB.WithContext(ctx), id) + if err != nil { + return err + } + + now := s.now().UTC() + input := notifier.EventInput{ + Kind: "system.notifications.test", + Title: "Sylve Notification Test", + Body: fmt.Sprintf("This is a test notification sent at %s.", now.Format(time.RFC3339)), + Severity: string(models.NotificationSeverityInfo), + Source: "settings.notifications", + Fingerprint: fmt.Sprintf("transport-test-%d-%d", id, now.UnixNano()), + Metadata: map[string]string{ + "transportId": strconv.FormatUint(uint64(id), 10), + }, + } + + switch normalizeTransportType(cfg.Type) { + case TransportTypeNtfy: + token := strings.TrimSpace(cfg.NtfyAuthToken) + return s.ntfySender(ctx, cfg, input, token) + case TransportTypeSMTP: + password := strings.TrimSpace(cfg.SMTPPassword) + return s.emailSender(ctx, cfg, input, password) + default: + return fmt.Errorf("invalid_transport_type") + } +} + func (s *Service) GetTransportConfig(ctx context.Context) (TransportConfigView, error) { if s == nil || s.DB == nil { return TransportConfigView{}, fmt.Errorf("notifications_service_not_initialized") @@ -427,7 +464,7 @@ func (s *Service) UpdateTransportConfig(ctx context.Context, input TransportConf cfg.Name = strings.TrimSpace(entry.Name) if cfg.Name == "" { - cfg.Name = defaultTransportName + return fmt.Errorf("transport_name_required") } cfg.Type = transportType @@ -577,10 +614,6 @@ func (s *Service) ensureTransportConfigs(tx *gorm.DB) ([]models.NotificationTran for idx := range configs { updated := false cfg := &configs[idx] - if strings.TrimSpace(cfg.Name) == "" { - cfg.Name = defaultTransportName - updated = true - } normalizedType := normalizeTransportType(cfg.Type) if normalizedType == "" { if cfg.NtfyEnabled && !cfg.EmailEnabled { @@ -668,10 +701,6 @@ func (s *Service) toTransportConfigView(configs []models.NotificationTransportCo Type: normalizeTransportType(cfg.Type), } - if entry.Name == "" { - entry.Name = defaultTransportName - } - switch entry.Type { case TransportTypeNtfy: entry.Enabled = cfg.NtfyEnabled diff --git a/internal/services/notifications/service_test.go b/internal/services/notifications/service_test.go index 50948d8a..4b5491b4 100644 --- a/internal/services/notifications/service_test.go +++ b/internal/services/notifications/service_test.go @@ -276,6 +276,101 @@ func TestTransportConfigStoresRecipientsAndSecretFlags(t *testing.T) { } } +func TestUpdateTransportConfigRejectsEmptyTransportName(t *testing.T) { + svc := newTestService(t) + + _, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{ + Transports: []TransportConfigEntryUpdate{ + { + Name: " ", + Type: TransportTypeNtfy, + Enabled: true, + Ntfy: &NtfyTransportConfigUpdate{ + BaseURL: "https://ntfy.sh", + Topic: "alerts", + }, + }, + }, + }) + if err == nil { + t.Fatalf("expected_error_for_blank_transport_name") + } + if err.Error() != "transport_name_required" { + t.Fatalf("expected_transport_name_required got: %v", err) + } +} + +func TestTestTransportSendsForNtfyAndSMTPEvenWhenDisabled(t *testing.T) { + svc := newTestService(t) + + ntfyCalls := 0 + emailCalls := 0 + svc.SetNtfySender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, token string) error { + ntfyCalls++ + return nil + }) + svc.SetEmailSender(func(ctx context.Context, cfg models.NotificationTransportConfig, input notifier.EventInput, password string) error { + emailCalls++ + return nil + }) + + view, err := svc.UpdateTransportConfig(context.Background(), TransportConfigUpdate{ + Transports: []TransportConfigEntryUpdate{ + { + Name: "Ntfy Transport", + Type: TransportTypeNtfy, + Enabled: false, + Ntfy: &NtfyTransportConfigUpdate{ + BaseURL: "https://ntfy.sh", + Topic: "alerts", + }, + }, + { + Name: "SMTP Transport", + Type: TransportTypeSMTP, + Enabled: false, + Email: &EmailTransportConfigUpdate{ + SMTPHost: "smtp.example.com", + SMTPPort: 587, + SMTPFrom: "alerts@example.com", + Recipients: []string{"alerts@example.com"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("update_transport_config_failed: %v", err) + } + + var ntfyID uint + var smtpID uint + for _, transport := range view.Transports { + switch transport.Type { + case TransportTypeNtfy: + ntfyID = transport.ID + case TransportTypeSMTP: + smtpID = transport.ID + } + } + if ntfyID == 0 || smtpID == 0 { + t.Fatalf("expected_both_transport_ids ntfy=%d smtp=%d", ntfyID, smtpID) + } + + if err := svc.TestTransport(context.Background(), ntfyID); err != nil { + t.Fatalf("test_ntfy_transport_failed: %v", err) + } + if err := svc.TestTransport(context.Background(), smtpID); err != nil { + t.Fatalf("test_smtp_transport_failed: %v", err) + } + + if ntfyCalls != 1 { + t.Fatalf("expected_ntfy_test_call_once got: %d", ntfyCalls) + } + if emailCalls != 1 { + t.Fatalf("expected_smtp_test_call_once got: %d", emailCalls) + } +} + func TestSuppressionDoesNotCrossKindsWithSameFingerprint(t *testing.T) { svc := newTestService(t) diff --git a/web/src/lib/api/notifications.ts b/web/src/lib/api/notifications.ts index 9136bc75..e5311866 100644 --- a/web/src/lib/api/notifications.ts +++ b/web/src/lib/api/notifications.ts @@ -45,3 +45,7 @@ export async function updateNotificationTransports( export async function deleteNotificationTransport(id: number): Promise { return await apiRequest(`/notifications/transports/${id}`, APIResponseSchema, 'DELETE'); } + +export async function testNotificationTransport(id: number): Promise { + return await apiRequest(`/notifications/transports/${id}/test`, APIResponseSchema, 'POST'); +} diff --git a/web/src/lib/components/custom/Notifications/CreateOrEdit.svelte b/web/src/lib/components/custom/Notifications/CreateOrEdit.svelte index e77eb2e6..fcd91371 100644 --- a/web/src/lib/components/custom/Notifications/CreateOrEdit.svelte +++ b/web/src/lib/components/custom/Notifications/CreateOrEdit.svelte @@ -3,10 +3,13 @@ import SimpleSelect from '$lib/components/custom/SimpleSelect.svelte'; import Button from '$lib/components/ui/button/button.svelte'; import CustomCheckbox from '$lib/components/ui/custom-input/checkbox.svelte'; + import ComboBox from '$lib/components/ui/custom-input/combobox.svelte'; import CustomValueInput from '$lib/components/ui/custom-input/value.svelte'; import * as Dialog from '$lib/components/ui/dialog/index.js'; + import type { User } from '$lib/types/auth'; import type { NotificationConfig, UpdateNotificationConfigInput } from '$lib/types/notifications'; import { handleAPIError, isAPIResponse } from '$lib/utils/http'; + import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; type TransportType = 'ntfy' | 'smtp'; @@ -24,7 +27,7 @@ smtpUsername: string; smtpFrom: string; smtpUseTls: boolean; - smtpRecipients: string; + smtpRecipients: string[]; smtpPassword: string; smtpHasPassword: boolean; }; @@ -33,13 +36,15 @@ open: boolean; edit: boolean; id?: number; + users: User[]; transports: NotificationConfig['transports']; afterChange: () => void; } - let { open = $bindable(), edit, id, transports, afterChange }: Props = $props(); + let { open = $bindable(), edit, id, users, transports, afterChange }: Props = $props(); let loading = $state(false); + let smtpRecipientsOpen = $state(false); function defaultForm(index = 1, type: TransportType = 'smtp'): TransportForm { return { @@ -55,7 +60,7 @@ smtpUsername: '', smtpFrom: '', smtpUseTls: true, - smtpRecipients: '', + smtpRecipients: [], smtpPassword: '', smtpHasPassword: false }; @@ -87,7 +92,7 @@ smtpUsername: editingTransport.email?.smtpUsername ?? '', smtpFrom: editingTransport.email?.smtpFrom ?? '', smtpUseTls: editingTransport.email?.smtpUseTls ?? true, - smtpRecipients: (editingTransport.email?.recipients ?? []).join(', '), + smtpRecipients: [...(editingTransport.email?.recipients ?? [])], smtpPassword: '', smtpHasPassword: editingTransport.email?.hasPassword ?? false }; @@ -97,17 +102,47 @@ } }); - function parseRecipients(input: string): string[] { - return input - .split(/[\n,]+/g) - .map((item) => item.trim()) - .filter((item) => item.length > 0); + const smtpRecipientOptions = $derived.by(() => { + const seen = new SvelteSet(); + const options: { label: string; value: string }[] = []; + + for (const user of users) { + const email = user.email.trim(); + if (!email || seen.has(email)) { + continue; + } + + seen.add(email); + options.push({ + label: user.username ? `${user.username} <${email}>` : email, + value: email + }); + } + + return options; + }); + + function normalizeRecipients(values: string[]): string[] { + const seen = new SvelteSet(); + const normalized: string[] = []; + + for (const value of values) { + const recipient = value.trim(); + if (!recipient || seen.has(recipient)) { + continue; + } + + seen.add(recipient); + normalized.push(recipient); + } + + return normalized; } function buildEntry(f: TransportForm): UpdateNotificationConfigInput['transports'][number] { return { ...(f.id ? { id: f.id } : {}), - name: f.name.trim() || 'Default', + name: f.name.trim(), type: f.type, enabled: f.enabled, ntfy: @@ -126,7 +161,7 @@ smtpUsername: f.smtpUsername, smtpFrom: f.smtpFrom, smtpUseTls: f.smtpUseTls, - recipients: parseRecipients(f.smtpRecipients), + recipients: normalizeRecipients(f.smtpRecipients), ...(f.smtpPassword.trim().length > 0 ? { smtpPassword: f.smtpPassword.trim() } : {}) } : null @@ -156,6 +191,14 @@ } async function save() { + if (form.name.trim().length === 0) { + toast.error('Transport name is required', { + duration: 5000, + position: 'bottom-center' + }); + return; + } + loading = true; const entry = buildEntry(form); @@ -219,7 +262,7 @@ smtpUsername: editingTransport.email?.smtpUsername ?? '', smtpFrom: editingTransport.email?.smtpFrom ?? '', smtpUseTls: editingTransport.email?.smtpUseTls ?? true, - smtpRecipients: (editingTransport.email?.recipients ?? []).join(', '), + smtpRecipients: [...(editingTransport.email?.recipients ?? [])], smtpPassword: '', smtpHasPassword: editingTransport.email?.hasPassword ?? false }; @@ -309,12 +352,18 @@ : 'Optional'} revealOnFocus={true} /> - { + form.smtpRecipients = normalizeRecipients(Array.isArray(value) ? value : []); + }} />
diff --git a/web/src/routes/[node]/settings/system/notifications/+page.svelte b/web/src/routes/[node]/settings/system/notifications/+page.svelte index 69908696..2b2c2aee 100644 --- a/web/src/routes/[node]/settings/system/notifications/+page.svelte +++ b/web/src/routes/[node]/settings/system/notifications/+page.svelte @@ -1,10 +1,15 @@ {#snippet button(type: string)} @@ -107,6 +131,23 @@ Delete
+ {:else if type === 'test'} + {/if} {/if} {/snippet} @@ -120,6 +161,7 @@ New + {@render button('test')} {@render button('edit')} {@render button('delete')} @@ -136,6 +178,7 @@ configResource.refetch()} /> @@ -146,6 +189,7 @@ bind:open={modals.edit.open} edit={true} id={modals.edit.id} + users={data.users} transports={(configResource.current as NotificationConfig).transports || []} afterChange={() => configResource.refetch()} /> diff --git a/web/src/routes/[node]/settings/system/notifications/+page.ts b/web/src/routes/[node]/settings/system/notifications/+page.ts index e240b459..b4a827b0 100644 --- a/web/src/routes/[node]/settings/system/notifications/+page.ts +++ b/web/src/routes/[node]/settings/system/notifications/+page.ts @@ -1,21 +1,23 @@ import { getNotificationTransports } from '$lib/api/notifications'; +import { listUsers } from '$lib/api/auth/local'; import { SEVEN_DAYS } from '$lib/utils'; import { cachedFetch, isAPIResponse } from '$lib/utils/http'; export async function load() { - const response = await cachedFetch( - 'notification-config', - async () => await getNotificationTransports(), - SEVEN_DAYS - ); + const [response, usersResponse] = await Promise.all([ + cachedFetch('notification-config', async () => await getNotificationTransports(), SEVEN_DAYS), + cachedFetch('users', async () => await listUsers(), SEVEN_DAYS) + ]); const config = isAPIResponse(response) ? { transports: [] } : response; + const users = Array.isArray(usersResponse) ? usersResponse : []; return { - config + config, + users }; }