Changeset 0.20.0 (#85)

This commit is contained in:
2026-03-01 12:40:15 +00:00
parent eb74180611
commit 817062e5fc
57 changed files with 5435 additions and 72 deletions

View File

@@ -0,0 +1,198 @@
package notifications
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
"time"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SMTPConfig holds SMTP connection settings, loaded from platform_settings.
type SMTPConfig struct {
Host string `json:"smtp_host"`
Port int `json:"smtp_port"`
User string `json:"smtp_user"`
Password string `json:"smtp_password"` // decrypted at load time
From string `json:"smtp_from"`
TLS bool `json:"smtp_tls"`
}
// EmailTransport sends notification emails via SMTP.
type EmailTransport struct {
config SMTPConfig
}
// NewEmailTransport creates an email transport. Returns nil if config is invalid.
func NewEmailTransport(cfg SMTPConfig) *EmailTransport {
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" {
return nil
}
return &EmailTransport{config: cfg}
}
// Send delivers an email with both HTML and plaintext bodies.
// Uses multipart/alternative MIME for email client compatibility.
func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error {
if to == "" {
return fmt.Errorf("recipient email is empty")
}
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano())
// Build MIME message
var msg strings.Builder
msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From))
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
msg.WriteString("\r\n")
// Plaintext part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
msg.WriteString(textBody)
msg.WriteString("\r\n")
// HTML part
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(htmlBody)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
// Connect with timeout
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
var client *smtp.Client
if t.config.TLS {
// Implicit TLS (port 465)
tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host})
client, err = smtp.NewClient(tlsConn, t.config.Host)
} else {
client, err = smtp.NewClient(conn, t.config.Host)
}
if err != nil {
conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close()
// STARTTLS for non-implicit TLS on port 587
if !t.config.TLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
}
// Authenticate if credentials provided
if t.config.User != "" && t.config.Password != "" {
auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
// Send
if err := client.Mail(t.config.From); err != nil {
return fmt.Errorf("smtp MAIL: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp RCPT: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w", err)
}
if _, err := w.Write([]byte(msg.String())); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
return client.Quit()
}
// SendAsync wraps Send in a goroutine so notification delivery isn't blocked.
func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil {
log.Printf("[email] failed to send to %s: %v", to, err)
}
}()
}
// LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig.
// Returns nil if email is not enabled or config is missing.
// The vault parameter is optional; if provided, it decrypts the SMTP password.
func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) {
raw, err := gc.Get(context.Background(), "notifications")
if err != nil {
return nil, fmt.Errorf("no notification settings: %w", err)
}
// Check if email is enabled
enabled, _ := raw["email_enabled"].(bool)
if !enabled {
return nil, nil
}
cfg := SMTPConfig{
Host: stringVal(raw, "smtp_host"),
Port: intVal(raw, "smtp_port", 587),
User: stringVal(raw, "smtp_user"),
From: stringVal(raw, "smtp_from"),
TLS: boolVal(raw, "smtp_tls"),
}
// Password: try decrypted value first, then raw
cfg.Password = stringVal(raw, "smtp_password")
if cfg.Host == "" {
return nil, fmt.Errorf("smtp_host is required")
}
if cfg.From == "" {
return nil, fmt.Errorf("smtp_from is required")
}
return &cfg, nil
}
func stringVal(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func intVal(m map[string]interface{}, key string, def int) int {
switch v := m[key].(type) {
case float64:
return int(v)
case int:
return v
}
return def
}
func boolVal(m map[string]interface{}, key string) bool {
v, _ := m[key].(bool)
return v
}

View File

@@ -0,0 +1,203 @@
package notifications
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Package-level singleton ─────────────────
// Allows notification sources (KB ingester, group handler, etc.)
// to access the service without threading it through constructors.
var defaultService *Service
// SetDefault registers the global notification service instance.
// Called once at startup from main.go.
func SetDefault(svc *Service) {
defaultService = svc
}
// Default returns the global notification service instance, or nil if
// not configured (e.g. in unmanaged mode without a database).
func Default() *Service {
return defaultService
}
// Service handles notification creation, persistence, and real-time delivery.
// All notification sources should go through this service — never insert directly.
type Service struct {
store store.NotificationStore
prefStore store.NotificationPreferenceStore
userStore store.UserStore
hub *events.Hub
// Email transport (nil = email disabled)
email *EmailTransport
instanceName string
// Retention cleanup
retentionDays int
stopCleanup chan struct{}
cleanupWg sync.WaitGroup
}
// NewService creates a notification service bound to the given store and WebSocket hub.
func NewService(s store.NotificationStore, hub *events.Hub) *Service {
return &Service{
store: s,
hub: hub,
instanceName: "Chat Switchboard",
retentionDays: 90,
stopCleanup: make(chan struct{}),
}
}
// WithPrefs attaches the preference store for per-user delivery control.
func (s *Service) WithPrefs(ps store.NotificationPreferenceStore) *Service {
s.prefStore = ps
return s
}
// WithUsers attaches the user store for email address lookup.
func (s *Service) WithUsers(us store.UserStore) *Service {
s.userStore = us
return s
}
// WithEmail enables email transport.
func (s *Service) WithEmail(transport *EmailTransport, instanceName string) *Service {
s.email = transport
if instanceName != "" {
s.instanceName = instanceName
}
return s
}
// WithRetention sets the retention period in days. Notifications older than
// this are pruned by the background cleanup goroutine. Default: 90 days.
func (s *Service) WithRetention(days int) *Service {
if days > 0 {
s.retentionDays = days
}
return s
}
// Notify routes a notification through the user's delivery preferences:
// in-app (persist + WebSocket) and/or email. Falls back to system defaults
// if no user preferences are set.
func (s *Service) Notify(ctx context.Context, n *models.Notification) error {
prefs := s.resolvePrefs(ctx, n.UserID, n.Type)
// In-app: persist + WebSocket push
if prefs.InApp {
if err := s.store.Create(ctx, n); err != nil {
return err
}
if s.hub != nil {
payload, _ := json.Marshal(n)
s.hub.SendToUser(n.UserID, events.Event{
Label: "notification.new",
Payload: payload,
Ts: time.Now().UnixMilli(),
})
}
}
// Email: async send (don't block the caller)
if prefs.Email && s.email != nil && s.userStore != nil {
user, err := s.userStore.GetByID(ctx, n.UserID)
if err == nil && user != nil && user.Email != "" {
subject := SubjectForNotification(n, s.instanceName)
htmlBody := RenderHTML(n, s.instanceName)
textBody := RenderText(n, s.instanceName)
s.email.SendAsync(user.Email, subject, htmlBody, textBody)
}
}
return nil
}
// NotifyMany fans out a notification template to multiple users.
// Each user gets their own copy. Errors are logged but don't fail the batch.
func (s *Service) NotifyMany(ctx context.Context, userIDs []string, template models.Notification) {
for _, uid := range userIDs {
n := template
n.ID = "" // let store generate
n.UserID = uid
if err := s.Notify(ctx, &n); err != nil {
log.Printf("[notifications] failed to notify user %s: %v", uid, err)
}
}
}
// resolvePrefs returns the effective delivery preferences for a user + type.
// Resolution chain: specific type → user '*' default → system default.
func (s *Service) resolvePrefs(ctx context.Context, userID, notifType string) models.ResolvedPreference {
if s.prefStore == nil {
return models.SystemDefaultPreference
}
// 1. Specific type preference
pref, err := s.prefStore.Get(ctx, userID, notifType)
if err == nil && pref != nil {
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
}
// 2. User's wildcard default
pref, err = s.prefStore.Get(ctx, userID, "*")
if err == nil && pref != nil {
return models.ResolvedPreference{InApp: pref.InApp, Email: pref.Email}
}
// 3. System default
return models.SystemDefaultPreference
}
// StartCleanup launches a background goroutine that prunes old notifications daily.
func (s *Service) StartCleanup() {
s.cleanupWg.Add(1)
go func() {
defer s.cleanupWg.Done()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Run once on startup (after a short delay to avoid contention)
time.Sleep(5 * time.Minute)
s.runCleanup()
for {
select {
case <-ticker.C:
s.runCleanup()
case <-s.stopCleanup:
return
}
}
}()
}
// StopCleanup signals the cleanup goroutine to stop and waits for it.
func (s *Service) StopCleanup() {
close(s.stopCleanup)
s.cleanupWg.Wait()
}
func (s *Service) runCleanup() {
cutoff := time.Now().AddDate(0, 0, -s.retentionDays)
deleted, err := s.store.DeleteOlderThan(context.Background(), cutoff)
if err != nil {
log.Printf("[notifications] cleanup error: %v", err)
return
}
if deleted > 0 {
log.Printf("[notifications] cleaned up %d notifications older than %d days", deleted, s.retentionDays)
}
}

View File

@@ -0,0 +1,137 @@
package notifications
import (
"context"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// mockPrefStore implements store.NotificationPreferenceStore for testing.
type mockPrefStore struct {
prefs map[string]map[string]*models.NotificationPreference // userID → type → pref
}
func newMockPrefStore() *mockPrefStore {
return &mockPrefStore{prefs: make(map[string]map[string]*models.NotificationPreference)}
}
func (m *mockPrefStore) Get(_ context.Context, userID, notifType string) (*models.NotificationPreference, error) {
if userPrefs, ok := m.prefs[userID]; ok {
if p, ok := userPrefs[notifType]; ok {
return p, nil
}
}
return nil, nil
}
func (m *mockPrefStore) ListForUser(_ context.Context, userID string) ([]models.NotificationPreference, error) {
var result []models.NotificationPreference
if userPrefs, ok := m.prefs[userID]; ok {
for _, p := range userPrefs {
result = append(result, *p)
}
}
return result, nil
}
func (m *mockPrefStore) Upsert(_ context.Context, pref *models.NotificationPreference) error {
if m.prefs[pref.UserID] == nil {
m.prefs[pref.UserID] = make(map[string]*models.NotificationPreference)
}
m.prefs[pref.UserID][pref.Type] = pref
return nil
}
func (m *mockPrefStore) Delete(_ context.Context, userID, notifType string) error {
if userPrefs, ok := m.prefs[userID]; ok {
delete(userPrefs, notifType)
}
return nil
}
func (m *mockPrefStore) set(userID, notifType string, inApp, email bool) {
if m.prefs[userID] == nil {
m.prefs[userID] = make(map[string]*models.NotificationPreference)
}
m.prefs[userID][notifType] = &models.NotificationPreference{
UserID: userID,
Type: notifType,
InApp: inApp,
Email: email,
}
}
func TestResolvePrefs_SystemDefault(t *testing.T) {
svc := &Service{prefStore: newMockPrefStore()}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("expected system default (in_app=true, email=false), got %+v", p)
}
}
func TestResolvePrefs_NoPrefStore(t *testing.T) {
svc := &Service{prefStore: nil}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("expected system default when no pref store, got %+v", p)
}
}
func TestResolvePrefs_SpecificType(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", true, true)
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || !p.Email {
t.Errorf("expected specific pref (in_app=true, email=true), got %+v", p)
}
}
func TestResolvePrefs_WildcardFallback(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "*", false, true) // user turned off in-app, turned on email globally
svc := &Service{prefStore: ps}
// No specific "kb.ready" pref → falls to wildcard
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if p.InApp || !p.Email {
t.Errorf("expected wildcard pref (in_app=false, email=true), got %+v", p)
}
}
func TestResolvePrefs_SpecificOverridesWildcard(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "*", false, true) // wildcard: no in-app, yes email
ps.set("user1", "kb.ready", true, false) // specific: yes in-app, no email
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("specific should override wildcard, got %+v", p)
}
}
func TestResolvePrefs_DifferentUsersIsolated(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", false, true)
svc := &Service{prefStore: ps}
// user2 has no prefs → system default
p := svc.resolvePrefs(context.Background(), "user2", "kb.ready")
if !p.InApp || p.Email {
t.Errorf("user2 should get system default, got %+v", p)
}
}
func TestResolvePrefs_DisableBoth(t *testing.T) {
ps := newMockPrefStore()
ps.set("user1", "kb.ready", false, false)
svc := &Service{prefStore: ps}
p := svc.resolvePrefs(context.Background(), "user1", "kb.ready")
if p.InApp || p.Email {
t.Errorf("expected both disabled, got %+v", p)
}
}

View File

@@ -0,0 +1,146 @@
package notifications
import (
"context"
"encoding/json"
"fmt"
"log"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Role Fallback ───────────────────────────
// Subscribes to the existing "role.fallback" EventBus event (emitted by
// capabilities/resolver.go since v0.17.0) and creates notifications for
// all admin users.
// roleFallbackPayload matches the payload emitted by the role resolver.
type roleFallbackPayload struct {
Role string `json:"role"`
Primary string `json:"primary_model"`
Fallback string `json:"fallback_model"`
Error string `json:"error"`
}
// RoleFallbackHandler returns an events.Handler that creates admin
// notifications when a model role falls back.
func RoleFallbackHandler(svc *Service, stores store.Stores) events.Handler {
return func(e events.Event) {
var p roleFallbackPayload
if err := json.Unmarshal(e.Payload, &p); err != nil {
log.Printf("[notifications] failed to parse role.fallback payload: %v", err)
return
}
title := fmt.Sprintf("Model fallback: %s → %s", p.Primary, p.Fallback)
if p.Primary == "" {
title = fmt.Sprintf("Role '%s' fell back to %s", p.Role, p.Fallback)
}
body := ""
if p.Error != "" {
body = fmt.Sprintf("Primary model error: %s", p.Error)
}
// Notify all admin users
ctx := context.Background()
admins, _, err := stores.Users.List(ctx, store.ListOptions{Limit: 500})
if err != nil {
log.Printf("[notifications] failed to list users for role.fallback: %v", err)
return
}
for _, u := range admins {
if u.Role != "admin" {
continue
}
n := models.Notification{
UserID: u.ID,
Type: models.NotifTypeRoleFallback,
Title: title,
Body: body,
}
if err := svc.Notify(ctx, &n); err != nil {
log.Printf("[notifications] failed to create role.fallback notification for %s: %v", u.ID, err)
}
}
}
}
// ── Knowledge Base ──────────────────────────
// Called directly from the KB handler after indexing completes or fails.
// Not a bus subscriber — the KB handler calls these functions.
// NotifyKBReady creates a notification when a knowledge base finishes indexing.
func NotifyKBReady(svc *Service, userID, kbID, kbName string, chunkCount int) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeKBReady,
Title: fmt.Sprintf("Knowledge base \"%s\" ready (%d chunks)", kbName, chunkCount),
ResourceType: models.ResourceTypeKnowledgeBase,
ResourceID: kbID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] kb.ready failed for user %s: %v", userID, err)
}
}
// NotifyKBError creates a notification when KB indexing fails.
func NotifyKBError(svc *Service, userID, kbID, kbName, errMsg string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeKBError,
Title: fmt.Sprintf("Knowledge base \"%s\" indexing failed", kbName),
Body: errMsg,
ResourceType: models.ResourceTypeKnowledgeBase,
ResourceID: kbID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] kb.error failed for user %s: %v", userID, err)
}
}
// ── Group Membership ────────────────────────
// Called directly from the group handler on add/remove.
// NotifyGroupMemberAdded creates a notification when a user is added to a group.
func NotifyGroupMemberAdded(svc *Service, userID, groupID, groupName string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeGrantChanged,
Title: fmt.Sprintf("You were added to group \"%s\"", groupName),
ResourceType: models.ResourceTypeGroup,
ResourceID: groupID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] grant.changed (add) failed for user %s: %v", userID, err)
}
}
// NotifyGroupMemberRemoved creates a notification when a user is removed from a group.
func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
if svc == nil {
return
}
n := models.Notification{
UserID: userID,
Type: models.NotifTypeGrantChanged,
Title: fmt.Sprintf("You were removed from group \"%s\"", groupName),
ResourceType: models.ResourceTypeGroup,
ResourceID: groupID,
}
if err := svc.Notify(context.Background(), &n); err != nil {
log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err)
}
}

View File

@@ -0,0 +1,108 @@
package notifications
import (
"bytes"
"html/template"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Template Data ───────────────────────────
type emailData struct {
Title string
Body string
Type string
ResourceType string
InstanceName string
}
// ── HTML Template ───────────────────────────
var htmlTmpl = template.Must(template.New("email").Parse(`<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<div style="border-bottom: 2px solid #4a8af4; padding-bottom: 12px; margin-bottom: 20px;">
<h2 style="margin: 0; color: #4a8af4;">{{.InstanceName}}</h2>
</div>
<div style="background: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 16px;">
<h3 style="margin: 0 0 8px;">{{.Title}}</h3>
{{if .Body}}<p style="margin: 0; color: #555;">{{.Body}}</p>{{end}}
</div>
<p style="font-size: 0.85em; color: #999;">
This notification was sent by {{.InstanceName}}. You can adjust your notification preferences in Settings.
</p>
</body>
</html>`))
// ── Plaintext Template ──────────────────────
var textTmpl = template.Must(template.New("email_text").Parse(`{{.InstanceName}} — Notification
{{.Title}}
{{if .Body}}
{{.Body}}
{{end}}
---
Adjust your notification preferences in Settings.
`))
// ── Render Functions ────────────────────────
// RenderHTML renders an HTML email body for a notification.
func RenderHTML(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
data := emailData{
Title: n.Title,
Body: n.Body,
Type: n.Type,
ResourceType: n.ResourceType,
InstanceName: instanceName,
}
var buf bytes.Buffer
if err := htmlTmpl.Execute(&buf, data); err != nil {
return "<p>" + template.HTMLEscapeString(n.Title) + "</p>"
}
return buf.String()
}
// RenderText renders a plaintext email body for a notification.
func RenderText(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
data := emailData{
Title: n.Title,
Body: n.Body,
Type: n.Type,
ResourceType: n.ResourceType,
InstanceName: instanceName,
}
var buf bytes.Buffer
if err := textTmpl.Execute(&buf, data); err != nil {
return n.Title
}
return buf.String()
}
// SubjectForNotification returns an email subject line.
func SubjectForNotification(n *models.Notification, instanceName string) string {
if instanceName == "" {
instanceName = "Chat Switchboard"
}
prefix := "[" + instanceName + "] "
switch {
case strings.HasPrefix(n.Type, "kb."):
return prefix + "Knowledge Base: " + n.Title
case strings.HasPrefix(n.Type, "role."):
return prefix + "Model Alert: " + n.Title
case strings.HasPrefix(n.Type, "grant."):
return prefix + "Access Change: " + n.Title
default:
return prefix + n.Title
}
}

View File

@@ -0,0 +1,69 @@
package notifications
import (
"strings"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
func TestRenderHTML_Basic(t *testing.T) {
n := &models.Notification{
Title: "KB Ready",
Body: "Your knowledge base has been processed.",
Type: "kb.ready",
}
html := RenderHTML(n, "TestInstance")
if !strings.Contains(html, "KB Ready") {
t.Error("expected title in HTML output")
}
if !strings.Contains(html, "TestInstance") {
t.Error("expected instance name in HTML output")
}
if !strings.Contains(html, "knowledge base") {
t.Error("expected body in HTML output")
}
}
func TestRenderHTML_DefaultInstanceName(t *testing.T) {
n := &models.Notification{Title: "Test"}
html := RenderHTML(n, "")
if !strings.Contains(html, "Chat Switchboard") {
t.Error("expected default instance name")
}
}
func TestRenderText_Basic(t *testing.T) {
n := &models.Notification{
Title: "Model Fallback",
Body: "GPT-4 fell back to GPT-3.5.",
Type: "role.fallback",
}
text := RenderText(n, "MyInstance")
if !strings.Contains(text, "Model Fallback") {
t.Error("expected title in text output")
}
if !strings.Contains(text, "GPT-4 fell back") {
t.Error("expected body in text output")
}
}
func TestSubjectForNotification(t *testing.T) {
tests := []struct {
notifType string
title string
want string
}{
{"kb.ready", "KB Processed", "[Test] Knowledge Base: KB Processed"},
{"role.fallback", "Fallback", "[Test] Model Alert: Fallback"},
{"grant.changed", "Added to team", "[Test] Access Change: Added to team"},
{"custom.type", "Hello", "[Test] Hello"},
}
for _, tt := range tests {
n := &models.Notification{Type: tt.notifType, Title: tt.title}
got := SubjectForNotification(n, "Test")
if got != tt.want {
t.Errorf("SubjectForNotification(%q, %q) = %q, want %q", tt.notifType, tt.title, got, tt.want)
}
}
}