feat(core): Centralize timeouts/constants, add CLI prompt helpers, and introduce webhook/script actions with safety and SNMP refactors
This commit is contained in:
433
test/test.ts
433
test/test.ts
@@ -1,93 +1,368 @@
|
||||
import { assert, assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0';
|
||||
import { NupstSnmp } from '../ts/snmp/manager.ts';
|
||||
import type { ISnmpConfig } from '../ts/snmp/types.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/');
|
||||
|
||||
// Create an SNMP instance with debug enabled
|
||||
// =============================================================================
|
||||
// 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<string>();
|
||||
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<void> {
|
||||
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> = {}): 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<string, unknown>,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
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 the test configuration from .nogit/env.json
|
||||
// Load test configurations
|
||||
const testConfigV1 = await testQenv.getEnvVarOnDemandAsObject('testConfigV1');
|
||||
const testConfigV3 = await testQenv.getEnvVarOnDemandAsObject('testConfigV3');
|
||||
|
||||
Deno.test('should log config', () => {
|
||||
console.log(testConfigV1);
|
||||
assert(true);
|
||||
Deno.test('Integration: Real UPS test v1', async () => {
|
||||
await testUpsConnection(snmp, testConfigV1, 'SNMPv1 connection');
|
||||
});
|
||||
|
||||
// Test with real UPS using the configuration from .nogit/env.json
|
||||
Deno.test('Real UPS test v1', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfigV1.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 short timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
// Try to get the UPS status
|
||||
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`);
|
||||
|
||||
// Just make sure we got valid data types back
|
||||
assertExists(status);
|
||||
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
|
||||
assertEquals(typeof status.batteryCapacity, 'number');
|
||||
assertEquals(typeof status.batteryRuntime, 'number');
|
||||
} catch (error) {
|
||||
console.log('Real UPS test failed:', error);
|
||||
// Skip the test if we can't connect to the real UPS
|
||||
console.log('Skipping this test since the UPS might not be available');
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('Real UPS test v3', async () => {
|
||||
try {
|
||||
console.log('Testing with real UPS configuration...');
|
||||
|
||||
// Extract the correct SNMP config from the test configuration
|
||||
const snmpConfig = testConfigV3.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 short timeout for testing
|
||||
const testSnmpConfig = {
|
||||
...snmpConfig,
|
||||
timeout: Math.min(snmpConfig.timeout, 10000), // Use at most 10 seconds for testing
|
||||
};
|
||||
|
||||
// Try to get the UPS status
|
||||
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`);
|
||||
|
||||
// Just make sure we got valid data types back
|
||||
assertExists(status);
|
||||
assert(['online', 'onBattery', 'unknown'].includes(status.powerStatus));
|
||||
assertEquals(typeof status.batteryCapacity, 'number');
|
||||
assertEquals(typeof status.batteryRuntime, 'number');
|
||||
} catch (error) {
|
||||
console.log('Real UPS test failed:', error);
|
||||
// Skip the test if we can't connect to the real UPS
|
||||
console.log('Skipping this test since the UPS might not be available');
|
||||
}
|
||||
Deno.test('Integration: Real UPS test v3', async () => {
|
||||
await testUpsConnection(snmp, testConfigV3, 'SNMPv3 connection');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user