import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'; import { NupstSnmp } from '../ts/snmp/manager.ts'; import { UpsOidSets } from '../ts/snmp/oid-sets.ts'; import type { ISnmpConfig, TUpsModel, IOidSet } from '../ts/snmp/types.ts'; import { shortId } from '../ts/helpers/shortid.ts'; import { TIMING, SNMP, THRESHOLDS, HTTP_SERVER, UI } from '../ts/constants.ts'; import { Action, type IActionContext } from '../ts/actions/base-action.ts'; import * as qenv from 'npm:@push.rocks/qenv@^6.0.0'; const testQenv = new qenv.Qenv('./', '.nogit/'); // ============================================================================= // UNIT TESTS - No external dependencies required // ============================================================================= // ----------------------------------------------------------------------------- // shortId() Tests // ----------------------------------------------------------------------------- Deno.test('shortId: generates 6-character string', () => { const id = shortId(); assertEquals(id.length, 6); }); Deno.test('shortId: contains only alphanumeric characters', () => { const id = shortId(); const alphanumericRegex = /^[a-zA-Z0-9]+$/; assert(alphanumericRegex.test(id), `ID "${id}" contains non-alphanumeric characters`); }); Deno.test('shortId: generates unique IDs', () => { const ids = new Set(); const count = 100; for (let i = 0; i < count; i++) { ids.add(shortId()); } // All IDs should be unique (statistically extremely likely for 100 IDs) assertEquals(ids.size, count, 'Generated IDs should be unique'); }); // ----------------------------------------------------------------------------- // Constants Tests // ----------------------------------------------------------------------------- Deno.test('TIMING constants: all values are positive numbers', () => { for (const [key, value] of Object.entries(TIMING)) { assert(typeof value === 'number', `TIMING.${key} should be a number`); assert(value > 0, `TIMING.${key} should be positive`); } }); Deno.test('SNMP constants: port is 161', () => { assertEquals(SNMP.DEFAULT_PORT, 161); }); Deno.test('SNMP constants: timeouts increase with security level', () => { assert(SNMP.TIMEOUT_NO_AUTH_MS <= SNMP.TIMEOUT_AUTH_MS, 'Auth timeout should be >= noAuth timeout'); assert(SNMP.TIMEOUT_AUTH_MS <= SNMP.TIMEOUT_AUTH_PRIV_MS, 'AuthPriv timeout should be >= Auth timeout'); }); Deno.test('THRESHOLDS constants: defaults are reasonable', () => { assert(THRESHOLDS.DEFAULT_BATTERY_PERCENT > 0 && THRESHOLDS.DEFAULT_BATTERY_PERCENT <= 100); assert(THRESHOLDS.DEFAULT_RUNTIME_MINUTES > 0); assert(THRESHOLDS.EMERGENCY_RUNTIME_MINUTES < THRESHOLDS.DEFAULT_RUNTIME_MINUTES); }); Deno.test('HTTP_SERVER constants: valid defaults', () => { assertEquals(HTTP_SERVER.DEFAULT_PORT, 8080); assert(HTTP_SERVER.DEFAULT_PATH.startsWith('/')); }); Deno.test('UI constants: box widths are ascending', () => { assert(UI.DEFAULT_BOX_WIDTH < UI.WIDE_BOX_WIDTH); assert(UI.WIDE_BOX_WIDTH < UI.EXTRA_WIDE_BOX_WIDTH); }); // ----------------------------------------------------------------------------- // UpsOidSets Tests // ----------------------------------------------------------------------------- const UPS_MODELS: TUpsModel[] = ['cyberpower', 'apc', 'eaton', 'tripplite', 'liebert', 'custom']; Deno.test('UpsOidSets: all models have OID sets', () => { for (const model of UPS_MODELS) { const oidSet = UpsOidSets.getOidSet(model); assertExists(oidSet, `OID set for ${model} should exist`); } }); Deno.test('UpsOidSets: all non-custom models have complete OIDs', () => { const requiredOids = ['POWER_STATUS', 'BATTERY_CAPACITY', 'BATTERY_RUNTIME', 'OUTPUT_LOAD']; for (const model of UPS_MODELS.filter(m => m !== 'custom')) { const oidSet = UpsOidSets.getOidSet(model); for (const oid of requiredOids) { const value = oidSet[oid as keyof IOidSet]; assert( typeof value === 'string' && value.length > 0, `${model} should have non-empty ${oid}` ); } } }); Deno.test('UpsOidSets: power status values defined for non-custom models', () => { for (const model of UPS_MODELS.filter(m => m !== 'custom')) { const oidSet = UpsOidSets.getOidSet(model); assertExists(oidSet.POWER_STATUS_VALUES, `${model} should have POWER_STATUS_VALUES`); assertExists(oidSet.POWER_STATUS_VALUES?.online, `${model} should have online value`); assertExists(oidSet.POWER_STATUS_VALUES?.onBattery, `${model} should have onBattery value`); } }); Deno.test('UpsOidSets: getStandardOids returns RFC 1628 OIDs', () => { const standardOids = UpsOidSets.getStandardOids(); assert('power status' in standardOids); assert('battery capacity' in standardOids); assert('battery runtime' in standardOids); // RFC 1628 OIDs start with 1.3.6.1.2.1.33 for (const oid of Object.values(standardOids)) { assert(oid.startsWith('1.3.6.1.2.1.33'), `Standard OID should be RFC 1628: ${oid}`); } }); // ----------------------------------------------------------------------------- // Action Base Class Tests // ----------------------------------------------------------------------------- // Create a concrete implementation for testing class TestAction extends Action { readonly type = 'test'; executeCallCount = 0; execute(_context: IActionContext): Promise { this.executeCallCount++; return Promise.resolve(); } // Expose protected methods for testing public testShouldExecute(context: IActionContext): boolean { return this.shouldExecute(context); } public testAreThresholdsExceeded(batteryCapacity: number, batteryRuntime: number): boolean { return this.areThresholdsExceeded(batteryCapacity, batteryRuntime); } } function createMockContext(overrides: Partial = {}): IActionContext { return { upsId: 'test-ups', upsName: 'Test UPS', powerStatus: 'online', batteryCapacity: 100, batteryRuntime: 60, previousPowerStatus: 'online', timestamp: Date.now(), triggerReason: 'powerStatusChange', ...overrides, }; } Deno.test('Action.areThresholdsExceeded: returns false when no thresholds configured', () => { const action = new TestAction({ type: 'shutdown' }); assertEquals(action.testAreThresholdsExceeded(50, 30), false); }); Deno.test('Action.areThresholdsExceeded: returns true when battery below threshold', () => { const action = new TestAction({ type: 'shutdown', thresholds: { battery: 60, runtime: 20 }, }); assertEquals(action.testAreThresholdsExceeded(59, 30), true); // Battery below assertEquals(action.testAreThresholdsExceeded(60, 30), false); // Battery at threshold assertEquals(action.testAreThresholdsExceeded(100, 30), false); // Battery above }); Deno.test('Action.areThresholdsExceeded: returns true when runtime below threshold', () => { const action = new TestAction({ type: 'shutdown', thresholds: { battery: 60, runtime: 20 }, }); assertEquals(action.testAreThresholdsExceeded(100, 19), true); // Runtime below assertEquals(action.testAreThresholdsExceeded(100, 20), false); // Runtime at threshold assertEquals(action.testAreThresholdsExceeded(100, 60), false); // Runtime above }); Deno.test('Action.shouldExecute: onlyPowerChanges mode', () => { const action = new TestAction({ type: 'shutdown', triggerMode: 'onlyPowerChanges', }); assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), true ); assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })), false ); }); Deno.test('Action.shouldExecute: onlyThresholds mode', () => { const action = new TestAction({ type: 'shutdown', triggerMode: 'onlyThresholds', thresholds: { battery: 60, runtime: 20 }, }); // Below thresholds - should execute assertEquals( action.testShouldExecute(createMockContext({ batteryCapacity: 50, batteryRuntime: 10 })), true ); // Above thresholds - should not execute assertEquals( action.testShouldExecute(createMockContext({ batteryCapacity: 100, batteryRuntime: 60 })), false ); }); Deno.test('Action.shouldExecute: onlyThresholds mode without thresholds returns false', () => { const action = new TestAction({ type: 'shutdown', triggerMode: 'onlyThresholds', // No thresholds configured }); assertEquals(action.testShouldExecute(createMockContext()), false); }); Deno.test('Action.shouldExecute: powerChangesAndThresholds mode (default)', () => { const action = new TestAction({ type: 'shutdown', thresholds: { battery: 60, runtime: 20 }, // No triggerMode = defaults to powerChangesAndThresholds }); // Power change - should execute assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), true ); // Threshold violation - should execute assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation', batteryCapacity: 50, })), true ); // No power change and above thresholds - should not execute assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation', batteryCapacity: 100, batteryRuntime: 60, })), false ); }); Deno.test('Action.shouldExecute: anyChange mode always returns true', () => { const action = new TestAction({ type: 'shutdown', triggerMode: 'anyChange', }); assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'powerStatusChange' })), true ); assertEquals( action.testShouldExecute(createMockContext({ triggerReason: 'thresholdViolation' })), true ); }); // ----------------------------------------------------------------------------- // NupstSnmp Class Tests (Unit tests - no real UPS needed) // ----------------------------------------------------------------------------- Deno.test('NupstSnmp: can be instantiated', () => { const snmp = new NupstSnmp(false); assertExists(snmp); }); Deno.test('NupstSnmp: debug mode can be enabled', () => { const snmpDebug = new NupstSnmp(true); const snmpNormal = new NupstSnmp(false); assertExists(snmpDebug); assertExists(snmpNormal); }); // ============================================================================= // INTEGRATION TESTS - Require real UPS (loaded from .nogit/env.json) // ============================================================================= // Helper function to run UPS test with config async function testUpsConnection( snmp: NupstSnmp, config: Record, description: string, ): Promise { console.log(`Testing ${description}...`); const snmpConfig = config.snmp as ISnmpConfig; console.log('SNMP Config:'); console.log(` Host: ${snmpConfig.host}:${snmpConfig.port}`); console.log(` Version: SNMPv${snmpConfig.version}`); console.log(` UPS Model: ${snmpConfig.upsModel}`); // Use a reasonable timeout for testing const testSnmpConfig = { ...snmpConfig, timeout: Math.min(snmpConfig.timeout, SNMP.MAX_TEST_TIMEOUT_MS), }; const status = await snmp.getUpsStatus(testSnmpConfig); console.log('UPS Status:'); console.log(` Power Status: ${status.powerStatus}`); console.log(` Battery Capacity: ${status.batteryCapacity}%`); console.log(` Runtime Remaining: ${status.batteryRuntime} minutes`); // Validate response structure assertExists(status, 'Status should exist'); assert( ['online', 'onBattery', 'unknown'].includes(status.powerStatus), `Power status should be valid: ${status.powerStatus}` ); assertEquals(typeof status.batteryCapacity, 'number', 'Battery capacity should be a number'); assertEquals(typeof status.batteryRuntime, 'number', 'Battery runtime should be a number'); // Validate ranges assert( status.batteryCapacity >= 0 && status.batteryCapacity <= 100, `Battery capacity should be 0-100: ${status.batteryCapacity}` ); assert(status.batteryRuntime >= 0, `Battery runtime should be non-negative: ${status.batteryRuntime}`); } // Create SNMP instance for integration tests const snmp = new NupstSnmp(true); // Load test configurations const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1'); const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3'); Deno.test('Integration: Real UPS test v1', async () => { await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection'); }); Deno.test('Integration: Real UPS test v3', async () => { await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection'); });