From ca79b53e92bcc6935b79cf2d1a5af41dc0204873 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:17:18 +0100 Subject: [PATCH 01/16] add notification preferences store with memory and postgres backends --- chainhook/notification-preferences.js | 240 ++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 chainhook/notification-preferences.js diff --git a/chainhook/notification-preferences.js b/chainhook/notification-preferences.js new file mode 100644 index 00000000..f7705499 --- /dev/null +++ b/chainhook/notification-preferences.js @@ -0,0 +1,240 @@ +import { isValidStacksAddress } from "./validation.js"; + +export const NOTIFICATION_CHANNELS = { + IN_APP: "in_app", + EMAIL: "email", +}; + +export const NOTIFICATION_EVENT_TYPES = { + TIP_RECEIVED: "tip_received", + TIP_SENT: "tip_sent", + SCHEDULED_TIP_EXECUTED: "scheduled_tip_executed", + SCHEDULED_TIP_FAILED: "scheduled_tip_failed", + REFUND_REQUESTED: "refund_requested", + REFUND_RESOLVED: "refund_resolved", +}; + +export const DEFAULT_PREFERENCES = { + channels: { + [NOTIFICATION_CHANNELS.IN_APP]: true, + [NOTIFICATION_CHANNELS.EMAIL]: false, + }, + events: { + [NOTIFICATION_EVENT_TYPES.TIP_RECEIVED]: true, + [NOTIFICATION_EVENT_TYPES.TIP_SENT]: false, + [NOTIFICATION_EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: true, + [NOTIFICATION_EVENT_TYPES.SCHEDULED_TIP_FAILED]: true, + [NOTIFICATION_EVENT_TYPES.REFUND_REQUESTED]: true, + [NOTIFICATION_EVENT_TYPES.REFUND_RESOLVED]: true, + }, + email: null, +}; + +export function validatePreferencesPayload(body) { + if (!body || typeof body !== "object") { + return { valid: false, error: "request body must be an object" }; + } + + if (body.channels !== undefined) { + if (typeof body.channels !== "object" || Array.isArray(body.channels)) { + return { valid: false, error: "channels must be an object" }; + } + for (const [key, value] of Object.entries(body.channels)) { + if (!Object.values(NOTIFICATION_CHANNELS).includes(key)) { + return { valid: false, error: `unknown channel: ${key}` }; + } + if (typeof value !== "boolean") { + return { valid: false, error: `channel value for '${key}' must be a boolean` }; + } + } + } + + if (body.events !== undefined) { + if (typeof body.events !== "object" || Array.isArray(body.events)) { + return { valid: false, error: "events must be an object" }; + } + for (const [key, value] of Object.entries(body.events)) { + if (!Object.values(NOTIFICATION_EVENT_TYPES).includes(key)) { + return { valid: false, error: `unknown event type: ${key}` }; + } + if (typeof value !== "boolean") { + return { valid: false, error: `event value for '${key}' must be a boolean` }; + } + } + } + + if (body.email !== undefined && body.email !== null) { + if (typeof body.email !== "string") { + return { valid: false, error: "email must be a string or null" }; + } + const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRe.test(body.email)) { + return { valid: false, error: "email must be a valid email address" }; + } + if (body.email.length > 254) { + return { valid: false, error: "email must be 254 characters or fewer" }; + } + } + + return { valid: true }; +} + +export function mergePreferences(existing, updates) { + const merged = { + channels: { ...existing.channels }, + events: { ...existing.events }, + email: existing.email, + }; + + if (updates.channels) { + merged.channels = { ...merged.channels, ...updates.channels }; + } + if (updates.events) { + merged.events = { ...merged.events, ...updates.events }; + } + if ("email" in updates) { + merged.email = updates.email; + } + + return merged; +} + +export class MemoryNotificationPreferencesStore { + constructor() { + this._store = new Map(); + } + + async init() { + return this; + } + + async getPreferences(address) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + return this._store.get(address) || { ...DEFAULT_PREFERENCES, address }; + } + + async upsertPreferences(address, preferences) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + const existing = this._store.get(address) || { ...DEFAULT_PREFERENCES }; + const merged = mergePreferences(existing, preferences); + const record = { + ...merged, + address, + updatedAt: new Date().toISOString(), + }; + this._store.set(address, record); + return record; + } + + async deletePreferences(address) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + const existed = this._store.has(address); + this._store.delete(address); + return { deleted: existed }; + } + + async close() {} +} + +export class PostgresNotificationPreferencesStore { + constructor(pool, retryOptions = {}) { + this._pool = pool; + this._retryOptions = retryOptions; + this._ready = null; + } + + async init() { + if (!this._ready) { + this._ready = this._initialize(); + } + return this._ready; + } + + async _initialize() { + await this._pool.query(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + address TEXT PRIMARY KEY, + channels JSONB NOT NULL DEFAULT '{}', + events JSONB NOT NULL DEFAULT '{}', + email TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + await this._pool.query( + "CREATE INDEX IF NOT EXISTS notification_preferences_address_idx ON notification_preferences (address);" + ); + } + + async getPreferences(address) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + await this.init(); + const result = await this._pool.query( + "SELECT * FROM notification_preferences WHERE address = $1", + [address] + ); + if (!result.rows[0]) { + return { ...DEFAULT_PREFERENCES, address }; + } + return this._rowToPreferences(result.rows[0]); + } + + async upsertPreferences(address, preferences) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + await this.init(); + + const existing = await this.getPreferences(address); + const merged = mergePreferences(existing, preferences); + + const result = await this._pool.query( + `INSERT INTO notification_preferences (address, channels, events, email, updated_at) + VALUES ($1, $2::jsonb, $3::jsonb, $4, NOW()) + ON CONFLICT (address) DO UPDATE SET + channels = $2::jsonb, + events = $3::jsonb, + email = $4, + updated_at = NOW() + RETURNING *`, + [ + address, + JSON.stringify(merged.channels), + JSON.stringify(merged.events), + merged.email || null, + ] + ); + return this._rowToPreferences(result.rows[0]); + } + + async deletePreferences(address) { + if (!isValidStacksAddress(address)) { + throw new Error("invalid address"); + } + await this.init(); + const result = await this._pool.query( + "DELETE FROM notification_preferences WHERE address = $1", + [address] + ); + return { deleted: result.rowCount > 0 }; + } + + _rowToPreferences(row) { + return { + address: row.address, + channels: row.channels || { ...DEFAULT_PREFERENCES.channels }, + events: row.events || { ...DEFAULT_PREFERENCES.events }, + email: row.email || null, + updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null, + }; + } + + async close() {} +} From 33f7e5217fa84f95bd462dbd188370cb00028c94 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:17:40 +0100 Subject: [PATCH 02/16] add migration for notification_preferences table --- .../002_add_notification_preferences.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 chainhook/migrations/002_add_notification_preferences.sql diff --git a/chainhook/migrations/002_add_notification_preferences.sql b/chainhook/migrations/002_add_notification_preferences.sql new file mode 100644 index 00000000..83ba7a95 --- /dev/null +++ b/chainhook/migrations/002_add_notification_preferences.sql @@ -0,0 +1,14 @@ +-- Migration: Add notification preferences table +-- Issue: #399 +-- Description: Stores per-address notification channel and event-type preferences + +CREATE TABLE IF NOT EXISTS notification_preferences ( + address TEXT PRIMARY KEY, + channels JSONB NOT NULL DEFAULT '{"in_app": true, "email": false}', + events JSONB NOT NULL DEFAULT '{"tip_received": true, "tip_sent": false, "scheduled_tip_executed": true, "scheduled_tip_failed": true, "refund_requested": true, "refund_resolved": true}', + email TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS notification_preferences_address_idx + ON notification_preferences (address); From 8ac44ab73bde049f8b31baa8d6144b63666cdb02 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:32:54 +0100 Subject: [PATCH 03/16] wire notification preferences store and REST routes into chainhook server --- chainhook/server.js | 123 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/chainhook/server.js b/chainhook/server.js index c316ad61..11d9cc80 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -15,6 +15,14 @@ import { BadRequestError, PayloadTooLargeError, RateLimitError, UnauthorizedErro import { ScheduledTip, validateScheduledTipParams, SCHEDULED_TIP_STATUSES } from "./scheduler.js"; import { wsManager } from "./websocket.js"; import { REFUND_STATUSES, REFUND_WINDOW_MS } from "./storage.js"; +import { + MemoryNotificationPreferencesStore, + PostgresNotificationPreferencesStore, + validatePreferencesPayload, + DEFAULT_PREFERENCES, + NOTIFICATION_CHANNELS, + NOTIFICATION_EVENT_TYPES, +} from "./notification-preferences.js"; const PORT = process.env.PORT || 3100; const AUTH_TOKEN = process.env.CHAINHOOK_AUTH_TOKEN || ""; @@ -40,6 +48,7 @@ const addressRateLimiter = new AddressRateLimiter( let eventStore = null; let scheduledTipStore = null; let refundStore = null; +let notificationPreferencesStore = null; /** * Get the rate limiter instance for runtime configuration. @@ -98,6 +107,24 @@ async function getRefundStore() { return refundStore; } +async function getNotificationPreferencesStore() { + if (!notificationPreferencesStore) { + if (STORAGE_MODE === "memory" || process.env.NODE_ENV === "test") { + notificationPreferencesStore = new MemoryNotificationPreferencesStore(); + await notificationPreferencesStore.init(); + } else { + if (!DATABASE_URL) { + throw new Error("DATABASE_URL is required when CHAINHOOK_STORAGE=postgres"); + } + const { Pool } = await import("pg"); + const pool = new Pool({ connectionString: DATABASE_URL }); + notificationPreferencesStore = new PostgresNotificationPreferencesStore(pool); + await notificationPreferencesStore.init(); + } + } + return notificationPreferencesStore; +} + /** * Read and parse a JSON request body from a readable stream. * Rejects if the body exceeds MAX_BODY_SIZE or contains invalid JSON. @@ -303,7 +330,7 @@ function parseTipEvent(event) { }; } -export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, getAddressRateLimiter, wsManager, getRefundStore }; +export { parseBody, extractEvents, parseTipEvent, sendJson, getEventStore, checkShutdownState, validatePayloadStructure, validateBlock, validateTransaction, getRateLimiter, getAddressRateLimiter, wsManager, getRefundStore, getNotificationPreferencesStore }; function checkShutdownState(res, requestId) { if (isShuttingDown()) { @@ -1219,6 +1246,97 @@ const server = http.createServer(async (req, res) => { }); } + // GET /api/notifications/preferences/:address -- get preferences for an address + if (req.method === "GET" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) { + try { + const address = path.split("/api/notifications/preferences/")[1]; + + if (!address || address.trim() === "") { + return sendError(res, new BadRequestError("address parameter is required"), requestId, { path }); + } + if (!isValidStacksAddress(address)) { + return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address }); + } + + const store = await getNotificationPreferencesStore(); + const preferences = await store.getPreferences(address); + return sendJson(res, 200, { preferences }); + } catch (err) { + return sendError(res, err, requestId, { path }); + } + } + + // PUT /api/notifications/preferences/:address -- create or update preferences + if (req.method === "PUT" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) { + const startTime = Date.now(); + try { + const address = path.split("/api/notifications/preferences/")[1]; + + if (!address || address.trim() === "") { + return sendError(res, new BadRequestError("address parameter is required"), requestId, { path }); + } + if (!isValidStacksAddress(address)) { + return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address }); + } + + const body = await parseBody(req); + const validation = validatePreferencesPayload(body); + if (!validation.valid) { + return sendError(res, new BadRequestError(validation.error), requestId, { path }); + } + + const store = await getNotificationPreferencesStore(); + const preferences = await store.upsertPreferences(address, body); + + const processingMs = Date.now() - startTime; + logger.info("Notification preferences updated", { + address, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { ok: true, preferences }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + + // DELETE /api/notifications/preferences/:address -- reset preferences to defaults + if (req.method === "DELETE" && path.match(/^\/api\/notifications\/preferences\/[^/]+$/)) { + const startTime = Date.now(); + try { + const address = path.split("/api/notifications/preferences/")[1]; + + if (!address || address.trim() === "") { + return sendError(res, new BadRequestError("address parameter is required"), requestId, { path }); + } + if (!isValidStacksAddress(address)) { + return sendError(res, new BadRequestError("invalid address format"), requestId, { path, address }); + } + + const store = await getNotificationPreferencesStore(); + const result = await store.deletePreferences(address); + + const processingMs = Date.now() - startTime; + logger.info("Notification preferences reset", { + address, + deleted: result.deleted, + request_id: requestId, + processing_ms: processingMs, + }); + + metrics.recordRequest(true); + return sendJson(res, 200, { ok: true, deleted: result.deleted }); + } catch (err) { + const processingMs = Date.now() - startTime; + metrics.recordRequest(false); + return sendError(res, err, requestId, { path, processing_ms: processingMs }); + } + } + sendJson(res, 404, { error: "not found", path: path }); }); @@ -1247,6 +1365,9 @@ if (isMain) { clearInterval(cleanupInterval); wsManager.close(); await store.close(); + if (notificationPreferencesStore) { + await notificationPreferencesStore.close(); + } logger.info("Shutdown initiated"); }); From e41832fdc7ba215a809d5643cf4ec12d27236b32 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:33:46 +0100 Subject: [PATCH 04/16] add backend tests for notification preferences store and validation --- chainhook/notification-preferences.test.js | 215 +++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 chainhook/notification-preferences.test.js diff --git a/chainhook/notification-preferences.test.js b/chainhook/notification-preferences.test.js new file mode 100644 index 00000000..60309664 --- /dev/null +++ b/chainhook/notification-preferences.test.js @@ -0,0 +1,215 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + MemoryNotificationPreferencesStore, + validatePreferencesPayload, + mergePreferences, + DEFAULT_PREFERENCES, + NOTIFICATION_CHANNELS, + NOTIFICATION_EVENT_TYPES, +} from "./notification-preferences.js"; + +const VALID_ADDRESS = "SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE"; +const OTHER_ADDRESS = "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T"; + +describe("validatePreferencesPayload", () => { + it("accepts an empty object", () => { + const result = validatePreferencesPayload({}); + assert.equal(result.valid, true); + }); + + it("accepts valid channel toggles", () => { + const result = validatePreferencesPayload({ + channels: { in_app: false, email: true }, + }); + assert.equal(result.valid, true); + }); + + it("accepts valid event toggles", () => { + const result = validatePreferencesPayload({ + events: { tip_received: false, tip_sent: true }, + }); + assert.equal(result.valid, true); + }); + + it("accepts a valid email address", () => { + const result = validatePreferencesPayload({ email: "user@example.com" }); + assert.equal(result.valid, true); + }); + + it("accepts null email to clear the address", () => { + const result = validatePreferencesPayload({ email: null }); + assert.equal(result.valid, true); + }); + + it("rejects a non-object body", () => { + const result = validatePreferencesPayload("bad"); + assert.equal(result.valid, false); + assert.match(result.error, /object/); + }); + + it("rejects channels as an array", () => { + const result = validatePreferencesPayload({ channels: [] }); + assert.equal(result.valid, false); + assert.match(result.error, /channels/); + }); + + it("rejects an unknown channel key", () => { + const result = validatePreferencesPayload({ channels: { sms: true } }); + assert.equal(result.valid, false); + assert.match(result.error, /unknown channel/); + }); + + it("rejects a non-boolean channel value", () => { + const result = validatePreferencesPayload({ channels: { in_app: "yes" } }); + assert.equal(result.valid, false); + assert.match(result.error, /boolean/); + }); + + it("rejects an unknown event type", () => { + const result = validatePreferencesPayload({ events: { unknown_event: true } }); + assert.equal(result.valid, false); + assert.match(result.error, /unknown event type/); + }); + + it("rejects a non-boolean event value", () => { + const result = validatePreferencesPayload({ events: { tip_received: 1 } }); + assert.equal(result.valid, false); + assert.match(result.error, /boolean/); + }); + + it("rejects a malformed email", () => { + const result = validatePreferencesPayload({ email: "not-an-email" }); + assert.equal(result.valid, false); + assert.match(result.error, /email/); + }); + + it("rejects an email exceeding 254 characters", () => { + const long = "a".repeat(250) + "@b.co"; + const result = validatePreferencesPayload({ email: long }); + assert.equal(result.valid, false); + assert.match(result.error, /254/); + }); +}); + +describe("mergePreferences", () => { + it("returns existing preferences unchanged when updates are empty", () => { + const existing = { ...DEFAULT_PREFERENCES }; + const merged = mergePreferences(existing, {}); + assert.deepEqual(merged.channels, existing.channels); + assert.deepEqual(merged.events, existing.events); + assert.equal(merged.email, existing.email); + }); + + it("merges channel updates without overwriting untouched channels", () => { + const existing = { ...DEFAULT_PREFERENCES }; + const merged = mergePreferences(existing, { channels: { email: true } }); + assert.equal(merged.channels.email, true); + assert.equal(merged.channels.in_app, DEFAULT_PREFERENCES.channels.in_app); + }); + + it("merges event updates without overwriting untouched events", () => { + const existing = { ...DEFAULT_PREFERENCES }; + const merged = mergePreferences(existing, { events: { tip_sent: true } }); + assert.equal(merged.events.tip_sent, true); + assert.equal(merged.events.tip_received, DEFAULT_PREFERENCES.events.tip_received); + }); + + it("updates email when provided", () => { + const existing = { ...DEFAULT_PREFERENCES }; + const merged = mergePreferences(existing, { email: "test@example.com" }); + assert.equal(merged.email, "test@example.com"); + }); + + it("clears email when null is provided", () => { + const existing = { ...DEFAULT_PREFERENCES, email: "old@example.com" }; + const merged = mergePreferences(existing, { email: null }); + assert.equal(merged.email, null); + }); + + it("does not mutate the existing object", () => { + const existing = { ...DEFAULT_PREFERENCES }; + mergePreferences(existing, { channels: { email: true } }); + assert.equal(existing.channels.email, DEFAULT_PREFERENCES.channels.email); + }); +}); + +describe("MemoryNotificationPreferencesStore", () => { + let store; + + before(async () => { + store = new MemoryNotificationPreferencesStore(); + await store.init(); + }); + + after(async () => { + await store.close(); + }); + + it("returns default preferences for an unknown address", async () => { + const prefs = await store.getPreferences(VALID_ADDRESS); + assert.equal(prefs.address, VALID_ADDRESS); + assert.deepEqual(prefs.channels, DEFAULT_PREFERENCES.channels); + assert.deepEqual(prefs.events, DEFAULT_PREFERENCES.events); + assert.equal(prefs.email, null); + }); + + it("throws for an invalid address on get", async () => { + await assert.rejects(() => store.getPreferences("bad-address"), /invalid address/); + }); + + it("throws for an invalid address on upsert", async () => { + await assert.rejects(() => store.upsertPreferences("bad", {}), /invalid address/); + }); + + it("throws for an invalid address on delete", async () => { + await assert.rejects(() => store.deletePreferences("bad"), /invalid address/); + }); + + it("persists preferences after upsert", async () => { + await store.upsertPreferences(VALID_ADDRESS, { + channels: { email: true }, + email: "user@example.com", + }); + const prefs = await store.getPreferences(VALID_ADDRESS); + assert.equal(prefs.channels.email, true); + assert.equal(prefs.email, "user@example.com"); + }); + + it("merges partial updates without losing existing values", async () => { + await store.upsertPreferences(VALID_ADDRESS, { channels: { in_app: false } }); + const prefs = await store.getPreferences(VALID_ADDRESS); + assert.equal(prefs.channels.in_app, false); + assert.equal(prefs.channels.email, true); + }); + + it("stores preferences independently per address", async () => { + await store.upsertPreferences(OTHER_ADDRESS, { channels: { email: true } }); + const a = await store.getPreferences(VALID_ADDRESS); + const b = await store.getPreferences(OTHER_ADDRESS); + assert.equal(a.channels.in_app, false); + assert.equal(b.channels.in_app, DEFAULT_PREFERENCES.channels.in_app); + }); + + it("deletes preferences and returns deleted: true", async () => { + await store.upsertPreferences(VALID_ADDRESS, { email: "del@example.com" }); + const result = await store.deletePreferences(VALID_ADDRESS); + assert.equal(result.deleted, true); + const prefs = await store.getPreferences(VALID_ADDRESS); + assert.equal(prefs.email, null); + }); + + it("returns deleted: false when address had no stored preferences", async () => { + const fresh = "SP2DEMOADDRESS0000000000000000000000ABCDEF"; + const result = await store.deletePreferences(fresh); + assert.equal(result.deleted, false); + }); + + it("upsert returns the merged record with updatedAt", async () => { + const record = await store.upsertPreferences(OTHER_ADDRESS, { + events: { tip_sent: true }, + }); + assert.ok(record.updatedAt); + assert.equal(record.events.tip_sent, true); + }); +}); From b7be73dfcd453001e48568b7e9bd7299c7b21305 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:59:03 +0100 Subject: [PATCH 05/16] add notification preferences API route tests, fix store init for no-db environments --- .../notification-preferences-api.test.js | 205 ++++++++++++++++++ chainhook/server.js | 5 +- 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 chainhook/notification-preferences-api.test.js diff --git a/chainhook/notification-preferences-api.test.js b/chainhook/notification-preferences-api.test.js new file mode 100644 index 00000000..7511e749 --- /dev/null +++ b/chainhook/notification-preferences-api.test.js @@ -0,0 +1,205 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; + +process.env.NODE_ENV = "test"; +process.env.CHAINHOOK_AUTH_TOKEN = ""; +process.env.METRICS_AUTH_TOKEN = ""; + +const { server } = await import("./server.js"); + +const VALID_ADDRESS = "SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE"; +const OTHER_ADDRESS = "SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T"; + +function request(method, path, body) { + return new Promise((resolve, reject) => { + const payload = body !== undefined ? JSON.stringify(body) : ""; + const req = http.request( + { + hostname: "127.0.0.1", + port: server.address().port, + path, + method, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, body: data }); + } + }); + } + ); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +before(() => { + return new Promise((resolve) => { + if (server.listening) return resolve(); + server.listen(0, "127.0.0.1", resolve); + }); +}); + +after(() => { + return new Promise((resolve) => server.close(resolve)); +}); + +describe("GET /api/notifications/preferences/:address", () => { + it("returns default preferences for an address with no stored data", async () => { + const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`); + assert.equal(res.status, 200); + assert.ok(res.body.preferences); + assert.equal(res.body.preferences.address, VALID_ADDRESS); + assert.equal(typeof res.body.preferences.channels, "object"); + assert.equal(typeof res.body.preferences.events, "object"); + assert.equal(res.body.preferences.channels.in_app, true); + assert.equal(res.body.preferences.channels.email, false); + assert.equal(res.body.preferences.events.tip_received, true); + assert.equal(res.body.preferences.events.tip_sent, false); + }); + + it("returns 400 for an invalid address format", async () => { + const res = await request("GET", "/api/notifications/preferences/not-an-address"); + assert.equal(res.status, 400); + assert.match(res.body.message, /invalid address/i); + }); + + it("returns 404 for an unrelated route", async () => { + const res = await request("GET", "/api/notifications/unknown"); + assert.equal(res.status, 404); + }); +}); + +describe("PUT /api/notifications/preferences/:address", () => { + it("creates preferences and returns 200 with the merged record", async () => { + const res = await request("PUT", `/api/notifications/preferences/${OTHER_ADDRESS}`, { + channels: { email: true }, + email: "user@example.com", + }); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.preferences.channels.email, true); + assert.equal(res.body.preferences.email, "user@example.com"); + }); + + it("merges partial updates without overwriting untouched fields", async () => { + await request("PUT", `/api/notifications/preferences/${OTHER_ADDRESS}`, { + channels: { in_app: false }, + }); + const res = await request("GET", `/api/notifications/preferences/${OTHER_ADDRESS}`); + assert.equal(res.status, 200); + assert.equal(res.body.preferences.channels.in_app, false); + assert.equal(res.body.preferences.channels.email, true); + }); + + it("returns 400 for an invalid address", async () => { + const res = await request("PUT", "/api/notifications/preferences/bad-addr", { + channels: { in_app: false }, + }); + assert.equal(res.status, 400); + }); + + it("returns 400 for an unknown channel key", async () => { + const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + channels: { sms: true }, + }); + assert.equal(res.status, 400); + assert.match(res.body.message, /unknown channel/i); + }); + + it("returns 400 for an unknown event type", async () => { + const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + events: { mystery_event: true }, + }); + assert.equal(res.status, 400); + assert.match(res.body.message, /unknown event type/i); + }); + + it("returns 400 for a malformed email", async () => { + const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + email: "not-valid", + }); + assert.equal(res.status, 400); + assert.match(res.body.message, /email/i); + }); + + it("accepts null email to clear the stored address", async () => { + await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + email: "clear@example.com", + }); + const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + email: null, + }); + assert.equal(res.status, 200); + assert.equal(res.body.preferences.email, null); + }); + + it("accepts all valid event types toggled", async () => { + const res = await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + events: { + tip_received: false, + tip_sent: true, + scheduled_tip_executed: false, + scheduled_tip_failed: false, + refund_requested: false, + refund_resolved: false, + }, + }); + assert.equal(res.status, 200); + assert.equal(res.body.preferences.events.tip_sent, true); + assert.equal(res.body.preferences.events.tip_received, false); + }); + + it("persists preferences so a subsequent GET reflects the update", async () => { + await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + channels: { email: true }, + }); + const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`); + assert.equal(res.status, 200); + assert.equal(res.body.preferences.channels.email, true); + }); +}); + +describe("DELETE /api/notifications/preferences/:address", () => { + it("deletes stored preferences and returns deleted: true", async () => { + await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + email: "todelete@example.com", + }); + const res = await request("DELETE", `/api/notifications/preferences/${VALID_ADDRESS}`); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.deleted, true); + }); + + it("returns deleted: false when no preferences were stored for the address", async () => { + const fresh = "SP2DEMOADDRESS0000000000000000000000ABCDEF"; + const res = await request("DELETE", `/api/notifications/preferences/${fresh}`); + assert.equal(res.status, 200); + assert.equal(res.body.deleted, false); + }); + + it("returns default preferences after deletion on a subsequent GET", async () => { + await request("PUT", `/api/notifications/preferences/${VALID_ADDRESS}`, { + channels: { email: true }, + }); + await request("DELETE", `/api/notifications/preferences/${VALID_ADDRESS}`); + const res = await request("GET", `/api/notifications/preferences/${VALID_ADDRESS}`); + assert.equal(res.status, 200); + assert.equal(res.body.preferences.channels.email, false); + }); + + it("returns 400 for an invalid address", async () => { + const res = await request("DELETE", "/api/notifications/preferences/bad"); + assert.equal(res.status, 400); + }); +}); diff --git a/chainhook/server.js b/chainhook/server.js index 11d9cc80..da3d8618 100644 --- a/chainhook/server.js +++ b/chainhook/server.js @@ -109,13 +109,10 @@ async function getRefundStore() { async function getNotificationPreferencesStore() { if (!notificationPreferencesStore) { - if (STORAGE_MODE === "memory" || process.env.NODE_ENV === "test") { + if (STORAGE_MODE === "memory" || !DATABASE_URL) { notificationPreferencesStore = new MemoryNotificationPreferencesStore(); await notificationPreferencesStore.init(); } else { - if (!DATABASE_URL) { - throw new Error("DATABASE_URL is required when CHAINHOOK_STORAGE=postgres"); - } const { Pool } = await import("pg"); const pool = new Pool({ connectionString: DATABASE_URL }); notificationPreferencesStore = new PostgresNotificationPreferencesStore(pool); From ed17ad058de3a662a1b40283fcde250dc1bbf145 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:59:29 +0100 Subject: [PATCH 06/16] add frontend notification preferences storage library --- frontend/src/lib/notificationPreferences.js | 93 +++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 frontend/src/lib/notificationPreferences.js diff --git a/frontend/src/lib/notificationPreferences.js b/frontend/src/lib/notificationPreferences.js new file mode 100644 index 00000000..39ce3ea4 --- /dev/null +++ b/frontend/src/lib/notificationPreferences.js @@ -0,0 +1,93 @@ +const STORAGE_KEY_PREFIX = 'tipstream_notif_prefs_'; + +export const CHANNELS = { + IN_APP: 'in_app', + EMAIL: 'email', +}; + +export const EVENT_TYPES = { + TIP_RECEIVED: 'tip_received', + TIP_SENT: 'tip_sent', + SCHEDULED_TIP_EXECUTED: 'scheduled_tip_executed', + SCHEDULED_TIP_FAILED: 'scheduled_tip_failed', + REFUND_REQUESTED: 'refund_requested', + REFUND_RESOLVED: 'refund_resolved', +}; + +export const DEFAULT_PREFERENCES = { + channels: { + [CHANNELS.IN_APP]: true, + [CHANNELS.EMAIL]: false, + }, + events: { + [EVENT_TYPES.TIP_RECEIVED]: true, + [EVENT_TYPES.TIP_SENT]: false, + [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: true, + [EVENT_TYPES.SCHEDULED_TIP_FAILED]: true, + [EVENT_TYPES.REFUND_REQUESTED]: true, + [EVENT_TYPES.REFUND_RESOLVED]: true, + }, + email: null, +}; + +export const EVENT_LABELS = { + [EVENT_TYPES.TIP_RECEIVED]: 'Tip received', + [EVENT_TYPES.TIP_SENT]: 'Tip sent confirmation', + [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: 'Scheduled tip executed', + [EVENT_TYPES.SCHEDULED_TIP_FAILED]: 'Scheduled tip failed', + [EVENT_TYPES.REFUND_REQUESTED]: 'Refund requested', + [EVENT_TYPES.REFUND_RESOLVED]: 'Refund resolved', +}; + +export const CHANNEL_LABELS = { + [CHANNELS.IN_APP]: 'In-app notifications', + [CHANNELS.EMAIL]: 'Email notifications', +}; + +function storageKey(address) { + return `${STORAGE_KEY_PREFIX}${address}`; +} + +export function loadPreferences(address) { + if (!address) return { ...DEFAULT_PREFERENCES }; + try { + const raw = localStorage.getItem(storageKey(address)); + if (!raw) return { ...DEFAULT_PREFERENCES }; + const parsed = JSON.parse(raw); + return { + channels: { ...DEFAULT_PREFERENCES.channels, ...(parsed.channels || {}) }, + events: { ...DEFAULT_PREFERENCES.events, ...(parsed.events || {}) }, + email: parsed.email || null, + }; + } catch { + return { ...DEFAULT_PREFERENCES }; + } +} + +export function savePreferences(address, preferences) { + if (!address) return; + try { + localStorage.setItem(storageKey(address), JSON.stringify(preferences)); + } catch { + // localStorage may be unavailable + } +} + +export function clearPreferences(address) { + if (!address) return; + try { + localStorage.removeItem(storageKey(address)); + } catch { + // localStorage may be unavailable + } +} + +export function isEventEnabled(preferences, eventType) { + if (!preferences) return DEFAULT_PREFERENCES.events[eventType] ?? true; + return preferences.events?.[eventType] ?? DEFAULT_PREFERENCES.events[eventType] ?? true; +} + +export function isChannelEnabled(preferences, channel) { + if (!preferences) return DEFAULT_PREFERENCES.channels[channel] ?? false; + return preferences.channels?.[channel] ?? DEFAULT_PREFERENCES.channels[channel] ?? false; +} From 479a1559e4a7be3544ad66e6acb8406bc56d607a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 20:59:58 +0100 Subject: [PATCH 07/16] add NotificationPreferencesContext for app-wide preferences state --- .../NotificationPreferencesContext.jsx | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 frontend/src/context/NotificationPreferencesContext.jsx diff --git a/frontend/src/context/NotificationPreferencesContext.jsx b/frontend/src/context/NotificationPreferencesContext.jsx new file mode 100644 index 00000000..4524b614 --- /dev/null +++ b/frontend/src/context/NotificationPreferencesContext.jsx @@ -0,0 +1,105 @@ +import { createContext, useContext, useState, useCallback, useMemo } from 'react'; +import { + loadPreferences, + savePreferences, + clearPreferences, + DEFAULT_PREFERENCES, + isEventEnabled, + isChannelEnabled, +} from '../lib/notificationPreferences'; + +const NotificationPreferencesContext = createContext(null); +NotificationPreferencesContext.displayName = 'NotificationPreferencesContext'; + +export function NotificationPreferencesProvider({ children, userAddress }) { + const [preferences, setPreferences] = useState(() => { + return loadPreferences(userAddress); + }); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const updatePreferences = useCallback((updates) => { + setPreferences((prev) => { + const next = { + channels: { ...prev.channels, ...(updates.channels || {}) }, + events: { ...prev.events, ...(updates.events || {}) }, + email: 'email' in updates ? updates.email : prev.email, + }; + savePreferences(userAddress, next); + return next; + }); + }, [userAddress]); + + const toggleChannel = useCallback((channel, enabled) => { + updatePreferences({ channels: { [channel]: enabled } }); + }, [updatePreferences]); + + const toggleEvent = useCallback((eventType, enabled) => { + updatePreferences({ events: { [eventType]: enabled } }); + }, [updatePreferences]); + + const setEmail = useCallback((email) => { + updatePreferences({ email: email || null }); + }, [updatePreferences]); + + const resetToDefaults = useCallback(() => { + clearPreferences(userAddress); + setPreferences({ ...DEFAULT_PREFERENCES }); + setError(null); + }, [userAddress]); + + const reloadFromStorage = useCallback(() => { + setPreferences(loadPreferences(userAddress)); + }, [userAddress]); + + const checkEventEnabled = useCallback((eventType) => { + return isEventEnabled(preferences, eventType); + }, [preferences]); + + const checkChannelEnabled = useCallback((channel) => { + return isChannelEnabled(preferences, channel); + }, [preferences]); + + const value = useMemo(() => ({ + preferences, + saving, + error, + updatePreferences, + toggleChannel, + toggleEvent, + setEmail, + resetToDefaults, + reloadFromStorage, + isEventEnabled: checkEventEnabled, + isChannelEnabled: checkChannelEnabled, + }), [ + preferences, + saving, + error, + updatePreferences, + toggleChannel, + toggleEvent, + setEmail, + resetToDefaults, + reloadFromStorage, + checkEventEnabled, + checkChannelEnabled, + ]); + + return ( + + {children} + + ); +} + +export function useNotificationPreferences() { + const context = useContext(NotificationPreferencesContext); + if (!context) { + throw new Error( + 'useNotificationPreferences must be used within a NotificationPreferencesProvider' + ); + } + return context; +} From c23b1eb8d39c2dd8c53e0c15b57e3b4c7d14dff6 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 21:00:31 +0100 Subject: [PATCH 08/16] extend useNotifications to respect channel and event type preferences --- frontend/src/hooks/useNotifications.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js index 8d9ed0b3..cac41062 100644 --- a/frontend/src/hooks/useNotifications.js +++ b/frontend/src/hooks/useNotifications.js @@ -7,14 +7,19 @@ import { setLastSeenTimestamp as saveLastSeenTimestamp, migrateLegacyNotificationState } from '../lib/notificationStorage'; +import { EVENT_TYPES, isEventEnabled, isChannelEnabled, CHANNELS } from '../lib/notificationPreferences'; /** * useNotifications -- derives incoming-tip notifications from the shared * event cache in TipContext instead of polling the Stacks API independently. * + * Respects notification preferences: events and channels that are disabled + * are excluded from the returned list and unread count. + * * @param {string|null} userAddress - The current user's STX address. + * @param {object|null} preferences - Notification preferences from NotificationPreferencesContext. */ -export function useNotifications(userAddress) { +export function useNotifications(userAddress, preferences = null) { const { events, eventsLoading } = useTipContext(); const { demoEnabled, demoNotifications, markNotificationRead } = useDemoMode(); const network = NETWORK_NAME; @@ -38,6 +43,11 @@ export function useNotifications(userAddress) { /** Derive received tips from the shared event cache or demo context. */ const notifications = useMemo(() => { + const inAppEnabled = isChannelEnabled(preferences, CHANNELS.IN_APP); + const tipReceivedEnabled = isEventEnabled(preferences, EVENT_TYPES.TIP_RECEIVED); + + if (!inAppEnabled || !tipReceivedEnabled) return []; + if (demoEnabled) { return demoNotifications.map(n => ({ id: n.id, @@ -58,7 +68,7 @@ export function useNotifications(userAddress) { ...t, timestamp: t.timestamp || now - idx, })); - }, [events, userAddress, now, demoEnabled, demoNotifications]); + }, [events, userAddress, now, demoEnabled, demoNotifications, preferences]); // Derive unread count from notifications and last seen timestamp. const unreadCount = useMemo(() => { From 956ed40a9a58a936bddf50d1a611ef1d03d32048 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 21:01:19 +0100 Subject: [PATCH 09/16] add NotificationPreferences UI component with channel and event toggles --- .../components/NotificationPreferences.jsx | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 frontend/src/components/NotificationPreferences.jsx diff --git a/frontend/src/components/NotificationPreferences.jsx b/frontend/src/components/NotificationPreferences.jsx new file mode 100644 index 00000000..42594301 --- /dev/null +++ b/frontend/src/components/NotificationPreferences.jsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { Bell, Mail, RotateCcw } from 'lucide-react'; +import { useNotificationPreferences } from '../context/NotificationPreferencesContext'; +import { + CHANNELS, + EVENT_TYPES, + CHANNEL_LABELS, + EVENT_LABELS, +} from '../lib/notificationPreferences'; + +function Toggle({ id, checked, onChange, disabled }) { + return ( + + ); +} + +function PreferenceRow({ label, description, checked, onChange, id }) { + return ( +
+
+ + {description && ( +

{description}

+ )} +
+ +
+ ); +} + +const EVENT_DESCRIPTIONS = { + [EVENT_TYPES.TIP_RECEIVED]: 'When someone sends you a tip', + [EVENT_TYPES.TIP_SENT]: 'When your tip is confirmed on-chain', + [EVENT_TYPES.SCHEDULED_TIP_EXECUTED]: 'When a scheduled tip runs successfully', + [EVENT_TYPES.SCHEDULED_TIP_FAILED]: 'When a scheduled tip cannot be executed', + [EVENT_TYPES.REFUND_REQUESTED]: 'When a refund is requested on a tip', + [EVENT_TYPES.REFUND_RESOLVED]: 'When a refund request is approved or rejected', +}; + +export default function NotificationPreferences({ addToast }) { + const { + preferences, + toggleChannel, + toggleEvent, + setEmail, + resetToDefaults, + } = useNotificationPreferences(); + + const [emailInput, setEmailInput] = useState(preferences.email || ''); + const [emailError, setEmailError] = useState(''); + + const emailEnabled = preferences.channels[CHANNELS.EMAIL]; + + const handleEmailChange = (value) => { + setEmailInput(value); + if (!value) { + setEmailError(''); + return; + } + const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRe.test(value)) { + setEmailError('Enter a valid email address'); + } else if (value.length > 254) { + setEmailError('Email must be 254 characters or fewer'); + } else { + setEmailError(''); + } + }; + + const handleEmailSave = () => { + if (emailError) return; + setEmail(emailInput.trim() || null); + addToast?.('Email address saved', 'success'); + }; + + const handleEmailKeyDown = (e) => { + if (e.key === 'Enter') handleEmailSave(); + }; + + const handleReset = () => { + resetToDefaults(); + setEmailInput(''); + setEmailError(''); + addToast?.('Notification preferences reset to defaults', 'info'); + }; + + return ( +
+
+
+
+
+
+

+ Notification Preferences +

+

+ Control which events notify you and how +

+
+
+ +
+ +
+
+

+ Channels +

+
+ toggleChannel(CHANNELS.IN_APP, val)} + /> + toggleChannel(CHANNELS.EMAIL, val)} + /> +
+ + {emailEnabled && ( +
+ +
+
+
+ +
+ {emailError && ( + + )} + {preferences.email && !emailError && ( +

+ Saved: {preferences.email} +

+ )} +
+ )} +
+ +
+

+ Event types +

+
+ {Object.values(EVENT_TYPES).map((eventType) => ( + toggleEvent(eventType, val)} + /> + ))} +
+
+
+
+ ); +} From a19636fcea71b9394cf73cb22252c05c9dcff3c3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 21:02:08 +0100 Subject: [PATCH 10/16] add ROUTE_NOTIFICATION_PREFERENCES constant and route metadata --- frontend/src/config/routes.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/config/routes.js b/frontend/src/config/routes.js index 2527c1c9..b30a6d6b 100644 --- a/frontend/src/config/routes.js +++ b/frontend/src/config/routes.js @@ -109,6 +109,12 @@ export const ROUTE_TELEMETRY = '/telemetry'; */ export const ROUTE_REFUNDS = '/refunds'; +/** + * Notification preferences settings. + * @type {string} + */ +export const ROUTE_NOTIFICATION_PREFERENCES = '/notification-preferences'; + /** * The route that "/" redirects to when the user is authenticated. * Change this single value to alter the default landing page site-wide. @@ -137,6 +143,7 @@ export const ROUTE_LABELS = { [ROUTE_ADMIN]: 'Admin', [ROUTE_TELEMETRY]: 'Telemetry', [ROUTE_REFUNDS]: 'Refunds', + [ROUTE_NOTIFICATION_PREFERENCES]: 'Notifications', }; /** @@ -163,6 +170,7 @@ export const ROUTE_TITLES = { [ROUTE_ADMIN]: 'Admin Dashboard -- TipStream', [ROUTE_TELEMETRY]: 'Telemetry -- TipStream', [ROUTE_REFUNDS]: 'Refunds -- TipStream', + [ROUTE_NOTIFICATION_PREFERENCES]: 'Notification Preferences -- TipStream', }; /** @@ -257,4 +265,9 @@ export const ROUTE_META = { requiresAuth: true, adminOnly: false, }, + [ROUTE_NOTIFICATION_PREFERENCES]: { + description: 'Configure which events trigger notifications and through which channels.', + requiresAuth: true, + adminOnly: false, + }, }; From 86d92168a1ad853ca3d67f267dc0dee245705c0a Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 21:31:48 +0100 Subject: [PATCH 11/16] wire NotificationPreferencesProvider into app, add preferences route and nav item --- frontend/src/App.jsx | 23 +++++++++++++++++-- .../NotificationPreferencesContext.jsx | 2 +- frontend/src/main.jsx | 9 +++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b163b7f2..ffad4114 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,10 +21,12 @@ import { ROUTE_SEND, ROUTE_BATCH, ROUTE_TOKEN_TIP, ROUTE_SCHEDULE, ROUTE_SCHEDULED_TIPS, ROUTE_FEED, ROUTE_LEADERBOARD, ROUTE_ACTIVITY, ROUTE_PROFILE, ROUTE_ADDRESS_BOOK, ROUTE_BLOCK, ROUTE_STATS, ROUTE_ADMIN, ROUTE_TELEMETRY, ROUTE_REFUNDS, + ROUTE_NOTIFICATION_PREFERENCES, DEFAULT_AUTHENTICATED_ROUTE, ROUTE_META, } from './config/routes'; -import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw } from 'lucide-react'; +import { Zap, Radio, Trophy, User, BarChart3, Users, ShieldBan, Coins, UserCircle, Shield, Gauge, Calendar, Clock, BookUser, RotateCcw, BellCog } from 'lucide-react'; import { activateDemo, deactivateDemo } from './lib/demo-utils'; +import { useNotificationPreferences } from './context/NotificationPreferencesContext'; const AnimatedHero = lazy(() => import('./components/ui/animated-hero').then(m => ({ default: m.AnimatedHero }))); const MaintenancePage = lazy(() => import('./components/MaintenancePage')); @@ -44,6 +46,7 @@ const NotFound = lazy(() => import('./components/NotFound')); const AdminDashboard = lazy(() => import('./components/AdminDashboard')); const TelemetryDashboard = lazy(() => import('./components/TelemetryDashboard')); const RefundManager = lazy(() => import('./components/RefundManager')); +const NotificationPreferencesPage = lazy(() => import('./components/NotificationPreferences')); function App() { const [userData, setUserData] = useState(null); @@ -56,8 +59,9 @@ function App() { const { demoEnabled, toggleDemo } = useDemoMode(); const userAddress = getMainnetAddress(userData); - const { notifications, unreadCount, lastSeenTimestamp, markAllRead, loading: notificationsLoading } = useNotifications(userAddress); const { isOwner } = useAdmin(userAddress); + const { preferences: notifPreferences } = useNotificationPreferences(); + const { notifications, unreadCount, lastSeenTimestamp, markAllRead, loading: notificationsLoading } = useNotifications(userAddress, notifPreferences); usePageTitle(); @@ -170,6 +174,7 @@ function App() { { path: ROUTE_ADDRESS_BOOK, label: 'Address Book', icon: BookUser }, { path: ROUTE_BLOCK, label: 'Block', icon: ShieldBan }, { path: ROUTE_REFUNDS, label: 'Refunds', icon: RotateCcw }, + { path: ROUTE_NOTIFICATION_PREFERENCES, label: 'Notifications', icon: BellCog }, { path: ROUTE_STATS, label: 'Stats', icon: BarChart3 }, ]; @@ -402,6 +407,20 @@ function App() { ) } /> + + {/* Notification preferences */} + + ) : ( + + + + ) + } + /> {/* Root and fallback */} } /> diff --git a/frontend/src/context/NotificationPreferencesContext.jsx b/frontend/src/context/NotificationPreferencesContext.jsx index 4524b614..5ef4bf9a 100644 --- a/frontend/src/context/NotificationPreferencesContext.jsx +++ b/frontend/src/context/NotificationPreferencesContext.jsx @@ -11,7 +11,7 @@ import { const NotificationPreferencesContext = createContext(null); NotificationPreferencesContext.displayName = 'NotificationPreferencesContext'; -export function NotificationPreferencesProvider({ children, userAddress }) { +export function NotificationPreferencesProvider({ children, userAddress = null }) { const [preferences, setPreferences] = useState(() => { return loadPreferences(userAddress); }); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 1ec19f06..8d471f10 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -7,6 +7,7 @@ import ErrorBoundary from './components/ErrorBoundary.jsx' import { TipProvider } from './context/TipContext.jsx' import { ThemeProvider } from './context/ThemeContext.jsx' import { DemoProvider } from './context/DemoContext.jsx' +import { NotificationPreferencesProvider } from './context/NotificationPreferencesContext.jsx' import { validateConfigAtStartup, reportValidationErrors } from './config/startup.js' import { initializeTelemetrySink } from './config/telemetry.js' @@ -32,9 +33,11 @@ createRoot(document.getElementById('root')).render( - - - + + + + + From 181fa1c879d05a5906121846e08bc2de2a968161 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 21:42:09 +0100 Subject: [PATCH 12/16] add frontend tests for preferences storage, context, component, and hook integration --- .../src/test/NotificationPreferences.test.jsx | 264 ++++++++++++++++ .../NotificationPreferencesContext.test.jsx | 284 ++++++++++++++++++ .../src/test/notificationPreferences.test.js | 173 +++++++++++ .../test/useNotificationsPreferences.test.js | 179 +++++++++++ 4 files changed, 900 insertions(+) create mode 100644 frontend/src/test/NotificationPreferences.test.jsx create mode 100644 frontend/src/test/NotificationPreferencesContext.test.jsx create mode 100644 frontend/src/test/notificationPreferences.test.js create mode 100644 frontend/src/test/useNotificationsPreferences.test.js diff --git a/frontend/src/test/NotificationPreferences.test.jsx b/frontend/src/test/NotificationPreferences.test.jsx new file mode 100644 index 00000000..f2ffc94a --- /dev/null +++ b/frontend/src/test/NotificationPreferences.test.jsx @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import NotificationPreferences from '../components/NotificationPreferences'; +import { NotificationPreferencesProvider } from '../context/NotificationPreferencesContext'; +import { CHANNELS, EVENT_TYPES, EVENT_LABELS, CHANNEL_LABELS } from '../lib/notificationPreferences'; + +const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE'; + +function renderWithProvider(addToast = vi.fn()) { + return render( + + + + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('NotificationPreferences rendering', () => { + it('renders the component with a heading', () => { + renderWithProvider(); + expect(screen.getByText('Notification Preferences')).toBeInTheDocument(); + }); + + it('renders the Channels section heading', () => { + renderWithProvider(); + expect(screen.getByText('Channels')).toBeInTheDocument(); + }); + + it('renders the Event types section heading', () => { + renderWithProvider(); + expect(screen.getByText('Event types')).toBeInTheDocument(); + }); + + it('renders the in-app channel toggle', () => { + renderWithProvider(); + expect(screen.getByText(CHANNEL_LABELS[CHANNELS.IN_APP])).toBeInTheDocument(); + }); + + it('renders the email channel toggle', () => { + renderWithProvider(); + expect(screen.getByText(CHANNEL_LABELS[CHANNELS.EMAIL])).toBeInTheDocument(); + }); + + it('renders all event type labels', () => { + renderWithProvider(); + Object.values(EVENT_LABELS).forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + + it('renders the reset button', () => { + renderWithProvider(); + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); + + it('has the correct data-testid', () => { + renderWithProvider(); + expect(screen.getByTestId('notification-preferences')).toBeInTheDocument(); + }); +}); + +describe('channel toggles', () => { + it('in-app toggle is checked by default', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /in-app/i }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('email toggle is unchecked by default', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /email notifications/i }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking in-app toggle disables it', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /in-app/i }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking email toggle enables it', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /email notifications/i }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('persists channel toggle to localStorage', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /email notifications/i }); + fireEvent.click(toggle); + const stored = JSON.parse(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)); + expect(stored.channels[CHANNELS.EMAIL]).toBe(true); + }); +}); + +describe('email input', () => { + it('does not show email input when email channel is disabled', () => { + renderWithProvider(); + expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument(); + }); + + it('shows email input when email channel is enabled', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: /email notifications/i }); + fireEvent.click(toggle); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + }); + + it('shows validation error for an invalid email', () => { + renderWithProvider(); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + const input = screen.getByLabelText(/email address/i); + fireEvent.change(input, { target: { value: 'not-valid' } }); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('alert').textContent).toMatch(/valid email/i); + }); + + it('clears validation error when a valid email is entered', () => { + renderWithProvider(); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + const input = screen.getByLabelText(/email address/i); + fireEvent.change(input, { target: { value: 'not-valid' } }); + fireEvent.change(input, { target: { value: 'valid@example.com' } }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('saves email on Save button click and calls addToast', () => { + const addToast = vi.fn(); + renderWithProvider(addToast); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + const input = screen.getByLabelText(/email address/i); + fireEvent.change(input, { target: { value: 'save@example.com' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(addToast).toHaveBeenCalledWith('Email address saved', 'success'); + }); + + it('saves email on Enter key press', () => { + const addToast = vi.fn(); + renderWithProvider(addToast); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + const input = screen.getByLabelText(/email address/i); + fireEvent.change(input, { target: { value: 'enter@example.com' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(addToast).toHaveBeenCalledWith('Email address saved', 'success'); + }); + + it('Save button is disabled when email is invalid', () => { + renderWithProvider(); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + const input = screen.getByLabelText(/email address/i); + fireEvent.change(input, { target: { value: 'bad' } }); + const saveBtn = screen.getByRole('button', { name: /^save$/i }); + expect(saveBtn).toBeDisabled(); + }); +}); + +describe('event type toggles', () => { + it('tip_received toggle is checked by default', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_RECEIVED], 'i') }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('tip_sent toggle is unchecked by default', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('clicking tip_sent toggle enables it', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('clicking tip_received toggle disables it', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_RECEIVED], 'i') }); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('persists event toggle to localStorage', () => { + renderWithProvider(); + const toggle = screen.getByRole('switch', { name: new RegExp(EVENT_LABELS[EVENT_TYPES.TIP_SENT], 'i') }); + fireEvent.click(toggle); + const stored = JSON.parse(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)); + expect(stored.events[EVENT_TYPES.TIP_SENT]).toBe(true); + }); +}); + +describe('reset button', () => { + it('resets all toggles to defaults', () => { + renderWithProvider(); + const inAppToggle = screen.getByRole('switch', { name: /in-app/i }); + fireEvent.click(inAppToggle); + expect(inAppToggle).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + expect(inAppToggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls addToast with info type on reset', () => { + const addToast = vi.fn(); + renderWithProvider(addToast); + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + expect(addToast).toHaveBeenCalledWith( + 'Notification preferences reset to defaults', + 'info' + ); + }); + + it('clears localStorage on reset', () => { + renderWithProvider(); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull(); + }); + + it('hides email input after reset when email channel was enabled', () => { + renderWithProvider(); + fireEvent.click(screen.getByRole('switch', { name: /email notifications/i })); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /reset/i })); + expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument(); + }); +}); + +describe('accessibility', () => { + it('channels section has an accessible heading', () => { + renderWithProvider(); + expect(screen.getByRole('region', { name: /channels/i })).toBeInTheDocument(); + }); + + it('event types section has an accessible heading', () => { + renderWithProvider(); + expect(screen.getByRole('region', { name: /event types/i })).toBeInTheDocument(); + }); + + it('all toggles have role switch', () => { + renderWithProvider(); + const switches = screen.getAllByRole('switch'); + expect(switches.length).toBeGreaterThanOrEqual(2); + }); + + it('reset button has an accessible aria-label', () => { + renderWithProvider(); + expect( + screen.getByRole('button', { name: /reset notification preferences/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/NotificationPreferencesContext.test.jsx b/frontend/src/test/NotificationPreferencesContext.test.jsx new file mode 100644 index 00000000..37bcc310 --- /dev/null +++ b/frontend/src/test/NotificationPreferencesContext.test.jsx @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + NotificationPreferencesProvider, + useNotificationPreferences, +} from '../context/NotificationPreferencesContext'; +import { + DEFAULT_PREFERENCES, + CHANNELS, + EVENT_TYPES, +} from '../lib/notificationPreferences'; + +const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE'; + +function wrapper({ children }) { + return ( + + {children} + + ); +} + +function wrapperNoAddress({ children }) { + return ( + + {children} + + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('useNotificationPreferences', () => { + it('throws when used outside the provider', () => { + expect(() => { + renderHook(() => useNotificationPreferences()); + }).toThrow('useNotificationPreferences must be used within'); + }); + + it('returns default preferences on first render', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels); + expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events); + expect(result.current.preferences.email).toBeNull(); + }); + + it('works without a userAddress prop', () => { + const { result } = renderHook(() => useNotificationPreferences(), { + wrapper: wrapperNoAddress, + }); + expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels); + }); +}); + +describe('toggleChannel', () => { + it('disables in_app channel', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.IN_APP, false); + }); + expect(result.current.preferences.channels[CHANNELS.IN_APP]).toBe(false); + }); + + it('enables email channel', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.EMAIL, true); + }); + expect(result.current.preferences.channels[CHANNELS.EMAIL]).toBe(true); + }); + + it('does not affect other channels when toggling one', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.EMAIL, true); + }); + expect(result.current.preferences.channels[CHANNELS.IN_APP]).toBe( + DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP] + ); + }); + + it('persists the change to localStorage', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.EMAIL, true); + }); + const stored = JSON.parse( + localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`) + ); + expect(stored.channels[CHANNELS.EMAIL]).toBe(true); + }); +}); + +describe('toggleEvent', () => { + it('disables tip_received event', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleEvent(EVENT_TYPES.TIP_RECEIVED, false); + }); + expect(result.current.preferences.events[EVENT_TYPES.TIP_RECEIVED]).toBe(false); + }); + + it('enables tip_sent event', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true); + }); + expect(result.current.preferences.events[EVENT_TYPES.TIP_SENT]).toBe(true); + }); + + it('does not affect other events when toggling one', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true); + }); + expect(result.current.preferences.events[EVENT_TYPES.TIP_RECEIVED]).toBe( + DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED] + ); + }); + + it('persists the change to localStorage', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true); + }); + const stored = JSON.parse( + localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`) + ); + expect(stored.events[EVENT_TYPES.TIP_SENT]).toBe(true); + }); +}); + +describe('setEmail', () => { + it('stores an email address', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.setEmail('user@example.com'); + }); + expect(result.current.preferences.email).toBe('user@example.com'); + }); + + it('clears the email when called with empty string', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.setEmail('user@example.com'); + }); + act(() => { + result.current.setEmail(''); + }); + expect(result.current.preferences.email).toBeNull(); + }); + + it('clears the email when called with null', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.setEmail('user@example.com'); + }); + act(() => { + result.current.setEmail(null); + }); + expect(result.current.preferences.email).toBeNull(); + }); +}); + +describe('resetToDefaults', () => { + it('restores all preferences to defaults', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.IN_APP, false); + result.current.toggleEvent(EVENT_TYPES.TIP_RECEIVED, false); + result.current.setEmail('user@example.com'); + }); + act(() => { + result.current.resetToDefaults(); + }); + expect(result.current.preferences.channels).toEqual(DEFAULT_PREFERENCES.channels); + expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events); + expect(result.current.preferences.email).toBeNull(); + }); + + it('removes the localStorage entry', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.EMAIL, true); + }); + act(() => { + result.current.resetToDefaults(); + }); + expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull(); + }); +}); + +describe('isEventEnabled helper', () => { + it('returns true for tip_received with default preferences', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + expect(result.current.isEventEnabled(EVENT_TYPES.TIP_RECEIVED)).toBe(true); + }); + + it('returns false for tip_sent with default preferences', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + expect(result.current.isEventEnabled(EVENT_TYPES.TIP_SENT)).toBe(false); + }); + + it('reflects a toggled event', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleEvent(EVENT_TYPES.TIP_SENT, true); + }); + expect(result.current.isEventEnabled(EVENT_TYPES.TIP_SENT)).toBe(true); + }); +}); + +describe('isChannelEnabled helper', () => { + it('returns true for in_app with default preferences', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + expect(result.current.isChannelEnabled(CHANNELS.IN_APP)).toBe(true); + }); + + it('returns false for email with default preferences', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + expect(result.current.isChannelEnabled(CHANNELS.EMAIL)).toBe(false); + }); + + it('reflects a toggled channel', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.toggleChannel(CHANNELS.EMAIL, true); + }); + expect(result.current.isChannelEnabled(CHANNELS.EMAIL)).toBe(true); + }); +}); + +describe('updatePreferences', () => { + it('applies a full preferences update', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.updatePreferences({ + channels: { email: true }, + events: { tip_sent: true }, + email: 'bulk@example.com', + }); + }); + expect(result.current.preferences.channels.email).toBe(true); + expect(result.current.preferences.events.tip_sent).toBe(true); + expect(result.current.preferences.email).toBe('bulk@example.com'); + }); + + it('does not overwrite untouched fields in a partial update', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + act(() => { + result.current.updatePreferences({ channels: { email: true } }); + }); + expect(result.current.preferences.channels.in_app).toBe( + DEFAULT_PREFERENCES.channels.in_app + ); + expect(result.current.preferences.events).toEqual(DEFAULT_PREFERENCES.events); + }); +}); + +describe('reloadFromStorage', () => { + it('picks up changes written directly to localStorage', () => { + const { result } = renderHook(() => useNotificationPreferences(), { wrapper }); + localStorage.setItem( + `tipstream_notif_prefs_${ADDRESS}`, + JSON.stringify({ + channels: { in_app: false, email: true }, + events: DEFAULT_PREFERENCES.events, + email: 'reload@example.com', + }) + ); + act(() => { + result.current.reloadFromStorage(); + }); + expect(result.current.preferences.channels.in_app).toBe(false); + expect(result.current.preferences.email).toBe('reload@example.com'); + }); +}); diff --git a/frontend/src/test/notificationPreferences.test.js b/frontend/src/test/notificationPreferences.test.js new file mode 100644 index 00000000..6ef97f07 --- /dev/null +++ b/frontend/src/test/notificationPreferences.test.js @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + loadPreferences, + savePreferences, + clearPreferences, + isEventEnabled, + isChannelEnabled, + DEFAULT_PREFERENCES, + CHANNELS, + EVENT_TYPES, +} from '../lib/notificationPreferences'; + +const ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE'; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('loadPreferences', () => { + it('returns defaults when nothing is stored', () => { + const prefs = loadPreferences(ADDRESS); + expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels); + expect(prefs.events).toEqual(DEFAULT_PREFERENCES.events); + expect(prefs.email).toBeNull(); + }); + + it('returns defaults when address is null', () => { + const prefs = loadPreferences(null); + expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels); + }); + + it('returns stored preferences after savePreferences', () => { + const custom = { + channels: { in_app: false, email: true }, + events: { ...DEFAULT_PREFERENCES.events, tip_sent: true }, + email: 'user@example.com', + }; + savePreferences(ADDRESS, custom); + const loaded = loadPreferences(ADDRESS); + expect(loaded.channels.in_app).toBe(false); + expect(loaded.channels.email).toBe(true); + expect(loaded.events.tip_sent).toBe(true); + expect(loaded.email).toBe('user@example.com'); + }); + + it('merges stored channels with defaults for missing keys', () => { + localStorage.setItem( + `tipstream_notif_prefs_${ADDRESS}`, + JSON.stringify({ channels: { email: true } }) + ); + const prefs = loadPreferences(ADDRESS); + expect(prefs.channels.email).toBe(true); + expect(prefs.channels.in_app).toBe(DEFAULT_PREFERENCES.channels.in_app); + }); + + it('returns defaults when stored JSON is malformed', () => { + localStorage.setItem(`tipstream_notif_prefs_${ADDRESS}`, 'not-json'); + const prefs = loadPreferences(ADDRESS); + expect(prefs.channels).toEqual(DEFAULT_PREFERENCES.channels); + }); +}); + +describe('savePreferences', () => { + it('persists preferences to localStorage', () => { + savePreferences(ADDRESS, { ...DEFAULT_PREFERENCES, email: 'a@b.com' }); + const raw = localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw); + expect(parsed.email).toBe('a@b.com'); + }); + + it('does nothing when address is null', () => { + savePreferences(null, DEFAULT_PREFERENCES); + expect(localStorage.length).toBe(0); + }); +}); + +describe('clearPreferences', () => { + it('removes the stored key', () => { + savePreferences(ADDRESS, DEFAULT_PREFERENCES); + clearPreferences(ADDRESS); + expect(localStorage.getItem(`tipstream_notif_prefs_${ADDRESS}`)).toBeNull(); + }); + + it('does nothing when address is null', () => { + savePreferences(ADDRESS, DEFAULT_PREFERENCES); + clearPreferences(null); + expect(localStorage.length).toBe(1); + }); +}); + +describe('isEventEnabled', () => { + it('returns the default value when preferences is null', () => { + expect(isEventEnabled(null, EVENT_TYPES.TIP_RECEIVED)).toBe( + DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED] + ); + }); + + it('returns true for tip_received by default', () => { + expect(isEventEnabled(DEFAULT_PREFERENCES, EVENT_TYPES.TIP_RECEIVED)).toBe(true); + }); + + it('returns false for tip_sent by default', () => { + expect(isEventEnabled(DEFAULT_PREFERENCES, EVENT_TYPES.TIP_SENT)).toBe(false); + }); + + it('reflects a toggled value', () => { + const prefs = { + ...DEFAULT_PREFERENCES, + events: { ...DEFAULT_PREFERENCES.events, tip_sent: true }, + }; + expect(isEventEnabled(prefs, EVENT_TYPES.TIP_SENT)).toBe(true); + }); + + it('falls back to default when event key is missing from preferences', () => { + const prefs = { channels: DEFAULT_PREFERENCES.channels, events: {}, email: null }; + expect(isEventEnabled(prefs, EVENT_TYPES.TIP_RECEIVED)).toBe(true); + }); +}); + +describe('isChannelEnabled', () => { + it('returns the default value when preferences is null', () => { + expect(isChannelEnabled(null, CHANNELS.IN_APP)).toBe( + DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP] + ); + }); + + it('returns true for in_app by default', () => { + expect(isChannelEnabled(DEFAULT_PREFERENCES, CHANNELS.IN_APP)).toBe(true); + }); + + it('returns false for email by default', () => { + expect(isChannelEnabled(DEFAULT_PREFERENCES, CHANNELS.EMAIL)).toBe(false); + }); + + it('reflects a toggled value', () => { + const prefs = { + ...DEFAULT_PREFERENCES, + channels: { ...DEFAULT_PREFERENCES.channels, email: true }, + }; + expect(isChannelEnabled(prefs, CHANNELS.EMAIL)).toBe(true); + }); +}); + +describe('DEFAULT_PREFERENCES shape', () => { + it('has in_app enabled and email disabled by default', () => { + expect(DEFAULT_PREFERENCES.channels[CHANNELS.IN_APP]).toBe(true); + expect(DEFAULT_PREFERENCES.channels[CHANNELS.EMAIL]).toBe(false); + }); + + it('has tip_received enabled by default', () => { + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_RECEIVED]).toBe(true); + }); + + it('has tip_sent disabled by default', () => { + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.TIP_SENT]).toBe(false); + }); + + it('has all scheduled and refund events enabled by default', () => { + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.SCHEDULED_TIP_EXECUTED]).toBe(true); + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.SCHEDULED_TIP_FAILED]).toBe(true); + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.REFUND_REQUESTED]).toBe(true); + expect(DEFAULT_PREFERENCES.events[EVENT_TYPES.REFUND_RESOLVED]).toBe(true); + }); + + it('has null email by default', () => { + expect(DEFAULT_PREFERENCES.email).toBeNull(); + }); +}); diff --git a/frontend/src/test/useNotificationsPreferences.test.js b/frontend/src/test/useNotificationsPreferences.test.js new file mode 100644 index 00000000..9247e6b3 --- /dev/null +++ b/frontend/src/test/useNotificationsPreferences.test.js @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { DEFAULT_PREFERENCES, CHANNELS, EVENT_TYPES } from '../lib/notificationPreferences'; + +vi.mock('../context/TipContext', () => ({ + useTipContext: vi.fn(() => ({ + events: [], + eventsLoading: false, + })), +})); + +vi.mock('../config/contracts', () => ({ + NETWORK_NAME: 'mainnet', +})); + +import { useNotifications } from '../hooks/useNotifications'; +import { useTipContext } from '../context/TipContext'; + +const USER_ADDRESS = 'SP1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE'; + +function makeTipEvent(overrides = {}) { + return { + event: 'tip-sent', + sender: 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T', + recipient: USER_ADDRESS, + amount: '1000000', + timestamp: Math.floor(Date.now() / 1000) + 100, + txId: '0x' + Math.random().toString(16).slice(2, 18), + ...overrides, + }; +} + +beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe('useNotifications with preferences', () => { + it('returns notifications when in_app channel is enabled (default)', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const { result } = renderHook(() => + useNotifications(USER_ADDRESS, DEFAULT_PREFERENCES) + ); + expect(result.current.notifications.length).toBe(1); + }); + + it('returns empty notifications when in_app channel is disabled', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.notifications).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('returns empty notifications when tip_received event is disabled', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.notifications).toEqual([]); + expect(result.current.unreadCount).toBe(0); + }); + + it('returns empty notifications when both in_app and tip_received are disabled', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent(), makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false }, + events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.notifications).toEqual([]); + }); + + it('returns notifications when preferences is null (backward compat)', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const { result } = renderHook(() => useNotifications(USER_ADDRESS, null)); + expect(result.current.notifications.length).toBe(1); + }); + + it('returns notifications when preferences is undefined (backward compat)', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const { result } = renderHook(() => useNotifications(USER_ADDRESS)); + expect(result.current.notifications.length).toBe(1); + }); + + it('unread count is 0 when in_app is disabled even with new events', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.unreadCount).toBe(0); + }); + + it('unread count is 0 when tip_received is disabled even with new events', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + events: { ...DEFAULT_PREFERENCES.events, [EVENT_TYPES.TIP_RECEIVED]: false }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.unreadCount).toBe(0); + }); + + it('re-enables notifications when preferences change back to enabled', () => { + const event = makeTipEvent(); + useTipContext.mockReturnValue({ + events: [event], + eventsLoading: false, + }); + + const disabledPrefs = { + ...DEFAULT_PREFERENCES, + channels: { ...DEFAULT_PREFERENCES.channels, [CHANNELS.IN_APP]: false }, + }; + + const { result, rerender } = renderHook( + ({ prefs }) => useNotifications(USER_ADDRESS, prefs), + { initialProps: { prefs: disabledPrefs } } + ); + expect(result.current.notifications).toEqual([]); + + rerender({ prefs: DEFAULT_PREFERENCES }); + expect(result.current.notifications.length).toBe(1); + }); + + it('other event types in preferences do not affect tip_received notifications', () => { + useTipContext.mockReturnValue({ + events: [makeTipEvent()], + eventsLoading: false, + }); + const prefs = { + ...DEFAULT_PREFERENCES, + events: { + ...DEFAULT_PREFERENCES.events, + [EVENT_TYPES.TIP_SENT]: false, + [EVENT_TYPES.REFUND_REQUESTED]: false, + }, + }; + const { result } = renderHook(() => useNotifications(USER_ADDRESS, prefs)); + expect(result.current.notifications.length).toBe(1); + }); +}); From 78d46993a529122784dc0a41aee9c721cbba40a8 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 22:24:04 +0100 Subject: [PATCH 13/16] add settings shortcut in notification bell dropdown linking to preferences page --- frontend/src/components/NotificationBell.jsx | 27 +++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx index a1679f38..127e7c5d 100644 --- a/frontend/src/components/NotificationBell.jsx +++ b/frontend/src/components/NotificationBell.jsx @@ -1,11 +1,14 @@ import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { formatSTX, formatAddress } from '../lib/utils'; -import { Bell } from 'lucide-react'; +import { Bell, Settings } from 'lucide-react'; +import { ROUTE_NOTIFICATION_PREFERENCES } from '../config/routes'; export default function NotificationBell({ notifications, unreadCount, onMarkRead, loading, lastSeenTimestamp }) { const [open, setOpen] = useState(false); const dropdownRef = useRef(null); const panelId = 'notifications-panel'; + const navigate = useNavigate(); useEffect(() => { const handleClickOutside = (e) => { @@ -72,14 +75,26 @@ export default function NotificationBell({ notifications, unreadCount, onMarkRea >

Notifications

- {notifications.length > 0 && ( +
+ {notifications.length > 0 && ( + + )} - )} +
From bb0955a13d77fe276f880e84d4ba5e070dcce2b3 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 22:35:42 +0100 Subject: [PATCH 14/16] update NotificationBell tests for router dependency and settings shortcut --- frontend/src/test/NotificationBell.test.jsx | 224 +++++++------------- 1 file changed, 82 insertions(+), 142 deletions(-) diff --git a/frontend/src/test/NotificationBell.test.jsx b/frontend/src/test/NotificationBell.test.jsx index 87841b12..83513a54 100644 --- a/frontend/src/test/NotificationBell.test.jsx +++ b/frontend/src/test/NotificationBell.test.jsx @@ -1,7 +1,16 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import NotificationBell from '../components/NotificationBell'; +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => vi.fn(), + }; +}); + afterEach(() => { cleanup(); vi.restoreAllMocks(); @@ -27,49 +36,57 @@ const defaultProps = { lastSeenTimestamp: 0, }; +function renderBell(props = {}) { + return render( + + + + ); +} + describe('NotificationBell', () => { describe('rendering', () => { it('renders the bell icon button', () => { - render(); + renderBell(); expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument(); }); it('shows unread badge when unreadCount > 0', () => { - render(); + renderBell({ unreadCount: 3 }); expect(screen.getByText('3')).toBeInTheDocument(); }); it('shows 9+ when unreadCount exceeds 9', () => { - render(); + renderBell({ unreadCount: 15 }); expect(screen.getByText('9+')).toBeInTheDocument(); }); it('hides badge when unreadCount is 0', () => { - render(); + renderBell({ unreadCount: 0 }); expect(screen.queryByText('0')).not.toBeInTheDocument(); }); it('shows exact number when unreadCount is 9', () => { - render(); + renderBell({ unreadCount: 9 }); expect(screen.getByText('9')).toBeInTheDocument(); expect(screen.queryByText('9+')).not.toBeInTheDocument(); }); it('shows 9+ when unreadCount is exactly 10', () => { - render(); + renderBell({ unreadCount: 10 }); expect(screen.getByText('9+')).toBeInTheDocument(); }); }); describe('dropdown toggle', () => { it('opens dropdown on click', () => { - render(); + renderBell(); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByRole('region', { name: /notifications/i })).toBeInTheDocument(); }); it('closes dropdown on second click', () => { - render(); + renderBell(); const btn = screen.getByRole('button', { name: /notifications/i }); fireEvent.click(btn); fireEvent.click(btn); @@ -78,30 +95,39 @@ describe('NotificationBell', () => { it('calls onMarkRead when opening with unread items', () => { const onMarkRead = vi.fn(); - render(); + renderBell({ unreadCount: 2, onMarkRead }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(onMarkRead).toHaveBeenCalledTimes(1); }); it('does not call onMarkRead when there are no unread items', () => { const onMarkRead = vi.fn(); - render(); + renderBell({ unreadCount: 0, onMarkRead }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(onMarkRead).not.toHaveBeenCalled(); }); }); + describe('settings shortcut', () => { + it('renders the notification settings button inside the dropdown', () => { + renderBell(); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + expect(screen.getByRole('button', { name: /notification settings/i })).toBeInTheDocument(); + }); + + it('closes the dropdown when settings button is clicked', () => { + renderBell(); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + fireEvent.click(screen.getByRole('button', { name: /notification settings/i })); + expect(screen.queryByRole('region', { name: /notifications/i })).not.toBeInTheDocument(); + }); + }); + describe('read/unread visual distinction', () => { it('shows green dot for unread notifications', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now + 10 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dot = container.querySelector('.bg-green-400'); expect(dot).toBeInTheDocument(); @@ -110,13 +136,7 @@ describe('NotificationBell', () => { it('hides green dot for read notifications', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now - 100 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(0); @@ -125,13 +145,7 @@ describe('NotificationBell', () => { it('shows background highlight for unread items', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now + 10 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const highlighted = container.querySelector('[class*="bg-blue-50"]'); expect(highlighted).toBeInTheDocument(); @@ -140,13 +154,7 @@ describe('NotificationBell', () => { it('does not show background highlight for read items', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now - 100 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const highlighted = container.querySelector('[class*="bg-blue-50"]'); expect(highlighted).not.toBeInTheDocument(); @@ -158,13 +166,7 @@ describe('NotificationBell', () => { makeNotification({ txId: '0xnew1', timestamp: now + 10 }), makeNotification({ txId: '0xold1', timestamp: now - 100 }), ]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(1); @@ -173,13 +175,7 @@ describe('NotificationBell', () => { it('treats all items as read when lastSeenTimestamp is null', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now + 10 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: null }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(0); @@ -191,13 +187,7 @@ describe('NotificationBell', () => { makeNotification({ txId: '0xa', timestamp: now }), makeNotification({ txId: '0xb', timestamp: now - 60 }), ]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: 0 }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(2); @@ -208,13 +198,7 @@ describe('NotificationBell', () => { it('treats notification at exact lastSeenTimestamp as read', () => { const ts = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: ts })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: ts }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(0); @@ -223,13 +207,7 @@ describe('NotificationBell', () => { it('treats notification one second after lastSeen as unread', () => { const ts = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: ts + 1 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: ts }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dots = container.querySelectorAll('.bg-green-400'); expect(dots.length).toBe(1); @@ -238,13 +216,13 @@ describe('NotificationBell', () => { describe('empty states', () => { it('shows loading text when loading with no notifications', () => { - render(); + renderBell({ loading: true }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); it('shows empty message when not loading and no notifications', () => { - render(); + renderBell(); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText('No tips received yet')).toBeInTheDocument(); }); @@ -252,27 +230,27 @@ describe('NotificationBell', () => { describe('accessibility', () => { it('bell button has accessible label', () => { - render(); - const btn = screen.getByRole('button'); - expect(btn).toHaveAttribute('aria-label', 'Notifications (5 unread)'); + renderBell({ unreadCount: 5 }); + const btn = screen.getByRole('button', { name: /notifications \(5 unread\)/i }); + expect(btn).toBeInTheDocument(); }); it('bell button has default label with no unread', () => { - render(); - const btn = screen.getByRole('button'); + renderBell(); + const btn = screen.getByRole('button', { name: /^notifications$/i }); expect(btn).toHaveAttribute('aria-label', 'Notifications'); }); it('dropdown has region role', () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + renderBell(); + fireEvent.click(screen.getByRole('button', { name: /^notifications$/i })); expect(screen.getByRole('region')).toBeInTheDocument(); }); }); describe('click outside', () => { it('closes dropdown when clicking outside', () => { - render(); + renderBell(); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByRole('region')).toBeInTheDocument(); fireEvent.mouseDown(document.body); @@ -283,7 +261,7 @@ describe('NotificationBell', () => { describe('notification items', () => { it('renders notification items', () => { const notifications = [makeNotification({ amount: '2000000' })]; - render(); + renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText(/STX/)).toBeInTheDocument(); }); @@ -292,9 +270,7 @@ describe('NotificationBell', () => { const notifications = Array.from({ length: 25 }, (_, i) => makeNotification({ txId: `0x${String(i).padStart(8, '0')}` }) ); - const { container } = render( - - ); + const { container } = renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const items = container.querySelectorAll('.flex.items-start'); expect(items.length).toBe(20); @@ -303,13 +279,7 @@ describe('NotificationBell', () => { it('unread dot has correct size and shape classes', () => { const now = Math.floor(Date.now() / 1000); const notifications = [makeNotification({ timestamp: now + 10 })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const dot = container.querySelector('.bg-green-400'); expect(dot.className).toContain('w-2'); @@ -325,13 +295,7 @@ describe('NotificationBell', () => { makeNotification({ txId: '0xbbb', timestamp: now + 10 }), makeNotification({ txId: '0xccc', timestamp: now + 15 }), ]; - const { container } = render( - - ); + const { container } = renderBell({ notifications, lastSeenTimestamp: now }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const highlighted = container.querySelectorAll('[class*="bg-blue-50"]'); expect(highlighted.length).toBe(3); @@ -341,9 +305,7 @@ describe('NotificationBell', () => { describe('stable keys for notification rows', () => { it('uses txId as key when available', () => { const notifications = [makeNotification({ txId: '0xabc123' })]; - const { container } = render( - - ); + const { container } = renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); const items = container.querySelectorAll('[class*="px-4"]'); expect(items.length).toBeGreaterThan(0); @@ -358,12 +320,7 @@ describe('NotificationBell', () => { amount: '1000000', timestamp: Math.floor(Date.now() / 1000), }; - render( - - ); + renderBell({ notifications: [notificationWithoutTxId] }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText(/STX/)).toBeInTheDocument(); }); @@ -376,10 +333,7 @@ describe('NotificationBell', () => { amount: '1000000', timestamp: 1234567890, }; - const notifications = [notificationWithoutIdFields]; - render( - - ); + renderBell({ notifications: [notificationWithoutIdFields] }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText(/STX/)).toBeInTheDocument(); }); @@ -390,20 +344,15 @@ describe('NotificationBell', () => { makeNotification({ txId: '0xbbb', amount: '2000000' }), makeNotification({ txId: '0xccc', amount: '3000000' }), ]; - const { rerender } = render( - - ); + const { rerender } = renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); - - const reorderedNotifications = [ - notifications[2], - notifications[0], - notifications[1], - ]; + + const reorderedNotifications = [notifications[2], notifications[0], notifications[1]]; rerender( - + + + ); - expect(screen.getAllByText(/STX/)).toHaveLength(3); }); @@ -419,9 +368,7 @@ describe('NotificationBell', () => { }, makeNotification({ txId: '0xdef456' }), ]; - render( - - ); + renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getAllByText(/STX/)).toHaveLength(3); }); @@ -430,16 +377,15 @@ describe('NotificationBell', () => { const initialNotifications = Array.from({ length: 25 }, (_, i) => makeNotification({ txId: `0x${String(i).padStart(8, '0')}` }) ); - const { rerender } = render( - - ); + const { rerender } = renderBell({ notifications: initialNotifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); - + const trimmedNotifications = initialNotifications.slice(0, 10); rerender( - + + + ); - expect(screen.getAllByText(/STX/)).toHaveLength(10); }); }); @@ -447,7 +393,7 @@ describe('NotificationBell', () => { describe('mark all read button', () => { it('shows mark all read button when notifications exist', () => { const notifications = [makeNotification()]; - render(); + renderBell({ notifications }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); expect(screen.getByText('Mark all read')).toBeInTheDocument(); }); @@ -455,13 +401,7 @@ describe('NotificationBell', () => { it('calls onMarkRead when mark all read is clicked', () => { const onMarkRead = vi.fn(); const notifications = [makeNotification()]; - render( - - ); + renderBell({ notifications, onMarkRead }); fireEvent.click(screen.getByRole('button', { name: /notifications/i })); fireEvent.click(screen.getByText('Mark all read')); expect(onMarkRead).toHaveBeenCalled(); From 5e229427856cf5f1f4979a378200c3b697245191 Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 22:36:17 +0100 Subject: [PATCH 15/16] document notification preferences API endpoints in chainhook env example --- chainhook/.env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/chainhook/.env.example b/chainhook/.env.example index 39cdb017..85a40024 100644 --- a/chainhook/.env.example +++ b/chainhook/.env.example @@ -59,3 +59,11 @@ LOG_LEVEL=INFO # Metrics Access Control METRICS_AUTH_TOKEN= HEALTH_CHECK_ALWAYS_ENABLED=true + +# Notification Preferences +# Preferences are stored per-address in the notification_preferences table. +# No additional configuration is required; the table is created automatically on startup. +# API endpoints: +# GET /api/notifications/preferences/:address +# PUT /api/notifications/preferences/:address +# DELETE /api/notifications/preferences/:address From 65905fbcbec9ae193c6422c783dc1acc1512390d Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Thu, 21 May 2026 22:36:38 +0100 Subject: [PATCH 16/16] document notification preferences route in frontend env example --- frontend/.env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/.env.example b/frontend/.env.example index 4e891271..4ac0d306 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -28,3 +28,7 @@ VITE_APP_URL=https://tipstream-silk.vercel.app # VITE_TELEMETRY_ENABLED=true # VITE_TELEMETRY_ENDPOINT=https://telemetry.example.com/ingest # VITE_TELEMETRY_API_KEY=your-api-key + +# Notification preferences are stored in localStorage per wallet address. +# No additional configuration is required. +# Users can manage preferences at /notification-preferences.