system: notifications: add transport testing functionality and related UI components

This commit is contained in:
rainy-mathew
2026-04-18 16:25:35 +05:30
parent 1bbfe757f0
commit b061eb0fbd
9 changed files with 470 additions and 38 deletions
@@ -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
}
}
@@ -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: &notifications.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: &notifications.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: &notifications.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())
}
}
+1
View File
@@ -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")
+43 -14
View File
@@ -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
@@ -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)
+4
View File
@@ -45,3 +45,7 @@ export async function updateNotificationTransports(
export async function deleteNotificationTransport(id: number): Promise<APIResponse> {
return await apiRequest(`/notifications/transports/${id}`, APIResponseSchema, 'DELETE');
}
export async function testNotificationTransport(id: number): Promise<APIResponse> {
return await apiRequest(`/notifications/transports/${id}/test`, APIResponseSchema, 'POST');
}
@@ -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<string>();
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<string>();
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}
/>
<CustomValueInput
label="Recipients (comma or newline separated)"
type="textarea"
<ComboBox
bind:open={smtpRecipientsOpen}
label="Recipients"
bind:value={form.smtpRecipients}
placeholder=""
textAreaClasses="min-h-20"
data={smtpRecipientOptions}
placeholder="Select or type recipients"
width="w-full"
multiple={true}
allowCustom={true}
onValueChange={(value) => {
form.smtpRecipients = normalizeRecipients(Array.isArray(value) ? value : []);
}}
/>
<div class="grid grid-cols-2 gap-x-4">
<CustomCheckbox label="Use TLS/STARTTLS" bind:checked={form.smtpUseTls} />
@@ -1,10 +1,15 @@
<script lang="ts">
import { deleteNotificationTransport, getNotificationTransports } from '$lib/api/notifications';
import {
deleteNotificationTransport,
getNotificationTransports,
testNotificationTransport
} from '$lib/api/notifications';
import AlertDialog from '$lib/components/custom/Dialog/Alert.svelte';
import CreateOrEdit from '$lib/components/custom/Notifications/CreateOrEdit.svelte';
import TreeTable from '$lib/components/custom/TreeTable.svelte';
import Search from '$lib/components/custom/TreeTable/Search.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import type { User } from '$lib/types/auth';
import type { Column, Row } from '$lib/types/components/tree-table';
import type { NotificationConfig } from '$lib/types/notifications';
import { handleAPIError, isAPIResponse, updateCache } from '$lib/utils/http';
@@ -14,6 +19,7 @@
interface Data {
config: NotificationConfig;
users: User[];
}
let { data }: { data: Data } = $props();
@@ -73,6 +79,24 @@
let activeRow: Row | null = $derived(activeRows ? (activeRows[0] as Row) : ({} as Row));
let query: string = $state('');
let testingTransport = $state(false);
async function testSelectedTransport() {
if (!activeRow?.id) {
return;
}
testingTransport = true;
const result = await testNotificationTransport(Number(activeRow.id));
testingTransport = false;
if (isAPIResponse(result) && result.status === 'success') {
toast.success('Test notification sent', { position: 'bottom-center' });
} else {
handleAPIError(result);
toast.error('Failed to send test notification', { position: 'bottom-center' });
}
}
</script>
{#snippet button(type: string)}
@@ -107,6 +131,23 @@
<span>Delete</span>
</div>
</Button>
{:else if type === 'test'}
<Button
onclick={testSelectedTransport}
size="sm"
variant="outline"
class="h-6.5"
disabled={testingTransport}
>
<div class="flex items-center">
{#if testingTransport}
<span class="icon-[mdi--loading] mr-1 h-4 w-4 animate-spin"></span>
{:else}
<span class="icon-[mdi--flask-outline] mr-1 h-4 w-4"></span>
{/if}
<span>Test</span>
</div>
</Button>
{/if}
{/if}
{/snippet}
@@ -120,6 +161,7 @@
<span>New</span>
</div>
</Button>
{@render button('test')}
{@render button('edit')}
{@render button('delete')}
</div>
@@ -136,6 +178,7 @@
<CreateOrEdit
bind:open={modals.create.open}
edit={false}
users={data.users}
transports={(configResource.current as NotificationConfig).transports || []}
afterChange={() => 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()}
/>
@@ -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
};
}