import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const root = process.cwd(); const read = (path) => readFileSync(join(root, path), 'utf8'); for (const suffix of ['up', 'down', 'verify']) assert.ok(existsSync(join(root, `database/migrations/2026081110_m10a_notification_center.${suffix}.sql`))); const migration = read('database/migrations/2026081110_m10a_notification_center.up.sql'); for (const pattern of ['qipai_notification_templates', 'qipai_notification_routes', 'qipai_notification_subscriptions', 'qipai_notification_deliveries', 'qipai_notification_attempts', 'uq_qipai_notification_delivery_idempotency', 'scope_store_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(store_id, 0)) STORED', 'NOTIFICATION_ATTEMPT_IMMUTABLE', 'notification.read', 'notification.manage']) assert.ok(migration.includes(pattern), `notification migration is missing ${pattern}`); const service = read('backend/src/notifications/notification-service.ts'); for (const pattern of ['materializeOutboxEvent', 'enqueuePendingOutbox', 'handleTask', 'manualRetry', 'WECHAT_SUBSCRIBE', 'hasSubscription', 'inQuietHours', 'setSubscription', 'listInbox', 'markInboxRead', "trigger === 'MANUAL'", 'HttpNotificationAdapter', 'createNotificationAdaptersFromEnvironment', 'sensitiveKey', 'notification.dispatch', 'outbox.publish']) assert.ok(service.includes(pattern), `notification service is missing ${pattern}`); assert.match(read('backend/src/tasks/worker.ts'), /notification\.dispatch[\s\S]*outbox\.publish/); assert.match(read('backend/src/products/product-order-service.ts'), /INSERT IGNORE INTO qipai_outbox_events/); for (const source of ['backend/src/orders/pricing-repository.ts', 'backend/src/orders/order-state-repository.ts', 'backend/src/payments/payment-repository.ts', 'backend/src/payments/wechat-payment-service.ts', 'backend/src/cleaning/cleaning-task-repository.ts', 'backend/src/devices/iot-message-service.ts']) { assert.match(read(source), /qipai_outbox_events/, `${source} must publish notification events`); } const routes = read('backend/src/routes/notifications.ts'); for (const pattern of ['/admin-api', '/app-api/management', '/notifications/templates', '/notifications/routes', '/notifications/deliveries', '/retry', '/app-api/notifications/subscriptions', '/app-api/notifications/inbox']) assert.ok(routes.includes(pattern)); const panel = read('admin/src/components/NotificationCenterPanel.vue'); for (const pattern of ['事件路由、模板与投递追踪', '人工补发', '新增通知模板', '新增通知路由', '静默开始/结束']) assert.ok(panel.includes(pattern)); assert.match(read('admin/src/navigation.mjs'), /menuKey: 'notifications'/); assert.match(read('admin/src/router.ts'), /NotificationCenterPanel\.vue/); const mysql = read('backend/tests/mysql-migration-roundtrip.test.mjs'); for (const pattern of ['assertNotificationCenter', 'must-not-leak', 'explicit authorization', 'simulated webhook outage', "triggerType, operator_id AS operatorId", 'NOTIFICATION_ATTEMPT_IMMUTABLE']) assert.ok(mysql.includes(pattern)); console.log('PASS: M10-A notification templates, routing, consent, outbox fan-out, retry and operations gates are present.');