Files
MikrotikManager/backend/scripts/test-flow-read-adapters.mjs
T

65 lines
6.4 KiB
JavaScript

import assert from 'node:assert/strict'
import ts from 'typescript'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
const require=createRequire(import.meta.url),zod=require('zod'),here=import.meta.dirname
function evaluate(source,mocks={}){
const exports={},module={exports}
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText
new Function('require','exports','module',code)(id=>{if(id==='zod')return zod;if(Object.hasOwn(mocks,id))return mocks[id];throw Error('Unexpected dependency '+id)},exports,module)
return module.exports
}
const contracts=evaluate(readFileSync(resolve(here,'../../packages/contracts/src/traffic-flow.ts'),'utf8'))
let quality=null,slotReleases=0
const qualityHelper=evaluate(readFileSync(resolve(here,'../src/modules/mcp/flow-read-quality.ts'),'utf8'),{
'@mmapp/contracts/traffic-flow':contracts,'../../services/traffic-flow-ingest.js':{getFlowRuntimeCounters:async()=>({dataQuality:quality})},'./traffic-history-actions.js':{assertTrafficHistoryAccess:async()=>{}},
})
const server={id:1,name:'selected',type:'jump-host'},dto={bpsNow:0,bpsWire:0,bpsOverlay:0,bytesWire:0,live:false,rxSeries:[],txSeries:[],services:[]}
const window={rows:[],topRowsPerBucketLimit:20,topConversationsLimit:20,since:'from',until:'to'}
class McpError extends Error{constructor(code,message,statusCode){super(message);this.code=code;this.statusCode=statusCode}}
const mocks={
'./flow-read-quality.js':qualityHelper,
'@mmapp/contracts/traffic-flow':{...contracts,flowAnalyticsDtoSchema:{parse:x=>x},flowMapHopsDtoSchema:{parse:x=>x}},
'../../services/traffic-flow-analytics.js':{buildFlowAnalytics:async()=>dto,getFlowMonthly:async()=>({month:'2026-10',bytes:0,countries:[],services:[],asns:[]}),safeBuildLiveFlowSample:async()=>({event:'sample',data:dto}),LIVE_ANALYTICS_MINUTES:5},
'../../services/traffic-flow-map-hops.js':{buildFlowMapHops:async()=>({live:false,hops:[]})},
'../../services/traffic-flow-source-limits.js':{readFlowSourceLimits:async()=>({topN:20}),readFlowMapSourceLimits:async()=>({topN:20,mapServiceMinSharePct:5})},
'../servers/repository/servers-repository.js':{getServerRowById:async()=>server},
'../../services/traffic-flow-server-source.js':{getBoundedFlowServerRowById:async()=>server},
'./flow-source-scope.js':{assertBoundedFlowSource:async()=>{},flowCatalogDevices:async()=>({selected:[server],incomplete:false})},
'./traffic-history-actions.js':{assertTrafficHistoryAccess:async()=>{},assertTrafficHistorySource:async()=>{}},
'./security.js':{McpError},'node:util':require('node:util'),'node:timers/promises':require('node:timers/promises'),
'../../services/traffic-flow-live-slots.js':{FLOW_LIVE_TICK_MS:2000,tryAcquireFlowLiveSlot:()=>true,releaseFlowLiveSlot:()=>slotReleases++},
'../../services/traffic-flow-settings.js':{getTrafficFlowSettingsRow:async()=>({topN:20})},
'../../services/traffic-flow-ifindex.js':{resolveIfaceName:()=>''},
'../../services/traffic-flow-talkers-model.js':{projectFlowTalkers:()=>({talkers:[],totalConversations:0})},
'../../services/traffic-flow-catalog-read.js':{readFlowCatalogWindow:async()=>window,readFlowExporterInputs:async()=>({window,catalog:{list:[server]},configuredIds:new Set([1]),configurationMembershipVerified:true}),readFlowClientInputs:async()=>({users:[],total:0,window}),observeFlowExporter:()=>({id:'1'}),observeFlowClient:()=>({})},
'./catalog-scope.js':{catalogPaging:zod.z.object({offset:zod.z.number().default(0),limit:zod.z.number().default(50)}),catalogDevices:async()=>({selected:[server],incomplete:false})},
}
const actions=[]
for(const [file,method] of [['flow-catalog-actions.ts','registerFlowCatalogActions'],['flow-analytics-actions.ts','registerFlowAnalyticsActions'],['flow-map-actions.ts','registerFlowMapActions'],['flow-live-actions.ts','registerFlowLiveActions']]){
const actionModule=evaluate(readFileSync(resolve(here,'../src/modules/mcp',file),'utf8'),mocks)
actionModule[method]((...args)=>actions.push({id:args[0],schema:args[7],read:args[8].read}))
}
assert.equal(actions.length,7)
const base={version:1,collectorEpoch:'00000000-0000-4000-8000-000000000001',scope:'collector-global',exportCoverage:'unverified',localAdmission:'partial',records:'1',bytes:'10',packets:'1',knownLowerBounds:{records:'1',bytes:'10',packets:'1'},firstAt:'2026-10-29T00:00:00.000Z',lastAt:'2026-10-29T00:00:00.000Z',intervalUnknown:false,reasons:{'aggregate-capacity':true,'ring-capacity':false,'key-width':false,'unsafe-numeric':false},counterOverflow:false,rawRowsPolicy:'topN-and-pending-cap-not-complete',lifetimeScope:'current-collector-isolate'}
const clean={...base,localAdmission:'no-local-loss-observed',records:'0',bytes:'0',packets:'0',knownLowerBounds:{records:'0',bytes:'0',packets:'0'},firstAt:null,lastAt:null,reasons:{'aggregate-capacity':false,'ring-capacity':false,'key-width':false,'unsafe-numeric':false}}
const cases=[null,base,{...base,localAdmission:'loss-quantity-unknown',bytes:null},{...base,localAdmission:'no-local-loss-observed'},clean]
let count=0
for(const globalAllowed of [false,true])for(const state of cases){
quality=state
for(const action of actions){
const input=action.schema.parse(action.id==='traffic.flow.monthly.read'?{month:'2026-10'}:{serverId:1}),context={connection:{policy:{scope:{serverIds:[1],allowGlobal:globalAllowed,operationIds:globalAllowed?['netflow.settings.read']:[]}}}}
const output=await action.read(input,context),expected=contracts.flowIngestQualitySchema.safeParse(globalAllowed?state:null)
assert.deepEqual(output.dataQuality,expected.success?expected.data:null,action.id)
assert.equal(output.collectorQualityAvailable,expected.success,action.id)
assert.equal(output.collectorQualityScope,'current-collector-isolate',action.id)
assert.equal(output.incomplete,['traffic.flow.talkers.read','traffic.flow.analytics.read','traffic.flow.map.read','traffic.flow.live.read'].includes(action.id),action.id+' preserves original result completeness')
assert.equal(output.collectorObservationIncomplete,!expected.success||expected.data.localAdmission!=='no-local-loss-observed',action.id)
assert.equal(output.coverageVerified,false,action.id);assert.equal(output.exportCoverage,'unverified',action.id)
count++
}
}
assert.equal(slotReleases,10)
console.log('PASS '+count+' actual registered MCP read adapter invocations: unavailable, partial, unknown quantities and invalid healthy claim; inert source/auth adapters; real semantic quality schema')