import assert from 'node:assert/strict'; import Fastify from 'fastify'; import { signAccessToken } from '../dist/auth/jwt.js'; import { NotificationError } from '../dist/notifications/notification-service.js'; import { registerNotificationRoutes } from '../dist/routes/notifications.js'; const secret = 'test-only-notification-route-secret'; const sessionId = '3a6ab573-1105-4bf9-b75b-e88e49eb3b82'; const token = signAccessToken({ sub: '21', sid: sessionId, tid: '7', aid: '9', rv: 1 }, secret, 900); const headers = { authorization: `Bearer ${token}`, 'x-trace-id': 'm10a-notification-route' }; let access = { roles: ['STORE_ADMIN'], capabilities: ['notification.read', 'notification.manage'], storeIds: ['11'] }; const calls = []; const service = { async listTemplates(actor, input) { calls.push(['listTemplates', actor, input]); return [{ id: '1' }]; }, async saveTemplate(actor, input) { calls.push(['saveTemplate', actor, input]); return { id: input.id ?? '1', version: 1 }; }, async listRoutes(actor, input) { calls.push(['listRoutes', actor, input]); return [{ id: '2' }]; }, async createRoute(actor, input) { calls.push(['createRoute', actor, input]); return { id: '2', version: 1 }; }, async listDeliveries(actor, input) { calls.push(['listDeliveries', actor, input]); return { items: [], total: 0, page: input.page, pageSize: input.pageSize }; }, async manualRetry(actor, deliveryId) { calls.push(['manualRetry', actor, deliveryId]); if (deliveryId === '999') throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED'); return { id: deliveryId, queued: true }; }, async setSubscription(tenantId, userId, input) { calls.push(['setSubscription', tenantId, userId, input]); return input; }, async listInbox(tenantId, userId, input) { calls.push(['listInbox', tenantId, userId, input]); return { items: [], total: 0, ...input }; }, async markInboxRead(tenantId, userId, deliveryId) { calls.push(['markInboxRead', tenantId, userId, deliveryId]); return { id: deliveryId, read: true }; } }; const app = Fastify({ logger: false }); app.decorateRequest('traceId', ''); app.addHook('onRequest', async (request) => { request.traceId = request.headers['x-trace-id'] || request.id; }); await registerNotificationRoutes(app, { service, authRepository: { async validateSession() { return { id: sessionId, tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } }, accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret }); assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates' })).statusCode, 401); const listed = await app.inject({ method: 'GET', url: '/admin-api/notifications/deliveries?storeId=11&page=2&pageSize=10', headers }); assert.equal(listed.statusCode, 200); assert.equal(listed.json().data.page, 2); const saved = await app.inject({ method: 'PUT', url: '/app-api/management/notifications/templates', headers, payload: { templateCode: 'PRODUCT_ORDER_PAID_IN_APP', eventType: 'PRODUCT_ORDER_PAID', channel: 'IN_APP', titleTemplate: '新订单', bodyTemplate: '订单 {{orderNo}} 已支付' } }); assert.equal(saved.statusCode, 200); const routed = await app.inject({ method: 'POST', url: '/admin-api/notifications/routes', headers, payload: { storeId: '11', eventType: 'PRODUCT_ORDER_PAID', templateId: '1', recipientType: 'ROLE', recipientValue: 'STORE_ADMIN', quietStart: '23:00', quietEnd: '07:00' } }); assert.equal(routed.statusCode, 201); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/notifications/deliveries/3/retry', headers })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/admin-api/notifications/deliveries/999/retry', headers })).statusCode, 409); assert.equal(calls.find((call) => call[0] === 'createRoute')[1].traceId, 'm10a-notification-route'); assert.equal((await app.inject({ method: 'PUT', url: '/app-api/notifications/subscriptions', headers, payload: { channel: 'WECHAT_SUBSCRIBE', templateCode: 'PRODUCT_ORDER_PAID_WECHAT', status: 'AUTHORIZED' } })).statusCode, 200); assert.equal((await app.inject({ method: 'GET', url: '/app-api/notifications/inbox?page=2&pageSize=10', headers })).json().data.page, 2); assert.equal((await app.inject({ method: 'POST', url: '/app-api/notifications/inbox/3/read', headers })).statusCode, 200); assert.deepEqual(calls.find((call) => call[0] === 'setSubscription').slice(1, 3), ['7', '21']); access = { roles: ['STAFF'], capabilities: [], storeIds: ['11'] }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates', headers })).statusCode, 403); access = { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] }; assert.equal((await app.inject({ method: 'GET', url: '/admin-api/notifications/templates', headers })).statusCode, 200); await app.close(); console.log('PASS: M10-A notification routes enforce auth, permissions, validation and manual retry boundaries.');