From 4c210723cdb133b62462079f14491e37c1b87ed0 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 6 Sep 2026 20:22:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E7=BA=B3=E7=AE=A1=E4=BF=9D=E5=AD=98=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=A6=82=E5=86=B5=E6=9C=80=E8=BF=91=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operations/queries/uplink.queries.spec.ts | 17 +++ api/src/operations/queries/uplink.queries.ts | 90 +++++++++------ .../sending-monitor/sending-monitor.module.ts | 4 +- .../first-version-development-requirements.md | 1 + .../sending-monitor-redesign-plan-20260906.md | 4 +- docs/system-functional-test-cases.md | 8 +- docs/testing-progress.md | 8 ++ src/apps/admin/MonitorRuntimeOverview.tsx | 23 ---- .../verify-monitor-config-postgres.mjs | 103 ++++++++++++++++++ 9 files changed, 194 insertions(+), 64 deletions(-) create mode 100644 api/src/operations/queries/uplink.queries.spec.ts create mode 100644 tools/testing/verify-monitor-config-postgres.mjs diff --git a/api/src/operations/queries/uplink.queries.spec.ts b/api/src/operations/queries/uplink.queries.spec.ts new file mode 100644 index 0000000..b14e24b --- /dev/null +++ b/api/src/operations/queries/uplink.queries.spec.ts @@ -0,0 +1,17 @@ +import type { PrismaService } from '../../prisma/prisma.service'; +import { OperationsUplinkQueries } from './uplink.queries'; + +describe('runtime monitor summary', () => { + it('keeps tenant/channel status counts without reading recent business records', async () => { + const byStatus = [{ status: 'delivered', _count: { _all: 3 } }]; + const groupBy = jest.fn().mockResolvedValue(byStatus); + // No detail delegates: accessing any removed query fails this test. + const queries = new OperationsUplinkQueries({ smsMessageRecord: { groupBy } } as unknown as PrismaService); + expect(await queries.monitor({ tenantId: 'tenant-a', channelId: 'channel-a' })).toEqual({ byStatus }); + expect(groupBy).toHaveBeenCalledWith({ + by: ['status'], + where: expect.objectContaining({ tenantId: 'tenant-a', channelId: 'channel-a' }), + _count: { _all: true }, + }); + }); +}); diff --git a/api/src/operations/queries/uplink.queries.ts b/api/src/operations/queries/uplink.queries.ts index 1aa67a2..69a6e78 100644 --- a/api/src/operations/queries/uplink.queries.ts +++ b/api/src/operations/queries/uplink.queries.ts @@ -1,16 +1,22 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomUUID } from 'node:crypto'; -import { moneyToNumber } from '../../common/money'; import { PrismaService } from '../../prisma/prisma.service'; -import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; -import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers'; +import { messageWhere, clientUplinkView } from '../operations.helpers'; -// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline. +// Uplink record queries and the status-only runtime summary. export class OperationsUplinkQueries { constructor(private readonly prisma: PrismaService) {} -listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { + listUplinkMessages(query: { + tenantId?: string; + channelId?: string; + applicationId?: string; + phoneNumber?: string; + keyword?: string; + startTime?: string; + endTime?: string; + page?: number; + pageSize?: number; + }) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId: query.tenantId, @@ -18,7 +24,13 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId applicationId: query.applicationId, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, content: query.keyword ? { contains: query.keyword } : undefined, - receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined, + receivedAt: + query.startTime || query.endTime + ? { + gte: query.startTime ? new Date(query.startTime) : undefined, + lte: query.endTime ? new Date(query.endTime) : undefined, + } + : undefined, }, include: { tenant: true, @@ -39,11 +51,33 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId take: query.pageSize ?? 500, }); } -async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { + async listClientUplinkMessages(query: { + tenantId?: string; + applicationId?: string; + phoneNumber?: string; + keyword?: string; + startTime?: string; + endTime?: string; + page?: number; + pageSize?: number; + }) { const items = await this.listUplinkMessages(query); return items.map(clientUplinkView); } -async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { + async listUplinkMessagesPage( + query: { + tenantId?: string; + channelId?: string; + applicationId?: string; + phoneNumber?: string; + keyword?: string; + startTime?: string; + endTime?: string; + page?: number; + pageSize?: number; + }, + clientView = false, + ) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsUplinkMessageWhereInput = { @@ -52,10 +86,13 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app applicationId: query.applicationId, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, content: query.keyword ? { contains: query.keyword } : undefined, - receivedAt: query.startTime || query.endTime ? { - gte: query.startTime ? new Date(query.startTime) : undefined, - lte: query.endTime ? new Date(query.endTime) : undefined, - } : undefined, + receivedAt: + query.startTime || query.endTime + ? { + gte: query.startTime ? new Date(query.startTime) : undefined, + lte: query.endTime ? new Date(query.endTime) : undefined, + } + : undefined, }; const [rawItems, total] = await Promise.all([ this.listUplinkMessages({ ...query, page, pageSize }), @@ -68,28 +105,9 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app pageSize, }; } -async monitor(query: { tenantId?: string; channelId?: string }) { + async monitor(query: { tenantId?: string; channelId?: string }) { const where = messageWhere(query); - const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ - this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), - this.prisma.smsMessageRecord.findMany({ - where, - include: { submitRecords: true, receiptRecords: true }, - orderBy: { queuedAt: 'desc' }, - take: 20, - }), - this.prisma.smsReceiptRecord.findMany({ - where: { tenantId: query.tenantId, channelId: query.channelId }, - orderBy: { createdAt: 'desc' }, - take: 20, - }), - this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }), - ]); - return { - byStatus, - recentMessages, - recentReceipts, - recentUplinks: recentUplinks.slice(0, 20), - }; + const byStatus = await this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }); + return { byStatus }; } } diff --git a/api/src/sending-monitor/sending-monitor.module.ts b/api/src/sending-monitor/sending-monitor.module.ts index 8c350c1..0789d62 100644 --- a/api/src/sending-monitor/sending-monitor.module.ts +++ b/api/src/sending-monitor/sending-monitor.module.ts @@ -125,7 +125,7 @@ export class SendingMonitorService { select: { id: true }, }); if (!channel) throw new NotFoundException('通道不存在'); - await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`; const old = await tx.$queryRawUnsafe>( `SELECT * FROM "SendingMonitorTarget" WHERE "channelId"=$1`, id, @@ -239,7 +239,7 @@ export class SendingMonitorService { const scopeKey = JSON.stringify(scope), type = monitorType(body.type); return this.prisma.$transaction(async (tx) => { - await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`; const old = await tx.$queryRawUnsafe>( `SELECT * FROM "SendingMonitorRule" WHERE type=$1 AND "scopeKey"=$2`, type, diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index ef8446e..64f4892 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -448,6 +448,7 @@ - 展示总发送量、成功率、通道健康度、待处理审核数。 - 发送监控展示通道状态、发送趋势、失败率、积压队列。 +- 2026-09-06调整:运行概况不再展示最近短信、最近状态报告、最近上行,聚合接口不再读取或返回这三组明细;独立记录页面保持可用。行业通道纳管与规则保存应真实持久化、记录版本和审计,冲突返回409,不能因事务锁返回类型导致500。 - 数据统计支持按企业、应用、通道、日期统计。 - 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。 - 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。 diff --git a/docs/sending-monitor-redesign-plan-20260906.md b/docs/sending-monitor-redesign-plan-20260906.md index 26198f4..4c0ffe3 100644 --- a/docs/sending-monitor-redesign-plan-20260906.md +++ b/docs/sending-monitor-redesign-plan-20260906.md @@ -46,7 +46,7 @@ | [upstream/deliver.go](../gateway/internal/upstream/deliver.go) | 回执事件DeliveredAt赋值time.Now().UTC() | 这是Gateway收到回执的时间,不是供应商DoneTime;不能将字段名直接理解为终端时间 | | [send-receipt.service.ts](../api/src/send-chain/send-receipt.service.ts) | 入站回执持久化到Inbox,再匹配处理;重复回执有receiptKey | 复用现有持久化与匹配结果,区分Gateway接收时间和API处理时间,避免处理积压扭曲5秒指标 | -旧需求[5.10运营看板与监控](first-version-development-requirements.md)及[TC-ADMIN-011](system-functional-test-cases.md)还要求发送趋势、最近发送/回执/上行、通道状态和积压。本次以三类质量监控为主视图,保留“运行概况”和详情跳转入口承接这些功能;不因当前页面未展示某旧需求就擅自删除它。统计逻辑不复用运营看板的分片到达率。 +旧需求[5.10运营看板与监控](first-version-development-requirements.md)及[TC-ADMIN-011](system-functional-test-cases.md)由三类质量监控及“运行概况”承接。按2026-09-06用户追加要求,运行概况删除最近发送/回执/上行三组列表,并删除其后端查询与响应字段;保留状态统计、通道列表及基础设施跳转。独立短信记录、状态报告和上行记录页面/API保持原有职责。统计逻辑不复用运营看板的分片到达率。 ## 4. 统一统计口径 @@ -158,7 +158,7 @@ R_h = S_h / N_h × 100% # N_h=0时为null - 摘要卡:监控维度数、异常维度数、样本不足维度数、窗口提交量(行业用“提交尝试”,其他用“业务短信”)。无数据时显示—/0并区别未配置、无发送、计算中与接口失败。 - 表格以异常优先:维度、窗口提交量、三个到达率、实际阈值/规则来源、状态、趋势/详情。各指标格包含百分比、成功/成熟数;低于阈值的格红色强调,观察中/样本不足中性显示,不只依赖颜色。 - 用户能筛选全部/异常/正常/样本不足/数据延迟;应用/签名支持带企业名的远程搜索、分页。筛选保存在URL,首次进入、刷新和告警深链接能还原。 -- 保留“运行概况”入口显示连接、积压及最近消息/回执/上行,不把业务启用标记等同实际连接健康。详情链接保持权限过滤。 +- 保留“运行概况”入口显示状态统计及通道信息,通过基础设施入口查看连接和积压;删除最近消息/回执/上行列表及对应明细查询,不把业务启用标记等同实际连接健康。 ### 7.2 趋势与告警详情 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 368810b..b478544 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -549,7 +549,7 @@ - 预期结果: - 看板展示任务数、发送状态分布、上行数、账务聚合,不重复展示通道连接或通道运行数据。 - 看板按当天真实短信记录展示“不含引流/含引流”两组签名统计;每行包含签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。 - - 监控展示最近发送、最近回执、最近上行。 + - 发送监控运行概况保留统计和通道列表,删除最近发送、最近回执、最近上行;聚合API仅返回byStatus,不读取三组明细(2026-09-06用户调整)。 - 过滤条件生效。 ### TC-ADMIN-011A 通道今日质量与日期通道占比 @@ -5172,3 +5172,9 @@ npm run verify:phase8 - SMR-024:测试环境三尺寸真实规则空态、默认禁用通过;受控请求失败后草稿保留、取消不写入通过。三个监控类型、告警、运行概况共15项页面检查通过;没有真实近期发送样本,不标记高峰或供应商链路验收完成。 - RRN-001~004、008:全新不可路由QA对象触发真实数据库事件、同小时2→3→4版本、双端独立已读及重新未读、三尺寸详情、顶栏跳转通过。RRN-005真实跨租户404、伪造头403、匿名401、非法page400通过。其余边界以真实PG隔离事务和单测证据逐项说明,不笼统称全套端到端通过。 - RRN-009:关闭期间迟到结果的新增组件回归通过,修复后真实双端6项交互通过。临时数据/客户端会话已精确清理,持久管理员保留。完整数字、版本、证据路径和未执行项见testing-progress.md最终验收节。 + +### SMR-027~029 运行概况与纳管保存回归(2026-09-06) + +- SMR-027:运行概况三尺寸首次进入、刷新和跨tab切换,三个最近列表均不存在;真实GET /admin/operations/monitor仅含byStatus,状态卡片/通道表/基础设施入口可用,独立记录页面保留。 +- SMR-028:真实Prisma加入、移除、重新加入及规则保存,版本和审计持久化;同版本并发仅一次成功,另一次409;不存在通道404,非法enabled/version400;审计失败回滚当前行和版本行。真实PG隔离schema验证,不用mock掩盖void返回类型。 +- SMR-029:测试环境人工生成并标注“测试演示”的三类监控已恢复/已关闭告警,列表、筛选、详情和历史曲线来自真实API/PG;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 55e1c9a..40641f8 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4638,3 +4638,11 @@ git diff --check - 19:57:46最终8项应用服务均active/Result=success/NRestarts=0;API/Gateway健康。监控检查点每5秒更新,complete=true、poolMax=2、pendingReceipts=0,stderr为0字节,API/监控服务发布窗口warning以上journal为空。三个Stream pending/lag均0;命令last-delivered=1787806802603-0/entries-read=130918,结果1787806917609-0/191696,协议日志1787806802967-0/40472。Gateway desired6/connected0保持发布前基线,未擅自调整供应商连接配置。 - 线上主JS index-CcT-At79.js SHA-256=3efb6da4e5ec852c86930c0a09089d34940170f7e9cf9271ec2c1961e4a8068d;主CSS index-Oaoh-Ppy.css SHA-256=c42f481be579764585c3e993ed91d2ea4b3f3c297ccbfd866b0a3358a34d8a0ae;真实HTTP下载与已构建运行文件一致。自动检查最终前端20套100项、API59套638项、真实PG隔离事务20项、Go全量/vet、类型/构建/格式/样式/CSS治理等通过。 - 证据目录:本机%TEMP%/cmpp-monitor-20260906内browser-admin.json、browser-notification-again.json、monitor各页/规则/双端通知三尺寸截图及各验证日志;认证不进入报告。低负载只读API样本15~27ms,不能代替高峰P95。未执行真实短信投递、500TPS容量测试、长时间压力或实际备份恢复演练。预生产本轮未访问、未部署。资料池结论仍为调查:已经生成且pendingReport=false的对象不会因组新增通道自动重入池,未修改此规则。 + +## 2026-09-06 运行概况裁剪与监控配置500修复(发布前) + +- 用户授权修改、提交、推送和测试发布,并追加测试告警数据;本轮不访问预生产。起始main/本地/远端/测试标记f059674,远端重新fetch后0/0。原8份修改文档、3份未跟踪文件保护;用例/进度只暂存本轮精确变更。 +- 20:10只读诊断:测试API日志显示20:05:54及20:09:06的Prisma P2010/UnsupportedNativeDataType void,指向target事务锁;独立只读事务SELECT pg_advisory_xact_lock真实复现,无业务写入。规则保存也有同样写法。浏览器确认运行概况仍有三个最近列表,monitor响应含byStatus和三组recent字段,targets正常返回7项。 +- 修复:两处仅用于加事务锁的$queryRaw改为$executeRaw,锁粒度、参数绑定、事务、版本和审计语义保留。删除运行概况三组表格及后端明细查询/响应;保留byStatus聚合与独立记录页面接口。uplink查询文件按现有格式门禁格式化并移除原拆分遗留的47项未使用导入,其他上行查询逻辑不变。无CSS/Gateway/数据库迁移或发送链路修改。 +- 验证:前端20套100项、API60套639项,前后端TypeScript/构建、格式/ESLint及结构/安全/部署/包体门禁通过。真实服务+Prisma在独立qa_monitor_config随机schema执行12项:加入、重复版本冲突、并发移除、重新加入、非法参数/不存在通道、规则保存及并发、非法样本、审计失败双路径回滚;结束删除自己的临时schema,公共业务表写入0。旧方式只读复现P2010,修复后真实保存通过,弥补上一轮只验证规则空态和故障拦截的不足。 +- 数据计划:按用户授权新增6条明确标注“测试演示”的历史告警(三种类型×已恢复/已关闭)及趋势快照,演示异常→恢复/关闭,阈值只在历史快照中,不填全局规则,不改余额/客户/通道配置,不发送短信。告警页面真实读取演示行只证明展示和交互,不替代自动采集/告警生命周期验收;发布后记录实际生成与浏览器证据。 diff --git a/src/apps/admin/MonitorRuntimeOverview.tsx b/src/apps/admin/MonitorRuntimeOverview.tsx index 92b5919..79ab704 100644 --- a/src/apps/admin/MonitorRuntimeOverview.tsx +++ b/src/apps/admin/MonitorRuntimeOverview.tsx @@ -115,29 +115,6 @@ export function MonitorRuntimeOverview() {
- {[ - ['recentMessages', '最近短信', '/admin/sms-records'], - ['recentReceipts', '最近状态报告', '/admin/sms-records'], - ['recentUplinks', '最近上行', '/admin/sms-uplink-records'], - ].map(([key, label, to]) => ( -
-

{label}

- 查看全部 -
[]) : []} - columns={[ - { key: 'messageId', title: '消息编号', render: (r) => String(r.messageId ?? r.gatewayMessageId ?? r.id) }, - { key: 'status', title: '状态', render: (r) => String(r.status ?? r.receiptStatus ?? '—') }, - { - key: 'time', - title: '时间', - render: (r) => new Date(String(r.queuedAt ?? r.deliveredAt ?? r.createdAt)).toLocaleString('zh-CN'), - }, - ]} - /> - - ))} ); } diff --git a/tools/testing/verify-monitor-config-postgres.mjs b/tools/testing/verify-monitor-config-postgres.mjs new file mode 100644 index 0000000..0054b16 --- /dev/null +++ b/tools/testing/verify-monitor-config-postgres.mjs @@ -0,0 +1,103 @@ +// Runs real service/Prisma operations in a uniquely named schema; never writes public business tables. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import pg from '../../api/node_modules/pg/lib/index.js'; +import prisma from '../../api/node_modules/@prisma/client/default.js'; +import adapter from '../../api/node_modules/@prisma/adapter-pg/dist/index.js'; +import service from '../../api/dist/sending-monitor/sending-monitor.module.js'; + +process.env.TZ = 'UTC'; +const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL; +if (!connectionString) throw Error('QA_DATABASE_URL is required'); +const schema = `qa_monitor_config_${randomUUID().replaceAll('-', '')}`; +assert.match(schema, /^qa_monitor_config_[a-f0-9]{32}$/); +const admin = new pg.Client({ connectionString }); +let client; +let checks = 0; +await admin.connect(); +try { + await admin.query(`CREATE SCHEMA "${schema}"`); + for (const table of [ + 'SmsChannel', + 'SendingMonitorTarget', + 'SendingMonitorTargetVersion', + 'SendingMonitorRule', + 'SendingMonitorRuleVersion', + 'SendingMonitorAlert', + 'OperationLog', + ]) + await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`); + await admin.query( + `INSERT INTO "${schema}"."SmsChannel" SELECT * FROM public."SmsChannel" WHERE status<>'deleted' LIMIT 1`, + ); + const pool = new pg.Pool({ connectionString, max: 2, options: `-c search_path=${schema} -c timezone=UTC` }); + client = new prisma.PrismaClient({ adapter: new adapter.PrismaPg(pool, { schema, disposeExternalPool: true }) }); + assert.equal((await client.$queryRawUnsafe('SELECT current_schema() name'))[0].name, schema); + const sut = new service.SendingMonitorService(client); + const channel = await client.smsChannel.findFirstOrThrow(); + const actor = 'qa-monitor-config'; + assert.equal(await client.sendingMonitorTarget.count(), 0); + const joined = await sut.target(channel.id, { enabled: true, version: 0 }, actor); + assert.equal(joined[0].enabled, true); + assert.equal(joined[0].version, 1); + assert.equal(await client.sendingMonitorTargetVersion.count(), 1); + checks++; + await assert.rejects(sut.target(channel.id, { enabled: true, version: 0 }, actor), (e) => e.getStatus?.() === 409); + assert.equal(await client.operationLog.count(), 1); + checks++; + const race = await Promise.allSettled([ + sut.target(channel.id, { enabled: false, version: 1 }, actor), + sut.target(channel.id, { enabled: false, version: 1 }, actor), + ]); + assert.equal(race.filter((r) => r.status === 'fulfilled').length, 1); + assert.equal(race.find((r) => r.status === 'rejected').reason.getStatus(), 409); + assert.equal(await client.sendingMonitorTargetVersion.count(), 2); + checks++; + await sut.target(channel.id, { enabled: true, version: 2 }, actor); + assert.equal((await client.sendingMonitorTarget.findUniqueOrThrow({ where: { channelId: channel.id } })).version, 3); + checks++; + for (const [id, body, status] of [ + ['missing', { enabled: true, version: 0 }, 404], + [channel.id, { enabled: 'true', version: 0 }, 400], + [channel.id, { enabled: true, version: -1 }, 400], + ]) { + await assert.rejects(sut.target(id, body, actor), (e) => e.getStatus?.() === status); + checks++; + } + const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 }; + const body = { type: 'industry', scope: {}, config, version: 0 }; + const rules = await sut.saveRule(body, actor); + assert.equal(rules[0].version, 1); + assert.equal(await client.sendingMonitorRuleVersion.count(), 1); + checks++; + const ruleRace = await Promise.allSettled([ + sut.saveRule({ ...body, version: 1 }, actor), + sut.saveRule({ ...body, version: 1 }, actor), + ]); + assert.equal(ruleRace.filter((r) => r.status === 'fulfilled').length, 1); + assert.equal(ruleRace.find((r) => r.status === 'rejected').reason.getStatus(), 409); + assert.equal(await client.sendingMonitorRuleVersion.count(), 2); + assert.equal(await client.operationLog.count(), 5); + checks++; + await assert.rejects( + sut.saveRule({ ...body, config: { ...config, minSamples: 0 } }, actor), + (e) => e.getStatus?.() === 400, + ); + checks++; + // A later audit failure must roll back both the current row and immutable version. + await client.$executeRawUnsafe( + `ALTER TABLE "OperationLog" ADD CONSTRAINT qa_reject CHECK ("resource"<>'sending_monitor') NOT VALID`, + ); + await assert.rejects(sut.target(channel.id, { enabled: false, version: 3 }, actor)); + assert.equal((await client.sendingMonitorTarget.findUniqueOrThrow({ where: { channelId: channel.id } })).version, 3); + assert.equal(await client.sendingMonitorTargetVersion.count(), 3); + checks++; + await assert.rejects(sut.saveRule({ ...body, version: 2 }, actor)); + assert.equal(await client.sendingMonitorRuleVersion.count(), 2); + checks++; + console.log(JSON.stringify({ checks, realPrisma: true, schemaIsolated: true, businessWrites: 0 })); +} finally { + if (client) await client.$disconnect(); + await admin.query(`DROP SCHEMA "${schema}" CASCADE`); + await admin.end(); +}