Changeset 0.28.2 (#183)
This commit is contained in:
@@ -193,9 +193,25 @@ All enum values used across the API. Definitive source of truth.
|
||||
|
||||
## Notification Types
|
||||
|
||||
`role.fallback`, `memory.extracted`, `system.announcement`,
|
||||
`workflow.assigned`, `workflow.claimed`, `task.completed`,
|
||||
`task.failed`, `user.mentioned`
|
||||
Convention: `domain.action`. Free-form strings — new types don't
|
||||
require migration.
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `role.fallback` | Model role fell back to secondary provider |
|
||||
| `kb.ready` | Knowledge base finished indexing |
|
||||
| `kb.error` | Knowledge base indexing failed |
|
||||
| `grant.changed` | User added to or removed from a group |
|
||||
| `memory.extracted` | New memories extracted from conversation |
|
||||
| `user.mentioned` | User was @mentioned in a channel |
|
||||
| `workflow.assigned` | Workflow stage assigned to team/user |
|
||||
| `workflow.claimed` | Workflow assignment claimed by a user |
|
||||
| `task.completed` | Scheduled task finished successfully |
|
||||
| `task.failed` | Scheduled task failed |
|
||||
| `task.budget_exceeded` | Task hit token/tool/wall-clock budget |
|
||||
|
||||
**Planned (not yet implemented):**
|
||||
`system.announcement` (v0.28.4 — admin broadcast to all users)
|
||||
|
||||
## Git Auth Types
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Real-time notification infrastructure with WebSocket delivery and
|
||||
optional email transport.
|
||||
|
||||
**Auth:** All endpoints require authentication (JWT via `sb_token`
|
||||
cookie or `Authorization: Bearer` header).
|
||||
|
||||
### CRUD
|
||||
|
||||
```
|
||||
@@ -13,32 +16,102 @@ POST /notifications/mark-all-read → mark all as read
|
||||
DELETE /notifications/:id
|
||||
```
|
||||
|
||||
**`GET /notifications`** — paginated list for the authenticated user.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `limit` | int | 20 | 1–100, clamped |
|
||||
| `offset` | int | 0 | Pagination offset |
|
||||
| `unread_only` | bool | false | Filter to unread only |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [ ... ],
|
||||
"total": 42,
|
||||
"limit": 20,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
Notification object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"type": "role.fallback|memory.extracted|system.announcement|...",
|
||||
"type": "role.fallback",
|
||||
"title": "Role Fallback Triggered",
|
||||
"body": "Primary model unavailable, using fallback",
|
||||
"metadata": {},
|
||||
"read": false,
|
||||
"created_at": "..."
|
||||
"resource_type": "channel",
|
||||
"resource_id": "uuid",
|
||||
"is_read": false,
|
||||
"created_at": "2025-01-15T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`resource_type` and `resource_id` are optional — used for
|
||||
click-to-navigate. The resource may be deleted (no FK constraint).
|
||||
|
||||
### Preferences
|
||||
|
||||
Users control per-type delivery:
|
||||
|
||||
```
|
||||
GET /notifications/preferences → { "preferences": [...] }
|
||||
PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
|
||||
DELETE /notifications/preferences/:type → reset to default
|
||||
GET /notifications/preferences → { "data": [...] }
|
||||
PUT /notifications/preferences/:type ← { "in_app": true, "email": false }
|
||||
DELETE /notifications/preferences/:type → reset to default
|
||||
```
|
||||
|
||||
**`GET /notifications/preferences`** returns all preferences set by
|
||||
the user. Empty array if none are configured.
|
||||
|
||||
**`PUT /notifications/preferences/:type`** creates or updates a
|
||||
preference. Both `in_app` and `email` fields are optional (merges
|
||||
with existing values if set).
|
||||
|
||||
**`DELETE /notifications/preferences/:type`** removes the preference,
|
||||
falling back to the next level in the resolution chain. Idempotent —
|
||||
deleting a non-existent preference returns 200.
|
||||
|
||||
Resolution: specific type pref → user wildcard `*` pref → system
|
||||
default (`in_app=true`, `email=false`).
|
||||
|
||||
Returns 503 if the preference store is not available (unmanaged mode).
|
||||
|
||||
### Admin
|
||||
|
||||
```
|
||||
POST /admin/notifications/test-email → send test email to requesting admin
|
||||
```
|
||||
|
||||
Requires admin role. Loads SMTP config from platform settings,
|
||||
sends a test message to the admin's registered email address.
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
| Event | Payload | Description |
|
||||
|-------|---------|-------------|
|
||||
| `notification.new` | Full notification object | New notification created |
|
||||
| `notification.read` | `{"id": "uuid"}` | Single notification marked read |
|
||||
| `notification.read` | `{"action": "mark_all_read"}` | All notifications marked read |
|
||||
|
||||
These are targeted via `Hub.SendToUser` — no room subscription
|
||||
required. Used for badge sync across tabs.
|
||||
|
||||
### Notification Types
|
||||
|
||||
See [enums.md](enums.md#notification-types) for the canonical list.
|
||||
Types are free-form strings (`domain.action` convention) — new types
|
||||
don't require migration.
|
||||
|
||||
### Retention
|
||||
|
||||
Background cleanup prunes notifications older than 90 days (default,
|
||||
configurable via `WithRetention`). Runs daily after a 5-minute
|
||||
startup delay.
|
||||
|
||||
---
|
||||
|
||||
@@ -63,6 +63,11 @@ Clients subscribe to rooms for scoped event delivery:
|
||||
| `memory.extracted` | → client | New memory extracted |
|
||||
| `memory.status` | → client | Memory approved/rejected |
|
||||
| `role.fallback` | → client | Role fallback triggered |
|
||||
| `user.mentioned` | → client | User was @mentioned in a channel |
|
||||
| `workflow.assigned` | → client | Workflow stage assigned to team/user |
|
||||
| `workflow.claimed` | → client | Workflow assignment claimed |
|
||||
| `workflow.advanced` | → client | Workflow stage advanced |
|
||||
| `workflow.completed` | → client | Workflow instance completed |
|
||||
| `workspace.updated` | → client | Workspace state changed |
|
||||
| `project.updated` | → client | Project metadata changed |
|
||||
| `extension.updated` | → client | Extension config changed |
|
||||
@@ -168,8 +173,12 @@ only, `↔ both` = bidirectional.
|
||||
|
||||
### Notification Types
|
||||
|
||||
`role.fallback`, `memory.extracted`, `system.announcement`, plus
|
||||
extensible via notification service.
|
||||
`role.fallback`, `kb.ready`, `kb.error`, `grant.changed`,
|
||||
`memory.extracted`, `user.mentioned`, `workflow.assigned`,
|
||||
`workflow.claimed`, `task.completed`, `task.failed`,
|
||||
`task.budget_exceeded`
|
||||
|
||||
See [enums.md](enums.md#notification-types) for full descriptions.
|
||||
|
||||
### Git Auth Types
|
||||
|
||||
|
||||
@@ -69,12 +69,20 @@ Audit arc, frontend SDK, and infrastructure improvements.
|
||||
### v0.28.2 — ICD Audit: Notifications + Profile + Knowledge + Notes + Providers
|
||||
Known ICD report failures to investigate (likely envelope shape mismatches):
|
||||
- [ ] `GET /settings` — `missing key "settings"` (profile)
|
||||
- [ ] `GET /notifications/preferences` — `missing key "preferences"`
|
||||
- [x] `GET /notifications/preferences` — `missing key "preferences"` (envelope fix)
|
||||
- [ ] `GET /workspaces` — `missing key "data"`
|
||||
- [ ] `GET /git-credentials` — `missing key "data"`
|
||||
- [ ] Notes, providers — passed clean but need full trace (route → handler → store → tests)
|
||||
- [ ] WebSocket event contract audit: document `message.created`, `workflow.advanced`,
|
||||
`presence.changed`, `typing`, `workflow.assigned` event shapes in ICD
|
||||
- [x] Notifications ICD audit: object shape, query params, response envelopes, WS events
|
||||
- [x] Notification type enum sync: remove aspirational types, add implemented types
|
||||
(`kb.ready`, `kb.error`, `grant.changed`, `task.budget_exceeded`)
|
||||
- [x] Implement `memory.extracted` notification (hook in memory extractor)
|
||||
- [x] Implement `user.mentioned` persisted notification (was WS-only)
|
||||
- [x] Implement `workflow.claimed` persisted notification (was WS-only)
|
||||
- [x] Remove dead `NotifTypeProjectInvite` constant
|
||||
- [ ] Notification handler + store tests (PG + SQLite)
|
||||
|
||||
### v0.28.3 — Security Tier (ICD Runner Red Team)
|
||||
New `security` tier in ICD test runner. Multi-user fixtures already exist.
|
||||
@@ -115,6 +123,9 @@ New `security` tier in ICD test runner. Multi-user fixtures already exist.
|
||||
- [ ] Git credentials settings UI: vault-encrypted per-user credentials, SSH key
|
||||
management, per-workspace remote config. Table exists (`git_credentials`), vault
|
||||
pattern exists — needs settings section and CRUD handler.
|
||||
- [ ] `system.announcement` notification type: admin broadcast endpoint
|
||||
(`POST /admin/notifications/broadcast`), fan-out to all active users via
|
||||
`NotifyMany`, admin UI for composing announcements
|
||||
|
||||
### v0.28.5 — Frontend SDK
|
||||
`switchboard-sdk.js` — composition layer over existing globals. Surface
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
@@ -584,6 +585,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
Ts: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// Persist notification for bell/inbox (v0.28.2)
|
||||
if svc := notifications.Default(); svc != nil {
|
||||
notifications.NotifyUserMentioned(svc, mentionedUserID, channelID, userID, truncateContent(req.Content, 120))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
|
||||
return
|
||||
}
|
||||
|
||||
558
server/handlers/notification_test.go
Normal file
558
server/handlers/notification_test.go
Normal file
@@ -0,0 +1,558 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
sqlite "git.gobha.me/xcaliber/chat-switchboard/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Notification Test Harness ──────────────
|
||||
|
||||
type notifHarness struct {
|
||||
*testHarness
|
||||
stores store.Stores
|
||||
adminToken string
|
||||
adminID string
|
||||
userToken string
|
||||
userID string
|
||||
}
|
||||
|
||||
func setupNotifHarness(t *testing.T) *notifHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg))
|
||||
|
||||
notifH := NewNotificationHandler(stores, nil) // no hub in tests
|
||||
protected.GET("/notifications", notifH.List)
|
||||
protected.GET("/notifications/unread-count", notifH.UnreadCount)
|
||||
protected.PATCH("/notifications/:id/read", notifH.MarkRead)
|
||||
protected.POST("/notifications/mark-all-read", notifH.MarkAllRead)
|
||||
protected.DELETE("/notifications/:id", notifH.Delete)
|
||||
protected.GET("/notifications/preferences", notifH.ListPreferences)
|
||||
protected.PUT("/notifications/preferences/:type", notifH.SetPreference)
|
||||
protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference)
|
||||
|
||||
// Seed admin
|
||||
adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID)
|
||||
adminToken := makeToken(adminID, "notif-admin@test.com", "admin")
|
||||
|
||||
// Seed regular user
|
||||
userID := database.SeedTestUser(t, "notif-user", "notif-user@test.com")
|
||||
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||
userToken := makeToken(userID, "notif-user@test.com", "user")
|
||||
|
||||
return ¬ifHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
stores: stores,
|
||||
adminToken: adminToken,
|
||||
adminID: adminID,
|
||||
userToken: userToken,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// seedNotification inserts a notification directly via the store.
|
||||
func (h *notifHarness) seedNotification(userID, notifType, title string) string {
|
||||
h.t.Helper()
|
||||
n := &models.Notification{
|
||||
UserID: userID,
|
||||
Type: notifType,
|
||||
Title: title,
|
||||
}
|
||||
if err := h.stores.Notifications.Create(context.Background(), n); err != nil {
|
||||
h.t.Fatalf("seedNotification: %v", err)
|
||||
}
|
||||
return n.ID
|
||||
}
|
||||
|
||||
// ── GET /notifications ─────────────────────
|
||||
|
||||
func TestNotifications_List_Empty(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if body.Data == nil {
|
||||
t.Error("data should be [] not null")
|
||||
}
|
||||
if len(body.Data) != 0 {
|
||||
t.Errorf("expected 0 items, got %d", len(body.Data))
|
||||
}
|
||||
if body.Total != 0 {
|
||||
t.Errorf("total: got %d, want 0", body.Total)
|
||||
}
|
||||
if body.Limit != 20 {
|
||||
t.Errorf("default limit: got %d, want 20", body.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_List_Pagination(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Seed 5 notifications
|
||||
for i := 0; i < 5; i++ {
|
||||
h.seedNotification(h.userID, "test.type", "Test Notification")
|
||||
}
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications?limit=2&offset=0", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if len(body.Data) != 2 {
|
||||
t.Errorf("items: got %d, want 2", len(body.Data))
|
||||
}
|
||||
if body.Total != 5 {
|
||||
t.Errorf("total: got %d, want 5", body.Total)
|
||||
}
|
||||
if body.Limit != 2 {
|
||||
t.Errorf("limit: got %d, want 2", body.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_List_UnreadOnly(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
id1 := h.seedNotification(h.userID, "test.type", "Unread One")
|
||||
h.seedNotification(h.userID, "test.type", "Unread Two")
|
||||
_ = id1
|
||||
|
||||
// Mark one as read
|
||||
h.stores.Notifications.MarkRead(context.Background(), id1, h.userID)
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications?unread_only=true", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if len(body.Data) != 1 {
|
||||
t.Errorf("unread items: got %d, want 1", len(body.Data))
|
||||
}
|
||||
if body.Total != 1 {
|
||||
t.Errorf("total unread: got %d, want 1", body.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_List_UserIsolation(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Seed notifications for admin, not user
|
||||
h.seedNotification(h.adminID, "test.type", "Admin Only")
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if len(body.Data) != 0 {
|
||||
t.Errorf("user should not see admin's notifications, got %d", len(body.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /notifications/unread-count ────────
|
||||
|
||||
func TestNotifications_UnreadCount(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
h.seedNotification(h.userID, "test.type", "N1")
|
||||
id2 := h.seedNotification(h.userID, "test.type", "N2")
|
||||
h.seedNotification(h.userID, "test.type", "N3")
|
||||
|
||||
// Mark one as read
|
||||
h.stores.Notifications.MarkRead(context.Background(), id2, h.userID)
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
|
||||
if body.Count != 2 {
|
||||
t.Errorf("unread count: got %d, want 2", body.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PATCH /notifications/:id/read ─────────
|
||||
|
||||
func TestNotifications_MarkRead(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
id := h.seedNotification(h.userID, "test.type", "Read Me")
|
||||
|
||||
resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Verify via unread-count
|
||||
resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if body.Count != 0 {
|
||||
t.Errorf("unread count after mark read: got %d, want 0", body.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_MarkRead_WrongUser(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Notification belongs to admin
|
||||
id := h.seedNotification(h.adminID, "test.type", "Admin's")
|
||||
|
||||
// User tries to mark it read
|
||||
resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("cross-user mark read: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_MarkRead_NotFound(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("PATCH", "/api/v1/notifications/00000000-0000-0000-0000-000000000000/read", h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("non-existent: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /notifications/mark-all-read ─────
|
||||
|
||||
func TestNotifications_MarkAllRead(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
h.seedNotification(h.userID, "test.type", "N1")
|
||||
h.seedNotification(h.userID, "test.type", "N2")
|
||||
h.seedNotification(h.userID, "test.type", "N3")
|
||||
|
||||
resp := h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Verify
|
||||
resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil)
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if body.Count != 0 {
|
||||
t.Errorf("unread count after mark-all-read: got %d, want 0", body.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_MarkAllRead_IsolatesUsers(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Both users have notifications
|
||||
h.seedNotification(h.userID, "test.type", "User's")
|
||||
h.seedNotification(h.adminID, "test.type", "Admin's")
|
||||
|
||||
// User marks all read
|
||||
h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil)
|
||||
|
||||
// Admin should still have unread
|
||||
resp := h.request("GET", "/api/v1/notifications/unread-count", h.adminToken, nil)
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if body.Count != 1 {
|
||||
t.Errorf("admin unread count: got %d, want 1", body.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /notifications/:id ─────────────
|
||||
|
||||
func TestNotifications_Delete(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
id := h.seedNotification(h.userID, "test.type", "Delete Me")
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
resp = h.request("GET", "/api/v1/notifications", h.userToken, nil)
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if len(body.Data) != 0 {
|
||||
t.Errorf("notification should be deleted, got %d", len(body.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_Delete_WrongUser(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
id := h.seedNotification(h.adminID, "test.type", "Admin's")
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("cross-user delete: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_Delete_NotFound(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/notifications/00000000-0000-0000-0000-000000000000", h.userToken, nil)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("non-existent: got %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /notifications/preferences ────────
|
||||
|
||||
func TestNotifications_Preferences_Empty(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v, raw: %s", err, resp.Body.String())
|
||||
}
|
||||
if body.Data == nil {
|
||||
t.Error("data should be [] not null")
|
||||
}
|
||||
if len(body.Data) != 0 {
|
||||
t.Errorf("expected empty prefs, got %d", len(body.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_Preferences_Envelope(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Verify response is wrapped in {"data": []}
|
||||
resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
|
||||
var raw map[string]json.RawMessage
|
||||
json.Unmarshal(resp.Body.Bytes(), &raw)
|
||||
|
||||
if _, ok := raw["data"]; !ok {
|
||||
t.Errorf("response must have 'data' key, got keys: %v, raw: %s",
|
||||
keys(raw), resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── PUT /notifications/preferences/:type ──
|
||||
|
||||
func TestNotifications_SetPreference(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
|
||||
"in_app": true,
|
||||
"email": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var pref models.NotificationPreference
|
||||
json.Unmarshal(resp.Body.Bytes(), &pref)
|
||||
if !pref.InApp || !pref.Email {
|
||||
t.Errorf("expected in_app=true email=true, got %+v", pref)
|
||||
}
|
||||
|
||||
// Verify via list
|
||||
resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
|
||||
var body struct {
|
||||
Data []models.NotificationPreference `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if len(body.Data) != 1 {
|
||||
t.Fatalf("prefs count: got %d, want 1", len(body.Data))
|
||||
}
|
||||
if body.Data[0].Type != "kb.ready" {
|
||||
t.Errorf("type: got %q, want %q", body.Data[0].Type, "kb.ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_SetPreference_PartialUpdate(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Set both
|
||||
h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
|
||||
"in_app": true,
|
||||
"email": true,
|
||||
})
|
||||
|
||||
// Partial update: only change email
|
||||
resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
|
||||
"email": false,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("partial update: got %d", resp.Code)
|
||||
}
|
||||
|
||||
var pref models.NotificationPreference
|
||||
json.Unmarshal(resp.Body.Bytes(), &pref)
|
||||
if !pref.InApp {
|
||||
t.Error("in_app should remain true after partial update")
|
||||
}
|
||||
if pref.Email {
|
||||
t.Error("email should be false after partial update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_SetPreference_Wildcard(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
resp := h.request("PUT", "/api/v1/notifications/preferences/*", h.userToken, map[string]interface{}{
|
||||
"in_app": false,
|
||||
"email": true,
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("wildcard pref: got %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var pref models.NotificationPreference
|
||||
json.Unmarshal(resp.Body.Bytes(), &pref)
|
||||
if pref.Type != "*" {
|
||||
t.Errorf("type: got %q, want %q", pref.Type, "*")
|
||||
}
|
||||
}
|
||||
|
||||
// ── DELETE /notifications/preferences/:type ──
|
||||
|
||||
func TestNotifications_DeletePreference(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Create then delete
|
||||
h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{
|
||||
"email": true,
|
||||
})
|
||||
|
||||
resp := h.request("DELETE", "/api/v1/notifications/preferences/kb.ready", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("delete pref: got %d", resp.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil)
|
||||
var body struct {
|
||||
Data []models.NotificationPreference `json:"data"`
|
||||
}
|
||||
json.Unmarshal(resp.Body.Bytes(), &body)
|
||||
if len(body.Data) != 0 {
|
||||
t.Errorf("prefs should be empty after delete, got %d", len(body.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications_DeletePreference_Idempotent(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
// Delete non-existent preference should still return 200 (idempotent)
|
||||
resp := h.request("DELETE", "/api/v1/notifications/preferences/nonexistent.type", h.userToken, nil)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("idempotent delete: got %d, want 200", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth enforcement ──────────────────────
|
||||
|
||||
func TestNotifications_Unauthenticated(t *testing.T) {
|
||||
h := setupNotifHarness(t)
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/api/v1/notifications"},
|
||||
{"GET", "/api/v1/notifications/unread-count"},
|
||||
{"PATCH", "/api/v1/notifications/fake-id/read"},
|
||||
{"POST", "/api/v1/notifications/mark-all-read"},
|
||||
{"DELETE", "/api/v1/notifications/fake-id"},
|
||||
{"GET", "/api/v1/notifications/preferences"},
|
||||
{"PUT", "/api/v1/notifications/preferences/test"},
|
||||
{"DELETE", "/api/v1/notifications/preferences/test"},
|
||||
}
|
||||
|
||||
for _, e := range endpoints {
|
||||
resp := h.request(e.method, e.path, "", nil)
|
||||
if resp.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without token: got %d, want 401", e.method, e.path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────
|
||||
|
||||
func keys(m map[string]json.RawMessage) []string {
|
||||
var ks []string
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
return ks
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) {
|
||||
}
|
||||
|
||||
if h.stores.NotifPrefs == nil {
|
||||
c.JSON(http.StatusOK, []models.NotificationPreference{})
|
||||
c.JSON(http.StatusOK, gin.H{"data": []models.NotificationPreference{}})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func (h *NotificationHandler) ListPreferences(c *gin.Context) {
|
||||
if prefs == nil {
|
||||
prefs = []models.NotificationPreference{}
|
||||
}
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
c.JSON(http.StatusOK, gin.H{"data": prefs})
|
||||
}
|
||||
|
||||
// PUT /api/v1/notifications/preferences/:type
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
)
|
||||
|
||||
// ── Workflow Assignment Handler ─────────────
|
||||
@@ -143,6 +144,15 @@ func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Persist notification for bell/inbox (v0.28.2)
|
||||
if svc := notifications.Default(); svc != nil {
|
||||
var channelID string
|
||||
_ = database.DB.QueryRowContext(c.Request.Context(),
|
||||
database.Q(`SELECT channel_id FROM workflow_assignments WHERE id = $1`),
|
||||
assignmentID).Scan(&channelID)
|
||||
notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/notifications"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
@@ -218,6 +219,11 @@ func (e *Extractor) Extract(ctx context.Context, channelID, userID, teamID, pers
|
||||
|
||||
if saved > 0 {
|
||||
log.Printf("✅ memory extraction: channel %s → %d facts", channelID, saved)
|
||||
|
||||
// Notify user about extracted memories (v0.28.2)
|
||||
if svc := notifications.Default(); svc != nil {
|
||||
notifications.NotifyMemoryExtracted(svc, userID, channelID, saved)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -23,11 +23,17 @@ type Notification struct {
|
||||
// Notification type constants. Convention: domain.action.
|
||||
// Free-form strings — new types don't require migration.
|
||||
const (
|
||||
NotifTypeRoleFallback = "role.fallback"
|
||||
NotifTypeKBReady = "kb.ready"
|
||||
NotifTypeKBError = "kb.error"
|
||||
NotifTypeGrantChanged = "grant.changed"
|
||||
NotifTypeProjectInvite = "project.invite"
|
||||
NotifTypeRoleFallback = "role.fallback"
|
||||
NotifTypeKBReady = "kb.ready"
|
||||
NotifTypeKBError = "kb.error"
|
||||
NotifTypeGrantChanged = "grant.changed"
|
||||
NotifTypeMemoryExtracted = "memory.extracted"
|
||||
NotifTypeUserMentioned = "user.mentioned"
|
||||
NotifTypeWorkflowAssign = "workflow.assigned"
|
||||
NotifTypeWorkflowClaimed = "workflow.claimed"
|
||||
NotifTypeTaskCompleted = "task.completed"
|
||||
NotifTypeTaskFailed = "task.failed"
|
||||
NotifTypeTaskBudget = "task.budget_exceeded"
|
||||
)
|
||||
|
||||
// Resource type constants for click-to-navigate.
|
||||
|
||||
@@ -144,3 +144,68 @@ func NotifyGroupMemberRemoved(svc *Service, userID, groupID, groupName string) {
|
||||
log.Printf("[notifications] grant.changed (remove) failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory Extraction ──────────────────────
|
||||
// Called from memory/extractor.go after successful extraction.
|
||||
|
||||
// NotifyMemoryExtracted creates a notification when new memories are extracted.
|
||||
func NotifyMemoryExtracted(svc *Service, userID, channelID string, count int) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
title := fmt.Sprintf("%d new memories extracted", count)
|
||||
if count == 1 {
|
||||
title = "1 new memory extracted"
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: userID,
|
||||
Type: models.NotifTypeMemoryExtracted,
|
||||
Title: title,
|
||||
ResourceType: models.ResourceTypeChannel,
|
||||
ResourceID: channelID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] memory.extracted failed for user %s: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Mention ───────────────────────────
|
||||
// Called from handlers/completion.go when an @mention targets a human user.
|
||||
|
||||
// NotifyUserMentioned creates a persisted notification for an @mention.
|
||||
func NotifyUserMentioned(svc *Service, mentionedUserID, channelID, fromUserID, preview string) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: mentionedUserID,
|
||||
Type: models.NotifTypeUserMentioned,
|
||||
Title: "You were mentioned in a conversation",
|
||||
Body: preview,
|
||||
ResourceType: models.ResourceTypeChannel,
|
||||
ResourceID: channelID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] user.mentioned failed for user %s: %v", mentionedUserID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Claimed ───────────────────────
|
||||
// Called from handlers/workflow_assignments.go when a user claims an assignment.
|
||||
|
||||
// NotifyWorkflowClaimed creates a persisted notification when an assignment is claimed.
|
||||
func NotifyWorkflowClaimed(svc *Service, claimerID, assignmentID, channelID string) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
n := models.Notification{
|
||||
UserID: claimerID,
|
||||
Type: models.NotifTypeWorkflowClaimed,
|
||||
Title: "You claimed a workflow assignment",
|
||||
ResourceType: models.ResourceTypeChannel,
|
||||
ResourceID: channelID,
|
||||
}
|
||||
if err := svc.Notify(context.Background(), &n); err != nil {
|
||||
log.Printf("[notifications] workflow.claimed failed for user %s: %v", claimerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
288
server/notifications/sources_test.go
Normal file
288
server/notifications/sources_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// mockNotifStore implements store.NotificationStore for testing.
|
||||
type mockNotifStore struct {
|
||||
items []models.Notification
|
||||
}
|
||||
|
||||
func (m *mockNotifStore) Create(_ context.Context, n *models.Notification) error {
|
||||
n.ID = "mock-id"
|
||||
n.CreatedAt = time.Now()
|
||||
m.items = append(m.items, *n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNotifStore) ListByUser(_ context.Context, _ string, _, _ int, _ bool) ([]models.Notification, int, error) {
|
||||
return m.items, len(m.items), nil
|
||||
}
|
||||
func (m *mockNotifStore) MarkRead(_ context.Context, _, _ string) error { return nil }
|
||||
func (m *mockNotifStore) MarkAllRead(_ context.Context, _ string) error { return nil }
|
||||
func (m *mockNotifStore) Delete(_ context.Context, _, _ string) error { return nil }
|
||||
func (m *mockNotifStore) UnreadCount(_ context.Context, _ string) (int, error) {
|
||||
return len(m.items), nil
|
||||
}
|
||||
func (m *mockNotifStore) DeleteOlderThan(_ context.Context, _ time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// newTestService creates a Service with a mock store for testing sources.
|
||||
func newTestService() (*Service, *mockNotifStore) {
|
||||
ms := &mockNotifStore{}
|
||||
svc := NewService(ms, nil) // no hub
|
||||
return svc, ms
|
||||
}
|
||||
|
||||
// ── NotifyKBReady ─────────────────────────
|
||||
|
||||
func TestNotifyKBReady(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyKBReady(svc, "user1", "kb1", "My KB", 42)
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.UserID != "user1" {
|
||||
t.Errorf("user_id: got %q, want %q", n.UserID, "user1")
|
||||
}
|
||||
if n.Type != models.NotifTypeKBReady {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBReady)
|
||||
}
|
||||
if n.ResourceType != models.ResourceTypeKnowledgeBase {
|
||||
t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeKnowledgeBase)
|
||||
}
|
||||
if n.ResourceID != "kb1" {
|
||||
t.Errorf("resource_id: got %q, want %q", n.ResourceID, "kb1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyKBReady_NilService(t *testing.T) {
|
||||
// Should not panic
|
||||
NotifyKBReady(nil, "user1", "kb1", "My KB", 10)
|
||||
}
|
||||
|
||||
// ── NotifyKBError ─────────────────────────
|
||||
|
||||
func TestNotifyKBError(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyKBError(svc, "user1", "kb2", "Bad KB", "chunk failed")
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.Type != models.NotifTypeKBError {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBError)
|
||||
}
|
||||
if n.Body != "chunk failed" {
|
||||
t.Errorf("body: got %q, want %q", n.Body, "chunk failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── NotifyGroupMemberAdded ────────────────
|
||||
|
||||
func TestNotifyGroupMemberAdded(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyGroupMemberAdded(svc, "user1", "grp1", "Engineering")
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.Type != models.NotifTypeGrantChanged {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeGrantChanged)
|
||||
}
|
||||
if n.ResourceType != models.ResourceTypeGroup {
|
||||
t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeGroup)
|
||||
}
|
||||
if n.ResourceID != "grp1" {
|
||||
t.Errorf("resource_id: got %q, want %q", n.ResourceID, "grp1")
|
||||
}
|
||||
}
|
||||
|
||||
// ── NotifyGroupMemberRemoved ──────────────
|
||||
|
||||
func TestNotifyGroupMemberRemoved(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyGroupMemberRemoved(svc, "user1", "grp1", "Engineering")
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.Type != models.NotifTypeGrantChanged {
|
||||
t.Errorf("type: got %q", n.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ── NotifyMemoryExtracted ─────────────────
|
||||
|
||||
func TestNotifyMemoryExtracted(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyMemoryExtracted(svc, "user1", "chan1", 5)
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.Type != models.NotifTypeMemoryExtracted {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeMemoryExtracted)
|
||||
}
|
||||
if n.Title != "5 new memories extracted" {
|
||||
t.Errorf("title: got %q", n.Title)
|
||||
}
|
||||
if n.ResourceType != models.ResourceTypeChannel {
|
||||
t.Errorf("resource_type: got %q", n.ResourceType)
|
||||
}
|
||||
if n.ResourceID != "chan1" {
|
||||
t.Errorf("resource_id: got %q", n.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyMemoryExtracted_Singular(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyMemoryExtracted(svc, "user1", "chan1", 1)
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
if ms.items[0].Title != "1 new memory extracted" {
|
||||
t.Errorf("singular title: got %q", ms.items[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyMemoryExtracted_NilService(t *testing.T) {
|
||||
NotifyMemoryExtracted(nil, "user1", "chan1", 3) // should not panic
|
||||
}
|
||||
|
||||
// ── NotifyUserMentioned ───────────────────
|
||||
|
||||
func TestNotifyUserMentioned(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyUserMentioned(svc, "mentioned-user", "chan1", "from-user", "hey @you check this")
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.UserID != "mentioned-user" {
|
||||
t.Errorf("user_id: got %q", n.UserID)
|
||||
}
|
||||
if n.Type != models.NotifTypeUserMentioned {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeUserMentioned)
|
||||
}
|
||||
if n.Body != "hey @you check this" {
|
||||
t.Errorf("body: got %q", n.Body)
|
||||
}
|
||||
if n.ResourceType != models.ResourceTypeChannel {
|
||||
t.Errorf("resource_type: got %q", n.ResourceType)
|
||||
}
|
||||
if n.ResourceID != "chan1" {
|
||||
t.Errorf("resource_id: got %q", n.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyUserMentioned_NilService(t *testing.T) {
|
||||
NotifyUserMentioned(nil, "u1", "c1", "u2", "hello") // should not panic
|
||||
}
|
||||
|
||||
// ── NotifyWorkflowClaimed ─────────────────
|
||||
|
||||
func TestNotifyWorkflowClaimed(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
NotifyWorkflowClaimed(svc, "claimer1", "assign1", "chan1")
|
||||
|
||||
if len(ms.items) != 1 {
|
||||
t.Fatalf("expected 1 notification, got %d", len(ms.items))
|
||||
}
|
||||
n := ms.items[0]
|
||||
if n.UserID != "claimer1" {
|
||||
t.Errorf("user_id: got %q", n.UserID)
|
||||
}
|
||||
if n.Type != models.NotifTypeWorkflowClaimed {
|
||||
t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeWorkflowClaimed)
|
||||
}
|
||||
if n.ResourceType != models.ResourceTypeChannel {
|
||||
t.Errorf("resource_type: got %q", n.ResourceType)
|
||||
}
|
||||
if n.ResourceID != "chan1" {
|
||||
t.Errorf("resource_id: got %q", n.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyWorkflowClaimed_NilService(t *testing.T) {
|
||||
NotifyWorkflowClaimed(nil, "u1", "a1", "c1") // should not panic
|
||||
}
|
||||
|
||||
// ── NotifyMany ────────────────────────────
|
||||
|
||||
func TestNotifyMany(t *testing.T) {
|
||||
svc, ms := newTestService()
|
||||
|
||||
template := models.Notification{
|
||||
Type: "test.broadcast",
|
||||
Title: "Hello everyone",
|
||||
}
|
||||
|
||||
svc.NotifyMany(context.Background(), []string{"u1", "u2", "u3"}, template)
|
||||
|
||||
if len(ms.items) != 3 {
|
||||
t.Fatalf("expected 3 notifications, got %d", len(ms.items))
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, n := range ms.items {
|
||||
seen[n.UserID] = true
|
||||
if n.Type != "test.broadcast" {
|
||||
t.Errorf("type: got %q", n.Type)
|
||||
}
|
||||
}
|
||||
for _, uid := range []string{"u1", "u2", "u3"} {
|
||||
if !seen[uid] {
|
||||
t.Errorf("missing notification for %s", uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email subject lines for new types ─────
|
||||
|
||||
func TestSubjectForNotification_NewTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
notifType string
|
||||
title string
|
||||
wantPfx string
|
||||
}{
|
||||
{"memory.extracted", "3 new memories", "[Test] "},
|
||||
{"user.mentioned", "You were mentioned", "[Test] "},
|
||||
{"workflow.claimed", "Assignment claimed", "[Test] "},
|
||||
{"task.completed", "Task done", "[Test] "},
|
||||
{"task.budget_exceeded", "Budget hit", "[Test] "},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
n := &models.Notification{Type: tt.notifType, Title: tt.title}
|
||||
got := SubjectForNotification(n, "Test")
|
||||
if got == "" {
|
||||
t.Errorf("SubjectForNotification(%q) returned empty", tt.notifType)
|
||||
}
|
||||
// All should at least have the instance prefix
|
||||
if len(got) < len(tt.wantPfx) {
|
||||
t.Errorf("SubjectForNotification(%q) too short: %q", tt.notifType, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user