fix(alerts): показать подтверждённое состояние очереди сообщений

This commit is contained in:
Denozordec committed 2026-10-03 19:09:42 +07:00
1 parent b1ad9515fa
commit 89c31289bd
24 files changed
+166 -22

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
"test:alerts-telegram": "tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
"test:alerts-telegram": "tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
+3 -3
View File
@@ -8,11 +8,11 @@ const {default:authPlugin,requireAuth}=await import("./auth.js")
const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js")
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
let writes=0,sends=0,reads=0
await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>({revision,telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++;return {ok:true as const,revision,groups:[],rules:[]}}}),{prefix:"/api"})
await app.register(createAlertsRoutes({...defaultAlertsDependencies,outbox:async()=>({counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}),overview:async()=>({revision,telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++;return {ok:true as const,revision,groups:[],rules:[]}}}),{prefix:"/api"})
for(const permission of ["mm:alerts:read","mm:alerts:write","mm:alerts:admin","mm:settings:admin"]){
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"if-match":'"'+revision+'"'},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
for(const [method,url] of [["GET","/api/alerts"],["GET","/api/alerts/outbox"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"if-match":'"'+revision+'"'},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
}
assert.equal(writes,4);assert.equal(sends,2);assert.equal(reads,2)
for(const [method,url] of [["GET","/api/alerts"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
for(const [method,url] of [["GET","/api/alerts"],["GET","/api/alerts/outbox"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
assert.equal(sends,2);await app.close();console.log("Alerts JWT: real read/write/admin gates before save/send/read, no token echo")
+3 -2
View File
@@ -4,9 +4,9 @@ import assert from "node:assert/strict"
import Fastify from "fastify"
import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js"
import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js"
let replacements=0,replaceConflict=false,replaceStale=false,overviewMalformed=false
let replacements=0,replaceConflict=false,replaceStale=false,overviewMalformed=false,outboxMalformed=false
let stored={botToken:"fixture-secret",chatId:"123",messageThreadId:10 as number|null},reads=0,writes=0,sends=0,failSend=false,failWrite=false
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>overviewMalformed?{}:({revision,telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceStale)throw Error("Правила изменились на сервере. Обновите снимок.");if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,revision,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,outbox:async()=>outboxMalformed?{}:({counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}),overview:async()=>overviewMalformed?{}:({revision,telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceStale)throw Error("Правила изменились на сервере. Обновите снимок.");if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,revision,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const test=()=>app.inject({method:"POST",url:"/api/alerts/telegram/test",payload:{}})
let response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,200);assert.equal(response.body.includes("fixture-secret"),false);assert.deepEqual(response.json().rules,[]);assert.equal(response.headers["cache-control"],"no-store");overviewMalformed=true;response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,500);assert.equal(response.json().ok,undefined);overviewMalformed=false
response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1)
@@ -21,4 +21,5 @@ for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...in
replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined)
replaceConflict=false;for(const headers of [{},{"if-match":"*"},{"if-match":revision}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers,payload:{groups:[],rules:[]}});assert.equal(response.statusCode,Object.keys(headers).length?400:428)}assert.equal(replacements,1)
replaceStale=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{groups:[],rules:[]}});assert.equal(response.statusCode,412);assert.equal(replacements,2);assert.equal(response.json().ok,undefined)
response=await app.inject({method:"GET",url:"/api/alerts/outbox"});assert.equal(response.statusCode,200);assert.equal(response.headers["cache-control"],"no-store");assert.equal(response.json().counts.total,0);outboxMalformed=true;response=await app.inject({method:"GET",url:"/api/alerts/outbox"});assert.equal(response.statusCode,500);assert.equal(response.json().counts,undefined);
await app.close();console.log("Alerts HTTP: one stored test snapshot, strict numeric thread, selective save, unknown send/no retry, missing returning/no success, no token echo")
+10 -3
View File
@@ -1,3 +1,4 @@
import {getAlertsOutboxOverview} from "../services/alert-engine/outbox.js"
import {sendTelegramRequest} from "../services/alerts-telegram-send.js"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
@@ -8,7 +9,7 @@ import {
updateTelegramSettings,
} from "../services/alerts-service.js"
import {
alertsResponseSchema,
alertsResponseSchema, alertsOutboxOverviewSchema,
putRulesSchema,
normalizeAlertsConfig,
putTelegramSchema,
@@ -17,7 +18,7 @@ import {
} from "@mmapp/contracts/alerts"
interface Dependencies {
overview:()=>Promise<unknown>;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings
outbox:()=>Promise<unknown>;overview:()=>Promise<unknown>;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings
readTelegram:()=>Promise<{botToken:string;chatId:string;messageThreadId:number|null}>
send:(input:{token:string;chatId:string;text:string;messageThreadId?:number})=>Promise<{ok:true}|{ok:false;error:string}>
}
@@ -28,6 +29,12 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
return reply.header("Cache-Control","no-store").send(result.data)
})
app.get("/alerts/outbox",async(_req,reply)=>{
const parsed=alertsOutboxOverviewSchema.safeParse(await deps.outbox())
if(!parsed.success)return reply.status(500).send({error:"Состояние очереди не подтверждено"})
return reply.header("Cache-Control","no-store").send(parsed.data)
})
app.put("/alerts/rules", async (req, reply) => {
const parsed = putRulesSchema.safeParse(req.body)
if (!parsed.success) {
@@ -111,5 +118,5 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
}
}
export const defaultAlertsDependencies:Dependencies={overview:getAlertsOverview,replace:replaceAlertsConfig,writeTelegram:updateTelegramSettings,readTelegram:getTelegramSettingsRow,send:input=>sendTelegramRequest(input.token,{chat_id:input.chatId,text:input.text,...(input.messageThreadId!==undefined?{message_thread_id:input.messageThreadId}:{})})}
export const defaultAlertsDependencies:Dependencies={outbox:getAlertsOutboxOverview,overview:getAlertsOverview,replace:replaceAlertsConfig,writeTelegram:updateTelegramSettings,readTelegram:getTelegramSettingsRow,send:input=>sendTelegramRequest(input.token,{chat_id:input.chatId,text:input.text,...(input.messageThreadId!==undefined?{message_thread_id:input.messageThreadId}:{})})}
export default createAlertsRoutes(defaultAlertsDependencies)
+10 -1
View File
@@ -1,4 +1,5 @@
import {and,asc,eq,lte} from "drizzle-orm"
import {readOutboxOverview} from "../alerts-outbox-overview.js"
import {and,asc,desc,eq,inArray,lte,sql} from "drizzle-orm"
import {db} from "../../db/index.js"
import {alertOutbox,alertHistory} from "../../db/schema.js"
import {sendTelegramAlertMessage} from "../alerts-service.js"
@@ -38,3 +39,11 @@ export async function dispatchPendingOutbox(limit=25):Promise<{sent:number;faile
}
return {sent,failed,held,errors}
}
export async function getAlertsOutboxOverview(){
return readOutboxOverview<Parameters<Parameters<typeof db.transaction>[0]>[0]>({
transaction:(work,options)=>db.transaction(work,options),
counts:async context=>{const result=await context.execute(sql`SELECT status, COUNT(*)::bigint AS count FROM alert_outbox GROUP BY status`);return result.rows},
items:async context=>{const rows=await context.select().from(alertOutbox).where(inArray(alertOutbox.status,["processing","unknown"])).orderBy(desc(alertOutbox.nextAttemptAt),desc(alertOutbox.id)).limit(20);return rows.map(row=>{const payload=row.payloadJson;const history=typeof payload==="object"&&payload!==null&&"history" in payload?payload.history:null;const ruleName=typeof history==="object"&&history!==null&&"ruleName" in history&&typeof history.ruleName==="string"?history.ruleName:null;return {id:row.id,status:row.status,ruleName,createdAt:row.createdAt,attemptedAt:row.nextAttemptAt}})},
})
}
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {outboxCounts,readOutboxOverview} from "./alerts-outbox-overview.js"
test("queue counts keep unknown database states, reject unsafe counts and read one context",async()=>{
assert.deepEqual(outboxCounts([]),{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0})
assert.equal(outboxCounts([{status:"legacy",count:2},{status:"unknown",count:1}]).unrecognized,2)
for(const raw of [[{status:"pending",count:-1}],[{status:"pending",count:"1"}],[{status:"sent",count:Number.MAX_SAFE_INTEGER},{status:"failed",count:1}]])assert.throws(()=>outboxCounts(raw))
const context={snapshot:1};let reads=0
const result=await readOutboxOverview({transaction:async(work,options)=>{assert.deepEqual(options,{isolationLevel:"repeatable read",accessMode:"read only"});return work(context)},counts:async ctx=>{assert.equal(ctx,context);reads++;return [{status:"pending",count:3}]},items:async ctx=>{assert.equal(ctx,context);reads++;return []}})
assert.equal(reads,2);assert.equal(result.counts.pending,3)
})
test("queue incomplete held list or failed read never produces empty success",async()=>{
for(const counts of [[{status:"unknown",count:1}],[]])await assert.rejects(readOutboxOverview({transaction:async work=>work({}),counts:async()=>counts,items:async()=>counts.length?[]:[{id:"a",status:"unknown",ruleName:null,createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T00:00:00Z"}]}),/не подтверждено/)
await assert.rejects(readOutboxOverview({transaction:async work=>work({}),counts:async()=>{throw Error("read failed")},items:async()=>[]}),/read failed/)
})
@@ -0,0 +1,16 @@
import {z} from "zod"
import {alertsOutboxOverviewSchema} from "@mmapp/contracts/alerts"
const countsSchema=z.array(z.object({status:z.string(),count:z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)}))
export function outboxCounts(raw:unknown){
const rows=countsSchema.parse(raw),counts={total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0}
for(const row of rows){const key=["pending","processing","unknown","sent","failed"].includes(row.status)?row.status as "pending"|"processing"|"unknown"|"sent"|"failed":"unrecognized";counts[key]+=row.count;counts.total+=row.count}
if(!Number.isSafeInteger(counts.total))throw Error("Число записей очереди не подтверждено")
return counts
}
export async function readOutboxOverview<C>(deps:{transaction:(work:(context:C)=>Promise<ReturnType<typeof alertsOutboxOverviewSchema.parse>>,options:{isolationLevel:"repeatable read";accessMode:"read only"})=>Promise<ReturnType<typeof alertsOutboxOverviewSchema.parse>>;counts:(context:C)=>Promise<unknown>;items:(context:C)=>Promise<unknown>}){
return deps.transaction(async context=>{
const parsed=alertsOutboxOverviewSchema.safeParse({counts:outboxCounts(await deps.counts(context)),items:await deps.items(context),itemsLimit:20})
if(!parsed.success)throw Error("Состояние очереди не подтверждено")
return parsed.data
},{isolationLevel:"repeatable read",accessMode:"read only"})
}