Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 2 additions & 33 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 102 additions & 13 deletions src/commands/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ import { registerBuiltinChannels } from "../alerts/builtins.js";

registerBuiltinChannels();

function buildTestEvent(contractId: string, thresholdLedgers: number) {
return buildAlertEvent({
type: "threshold_crossed",
contractId,
contractName: null,
network: "testnet",
entryKeyXdr: "TEST_ENTRY_KEY",
entryType: "instance",
entryLabel: "test-entry",
configuredLedgers: thresholdLedgers,
remainingTTL: Math.floor(thresholdLedgers * 0.5),
firedAtLedger: 0,
});
}

export function registerAlertsCommand(program: Command): void {
const alerts = program
.command("alerts")
Expand Down Expand Up @@ -184,6 +199,31 @@ export function registerAlertsCommand(program: Command): void {
console.log();
});

// ── alerts channels ────────────────────────────────────────────────
alerts
.command("channels")
.description("List all registered alert channel plugins")
.action(() => {
const channels = listAlertChannels();

if (channels.length === 0) {
console.log(chalk.yellow("No alert channels are registered."));
return;
}

console.log();
console.log(chalk.bold(" Registered Alert Channels"));
console.log();
for (const channel of channels) {
console.log(
` Name: ${chalk.cyan(channel.name.padEnd(12))} | ` +
`Target: ${chalk.yellow(channel.targetOption.padEnd(10))} | ` +
`Signing: ${chalk.green(channel.supportsSigning ? "yes" : "no")}`
);
}
console.log();
});

// ── alerts remove ──────────────────────────────────────────────────
alerts
.command("remove")
Expand Down Expand Up @@ -220,18 +260,7 @@ export function registerAlertsCommand(program: Command): void {
process.exit(1);
}

const testEvent = buildAlertEvent({
type: "threshold_crossed",
contractId: config.contract_id,
contractName: null,
network: "testnet",
entryKeyXdr: "TEST_ENTRY_KEY",
entryType: "instance",
entryLabel: "test-entry",
configuredLedgers: config.threshold_ledgers,
remainingTTL: Math.floor(config.threshold_ledgers * 0.5),
firedAtLedger: 0,
});
const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);

console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);

Expand All @@ -250,6 +279,66 @@ export function registerAlertsCommand(program: Command): void {
}
});

// ── alerts test-all ────────────────────────────────────────────────
alerts
.command("test-all")
.description("Send test alerts to every configured channel for a contract")
.requiredOption("--contract <id>", "The contract ID to test alerts for")
.action(async (options) => {
const contractId = options.contract;
const db = getDatabase();

const contract = getContract(db, contractId);
if (!contract) {
console.error(chalk.red(`Error: Contract ${formatContractID(contractId)} is not registered.`));
process.exit(1);
}

const configs = getAlertConfigsForContract(db, contractId);
if (configs.length === 0) {
console.log(chalk.yellow(`No alert configurations found for contract ${formatContractID(contractId)}.`));
return;
}

const results: Array<{ channelType: string; target: string; success: boolean; }> = [];

for (const config of configs) {
const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);
console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);

const success = await deliverSingleAlert(
config.channel_type,
config.channel_target,
testEvent,
config.webhook_secret,
);

results.push({
channelType: config.channel_type,
target: config.channel_target,
success,
});
}
Comment on lines +305 to +321

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider parallelizing channel tests and guarding against a hanging channel.

Channels are tested one at a time with a blocking await inside the loop. deliverSingleAlert/the underlying channel.send() don't appear to enforce a timeout, so one slow or unresponsive channel delays reporting the status of every subsequent channel — for a contract with several channels this can make test-all hang far longer than necessary, undermining the "quick connectivity check" purpose of the command.

♻️ Suggested parallelization
-            const results: Array<{ channelType: string; target: string; success: boolean; }> = [];
-
-            for (const config of configs) {
-                const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);
-                console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);
-
-                const success = await deliverSingleAlert(
-                    config.channel_type,
-                    config.channel_target,
-                    testEvent,
-                    config.webhook_secret,
-                );
-
-                results.push({
-                    channelType: config.channel_type,
-                    target: config.channel_target,
-                    success,
-                });
-            }
+            const results = await Promise.all(configs.map(async (config) => {
+                const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);
+                console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);
+
+                const success = await deliverSingleAlert(
+                    config.channel_type,
+                    config.channel_target,
+                    testEvent,
+                    config.webhook_secret,
+                );
+
+                return { channelType: config.channel_type, target: config.channel_target, success };
+            }));

This still doesn't cap the worst-case wall-clock time if a channel truly hangs — pairing it with a per-call timeout would fully close the gap, but that likely needs a change in deliverSingleAlert/channel implementations outside this file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const config of configs) {
const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);
console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);
const success = await deliverSingleAlert(
config.channel_type,
config.channel_target,
testEvent,
config.webhook_secret,
);
results.push({
channelType: config.channel_type,
target: config.channel_target,
success,
});
}
const results = await Promise.all(configs.map(async (config) => {
const testEvent = buildTestEvent(config.contract_id, config.threshold_ledgers);
console.log(`Sending test alert to ${config.channel_type}:${config.channel_target}...`);
const success = await deliverSingleAlert(
config.channel_type,
config.channel_target,
testEvent,
config.webhook_secret,
);
return { channelType: config.channel_type, target: config.channel_target, success };
}));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/alerts.ts` around lines 305 - 321, Update the channel-test loop
around deliverSingleAlert to start all channel checks concurrently rather than
awaiting each one sequentially, then await the combined results before
populating results. Preserve each result’s channelType, target, and success
mapping, and avoid introducing timeout behavior outside the scope of this
change.


console.log();
console.log(chalk.bold(` Alert Connectivity Summary for ${contract.name ?? formatContractID(contractId)}`));
console.log();
for (const result of results) {
console.log(
` Type: ${chalk.cyan(result.channelType.padEnd(10))} | ` +
`Target: ${chalk.yellow(result.target.padEnd(30))} | ` +
`Status: ${result.success ? chalk.green("success") : chalk.red("failure")}`
);
}
console.log();

const failedCount = results.filter((result) => !result.success).length;
if (failedCount > 0) {
console.error(chalk.red(`${failedCount} channel(s) failed connectivity testing.`));
process.exit(1);
}
});

// ── alerts history ─────────────────────────────────────────────────
alerts
.command("history")
Expand Down Expand Up @@ -294,4 +383,4 @@ export function registerAlertsCommand(program: Command): void {
}
console.log();
});
}
}
92 changes: 92 additions & 0 deletions tests/commands/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Command } from "commander";
import type Database from "better-sqlite3";
import { getDatabaseForTesting } from "../../src/db/database";
import { registerAlertsCommand } from "../../src/commands/alerts";
import { registerAlertChannel } from "../../src/alerts/registry";
import {
insertContract,
getAlertConfigsForContract,
Expand Down Expand Up @@ -761,4 +762,95 @@ describe("alerts command", () => {
);
});
});

describe("alerts test-all", () => {
beforeEach(() => {
mockDeliverSingleAlert.mockReset();
insertAlertConfig(mockDb, {
contract_id: contractID,
channel_type: "webhook",
channel_target: "https://example.com/webhook",
threshold_ledgers: 1000,
});
insertAlertConfig(mockDb, {
contract_id: contractID,
channel_type: "slack",
channel_target: "#ops-alerts",
threshold_ledgers: 2000,
});
});

it("sends a test event to every configured channel for the contract", async () => {
mockDeliverSingleAlert.mockResolvedValue(true);

const program = new Command();
registerAlertsCommand(program);

await program.parseAsync([
"node", "sorokeep", "alerts", "test-all",
"--contract", contractID,
]);

expect(mockDeliverSingleAlert).toHaveBeenCalledTimes(2);
expect(mockDeliverSingleAlert.mock.calls[0]![0]).toBe("webhook");
expect(mockDeliverSingleAlert.mock.calls[0]![1]).toBe("https://example.com/webhook");
expect(mockDeliverSingleAlert.mock.calls[1]![0]).toBe("slack");
expect(mockDeliverSingleAlert.mock.calls[1]![1]).toBe("#ops-alerts");

for (const [, , event] of mockDeliverSingleAlert.mock.calls) {
expect(event.type).toBe("threshold_crossed");
expect(event.contractId).toBe(contractID);
}
});

it("exits with 1 when any delivery fails", async () => {
mockDeliverSingleAlert.mockResolvedValueOnce(true).mockResolvedValueOnce(false);

const program = new Command();
registerAlertsCommand(program);

await expect(
program.parseAsync([
"node", "sorokeep", "alerts", "test-all",
"--contract", contractID,
])
).rejects.toThrow("process.exit called");

expect(mockDeliverSingleAlert).toHaveBeenCalledTimes(2);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("1 channel(s) failed")
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

describe("alerts channels", () => {
it("prints all five built-in channel names", () => {
parse(["alerts", "channels"]);

const output = consoleLogSpy.mock.calls.flat().join("\n");
expect(output).toContain("webhook");
expect(output).toContain("slack");
expect(output).toContain("pagerduty");
expect(output).toContain("discord");
expect(output).toContain("telegram");
});

it("includes dynamically registered plugin channels in the output", () => {
registerAlertChannel({
name: "matrix-listing-test",
channel: { send: vi.fn().mockResolvedValue(undefined) },
targetOption: "url",
missingTargetError: "Error: --url is required when --type is matrix-listing-test.",
supportsSigning: false,
});

parse(["alerts", "channels"]);

const output = consoleLogSpy.mock.calls.flat().join("\n");
expect(output).toContain("matrix-listing-test");
expect(output).toContain("url");
expect(output).toContain("no");
});
Comment on lines +839 to +854

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the registry exposes any reset/unregister capability for tests.
rg -n 'export function' src/alerts/registry.ts

Repository: AbdulmalikAlayande/sorokeep

Length of output: 442


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== registry.ts =="
sed -n '1,90p' src/alerts/registry.ts

echo
echo "== imports and setup in alerts.test.ts around registry/cleanup =="
rg -n 'registerAlertChannel|_resetRegistryForTesting|afterEach|afterAll|beforeEach|beforeAll|alerts channels|consoleLogSpy' tests/commands/alerts.test.ts

echo
echo "== lines 800-870 =="
sed -n '800,870p' tests/commands/alerts.test.ts

Repository: AbdulmalikAlayande/sorokeep

Length of output: 5577


Reset the alert channel registry after registering the plugin channel.

registerAlertChannel adds "matrix-listing-test" to the module-level registry and alerts channels reads from that same registry, so this expectation can leak into later tests in the same test worker. Use _resetRegistryForTesting() in this test’s cleanup after verifying output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/commands/alerts.test.ts` around lines 839 - 854, Update the test
containing registerAlertChannel and the alerts channels output assertions to
call _resetRegistryForTesting() during cleanup after verifying the output,
ensuring the dynamically registered "matrix-listing-test" channel is removed
before subsequent tests run.

});
});
Loading