Files
Cactiplugin_thold/tests/Unit/NotificationQueueClaimTest.php
T
TheWitnessandCopilot 9b217e0e3a INFO: set compat to 1.2.29 (#838)
* INFO: set compat to 1.2.32

* docs: update Copilot instructions Cacti baseline to 1.2.32 (matches INFO compat)

* INFO: back off compat to 1.2.29

* Add manifest.json + upgrade-time file pruning

* Prune phpunit.xml on upgrade and ignore markdown-lint configs

phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it
is removed from manifest.json 'expected', pruned from installs on upgrade
(like tests/), and excluded from the manifest drift check.

The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and
.md* is now ignored like .git*: protected from pruning and excluded from
drift if it reappears.

* Move thold_functions.php and thold_webapi.php into includes/; flatten css

The main threshold library becomes includes/functions.php and the web API
helper becomes includes/webapi.php; every caller (setup.php, includes/*, the
CLI/poller/notify entry points and the test suite) loads them from includes/.
Each per-theme stylesheet css/<theme>/main.css becomes css/<theme>.css so users
can drop their own css/<theme>.css into the css base, and the page_head hook
looks there. Old paths are tombstoned so existing installs drop the stale
copies on upgrade.

* Regenerate cacti.pot for the includes/ file relocations

Moving the plugin's library files into includes/ changed the source-reference
lines in the translation template; regenerate it so the 'Verify translation
template' CI check passes.

* Harden the hook-include test and make RPN integer ops explicit

The HookFunctionsIncludeTest invokes real hooks with placeholder input purely
to execute their relocated include_once lines; it now swallows engine
notices/warnings/deprecations during those calls (and restores the error
handler and config afterward) so it cannot trip the suite's failOnDeprecation
or leak state into other tests. The RPN % and ^ operators now cast their
operands to int explicitly, matching their long-standing integer-truncation
behaviour while silencing the implicit float-to-int conversion deprecation.

* Harden prune against tampered-manifest path traversal

The upgrade-time prune now rejects any tombstone containing '.'/'..' segments
(which could escape the plugin directory or resolve to its root) and treats
ancestors of whitelist entries as protected, so a tombstone on a parent
directory can no longer delete a whitelisted file beneath it.

* docs: update and align the Project Structure block

Point the structure tree at the relocated files (libraries now under
includes/, data files under docs/, stylesheets under css/) and align the
trailing '# ...' comments to a single column so they no longer drift right.

* docs/tests: standardize test header and correct manifest-contract notes

- Use the full license header (from the plugin's own setup.php) in
  tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner.
- Document that the manifest drift check and the upgrade-time prune also
  handle phpunit.xml and .md* files, matching the implemented behavior.

* refactor: rename prune helpers to the plugin-slug function prefix

The repository naming contract reserves the plugin_<name>_ prefix for plugin
lifecycle / hook-registration functions; all other functions use the plain
<name>_ prefix. Rename the internal upgrade helpers accordingly:

  plugin_<name>_prune_files() -> <name>_prune_files()
  plugin_<name>_rmtree()      -> <name>_rmtree()

The call site, unit tests, and the copilot-instructions.md references are
updated to match. No behavioral change.

* chore: set the Cacti compatibility floor to 1.2.29

Align the compat metadata (and the contributor-guide references) with the
team decision to standardize the minimum supported Cacti version at 1.2.29;
the previously proposed 1.2.32 floor was not adopted.

---------

Co-authored-by: Copilot <copilot@users.noreply.github.com>
2026-10-01 01:40:00 -05:00

961 lines
29 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
/**
* How a drain of the notification queue scopes itself.
*
* thold_notify.php stamps its own identifier on the rows it intends to handle,
* then drains. If the drain ignores that stamp, two overlapping runs mail the
* same notifications.
*/
final class NotificationQueueClaimTest extends TestCase {
/**
* @return void
*/
public static function setUpBeforeClass(): void {
self::loadPluginSource('includes/functions.php');
}
/**
* Every query the drain issued against the queue.
*
* @return array<int, string>
*/
private function queueQueries() {
$queries = [];
foreach (CactiStubs::$calls as $call) {
if (strpos($call['sql'], 'notification_queue') !== false) {
$sql = $call['sql'];
foreach ($call['params'] as $param) {
$placeholder = strpos($sql, '?');
if ($placeholder === false) {
break;
}
$sql = substr($sql, 0, $placeholder) . (int) $param . substr($sql, $placeholder + 1);
}
$queries[] = preg_replace('/\s+/', ' ', $sql);
}
}
return $queries;
}
/**
* @return void
*/
public function testADrainWithAnIdentifierOnlyTakesThatProcessesRows(): void {
thold_notification_execute(4242);
$queries = $this->queueQueries();
$this->assertNotEmpty($queries);
foreach ($queries as $sql) {
$this->assertStringContainsString('process_id = 4242', $sql);
}
}
/**
* @return void
*/
public function testADrainWithoutAnIdentifierFailsClosed(): void {
thold_notification_execute();
$this->assertSame([], $this->queueQueries());
$this->assertNotEmpty(CactiStubs::$log);
}
/**
* @return void
*/
public function testBothStagesThatReadTheQueueAreScoped(): void {
thold_notification_execute(77);
$queries = $this->queueQueries();
// the non-device stage and the device stage each select from the queue
$this->assertCount(2, $queries);
foreach ($queries as $sql) {
$this->assertStringContainsString('process_id = 77', $sql);
}
}
/**
* @return void
*/
public function testTheDrainRespectsARecordLimit(): void {
thold_notification_execute(5, 10);
$limited = array_filter($this->queueQueries(), static function ($sql) {
return strpos($sql, 'LIMIT 10') !== false;
});
$this->assertNotEmpty($limited);
}
/**
* @return void
*/
public function testAClaimRecoversOrphansThenTakesOnlyUnheldRows(): void {
$this->assertSame(0, thold_notification_claim(0));
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
CactiStubs::willReturn('db_affected_rows', 3);
$this->assertSame(3, thold_notification_claim(4242));
$calls = array_values(array_filter(CactiStubs::$calls, static function ($call) {
return $call['fn'] === 'db_execute_prepared';
}));
$this->assertCount(2, $calls);
$this->assertStringContainsString('LEFT JOIN processes', $calls[0]['sql']);
$this->assertStringContainsString('p.pid IS NULL', $calls[0]['sql']);
$this->assertSame(['thold_notify', 'child'], $calls[0]['params']);
$this->assertStringContainsString('(next_attempt IS NULL OR next_attempt <= NOW())', $calls[1]['sql']);
$this->assertStringContainsString('AND process_id = 0', $calls[1]['sql']);
$this->assertSame([4242], $calls[1]['params']);
}
/**
* @return void
*/
public function testAClaimSkipsTheRecoveryUpdateWhenNoOrphanExists(): void {
thold_notification_claim(4242);
$updates = CactiStubs::callsTo('db_execute_prepared');
$this->assertCount(1, $updates);
$this->assertStringContainsString('SET process_id = ?', $updates[0]['sql']);
}
/**
* @return void
*/
public function testAReleaseReturnsOnlyTheWorkersUnfinishedRows(): void {
$this->assertTrue(thold_notification_release_claim(0));
$this->assertTrue(thold_notification_release_claim(4242));
$calls = CactiStubs::$calls;
$call = end($calls);
$this->assertSame('db_execute_prepared', $call['fn']);
$this->assertStringContainsString('SET process_id = 0', $call['sql']);
$this->assertStringContainsString('AND event_processed = 0', $call['sql']);
$this->assertSame([4242], $call['params']);
}
/**
* @return void
*/
public function testDatabaseLeaseOperationsAreConnectionScoped(): void {
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
$this->assertSame('thold_notify_child_2', thold_notification_lock_name(2));
$this->assertTrue(thold_notification_acquire_lock(2));
$this->assertTrue(thold_notification_owns_lock(2));
$this->assertTrue(thold_notification_release_lock(2));
$this->assertSame(
[
"SELECT GET_LOCK(CONCAT(DATABASE(), ':', ?), 0)",
"SELECT IS_USED_LOCK(CONCAT(DATABASE(), ':', ?)) = CONNECTION_ID()",
"SELECT RELEASE_LOCK(CONCAT(DATABASE(), ':', ?))",
],
array_column(CactiStubs::$calls, 'sql')
);
foreach (CactiStubs::$calls as $call) {
$this->assertSame(['thold_notify_child_2'], $call['params']);
}
}
/**
* @return void
*/
public function testRegistrationRequiresTheLeaseAndHandlesQueryFailures(): void {
$this->assertFalse(thold_notification_register_process(2, 300, static function () {
return false;
}));
$this->assertSame(['cacti_log'], array_column(CactiStubs::$calls, 'fn'));
$this->assertNotEmpty(CactiStubs::$log);
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', false);
$this->assertFalse(thold_notification_register_process(2, 300, static function () {
return true;
}));
$this->assertSame(
['db_fetch_row_prepared', 'db_fetch_cell_prepared'],
array_column(CactiStubs::$calls, 'fn')
);
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
$this->assertTrue(thold_notification_register_process(2, 300, static function () {
return true;
}));
$this->assertSame(
['db_fetch_row_prepared', 'register_process_start'],
array_column(CactiStubs::$calls, 'fn')
);
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', []);
CactiStubs::willReturn('register_process_start', false);
$this->assertFalse(thold_notification_register_process(2, 300, static function () {
return true;
}));
$last = end(CactiStubs::$calls);
$this->assertSame('db_fetch_cell_prepared', $last['fn']);
$this->assertStringContainsString('RELEASE_LOCK', $last['sql']);
}
/**
* @return void
*/
public function testRegistrationUsesLivenessAndBoundedHeartbeatFallbacks(): void {
$process = [
'pid' => 42,
'started_at' => 500,
'heartbeat_at' => 600,
'current_timestamp' => 1000,
];
$lock = static function () {
return true;
};
$same_process = static function () {
return 600;
};
CactiStubs::willReturn('db_fetch_row_prepared', $process);
$this->assertFalse(thold_notification_register_process(2, 300, $lock, static function () {
return true;
}, $same_process));
CactiStubs::reset();
$fresh = $process;
$fresh['heartbeat_at'] = 900;
CactiStubs::willReturn('db_fetch_row_prepared', $fresh);
$this->assertFalse(thold_notification_register_process(2, 300, $lock, static function () {
return null;
}));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $fresh);
$this->assertTrue(thold_notification_register_process(2, 300, $lock, static function () {
return false;
}));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $process);
$this->assertTrue(thold_notification_register_process(2, 300, $lock, static function () {
return null;
}));
CactiStubs::reset();
$expired = $process;
$expired['heartbeat_at'] = 100;
$expired['current_timestamp'] = 2000;
CactiStubs::willReturn('db_fetch_row_prepared', $expired);
$this->assertFalse(thold_notification_register_process(2, 300, $lock, static function () {
return true;
}, static function () {
return 1600;
}));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $expired);
$this->assertTrue(thold_notification_register_process(2, 300, $lock, static function () {
return true;
}, static function () {
return 700;
}));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $expired);
$this->assertFalse(thold_notification_register_process(2, 300, $lock, static function () {
return true;
}, static function () {
return false;
}));
CactiStubs::reset();
CactiStubs::willReturn('db_fetch_row_prepared', $process);
$this->assertTrue(thold_notification_register_process(2, 300, $lock, static function () {
return false;
}));
$this->assertSame(
['db_fetch_row_prepared', 'db_execute_prepared', 'unregister_process', 'register_process_start'],
array_column(CactiStubs::$calls, 'fn')
);
$this->assertSame(['thold_notify', 'child', 2, 42], CactiStubs::$calls[2]['params']);
}
/**
* @return void
*/
public function testProcessProbeDistinguishesDeathFromPermissionErrors(): void {
$missing = static function () {
return false;
};
$this->assertTrue(thold_notification_probe_process(42, static function () {
return 42;
}));
$this->assertTrue(thold_notification_probe_process(42, $missing, static function () {
return true;
}));
$this->assertTrue(thold_notification_probe_process(42, $missing, $missing, static function () {
return 1;
}));
$this->assertFalse(thold_notification_probe_process(42, $missing, $missing, static function () {
return 3;
}));
$this->assertNull(thold_notification_probe_process(42, $missing, false, false));
$this->assertNull(thold_notification_probe_process(42, $missing, $missing, false));
$this->assertNull(thold_notification_probe_process(42, $missing, $missing, static function () {
return 99;
}));
$this->assertTrue(thold_notification_probe_process(getmypid()));
$this->assertTrue(thold_notification_probe_process(getmypid(), $missing));
$this->assertContains(thold_notification_probe_process(2147483647, $missing, $missing), [false, null], true);
$this->assertFalse(thold_notification_probe_process(0));
}
/**
* @return void
*/
public function testProcessIdentityDetectsPidReuse(): void {
$this->assertNull(thold_notification_process_matches(0, 500, 1000));
$this->assertTrue(thold_notification_process_matches(42, 500, 1000, static function () {
return 600;
}));
$this->assertTrue(thold_notification_process_matches(42, 500, 1000, static function () {
return 495;
}));
$this->assertFalse(thold_notification_process_matches(42, 500, 1000, static function () {
return 494;
}));
$this->assertNull(thold_notification_process_matches(42, 500, 1000, static function () {
return false;
}));
$this->assertNull(thold_notification_process_matches(42, 500, 1000, static function () {
throw new RuntimeException('process probe failed');
}));
}
/**
* @return void
*/
public function testElapsedProcessTimeAcceptsLinuxAndPortableFormats(): void {
$this->assertSame(83, thold_notification_elapsed_seconds('83'));
$this->assertSame(83, thold_notification_elapsed_seconds('01:23'));
$this->assertSame(3723, thold_notification_elapsed_seconds('01:02:03'));
$this->assertSame(90123, thold_notification_elapsed_seconds('1-01:02:03'));
$this->assertFalse(thold_notification_elapsed_seconds('unknown'));
}
/**
* @return void
*/
public function testProcessAgeProbeFallsBackToPortablePsOutput(): void {
$fields = [];
$runner = static function ($field, $pid) use (&$fields) {
$fields[] = [$field, $pid];
return $field === 'etimes' ? [1, []] : [0, ['01:23']];
};
$this->assertSame(83, thold_notification_probe_elapsed(42, $runner));
$this->assertSame([['etimes', 42], ['etime', 42]], $fields);
$this->assertFalse(thold_notification_probe_elapsed(42, false));
$this->assertFalse(thold_notification_probe_elapsed(42, static function () {
return [0, ['unknown']];
}));
$this->assertFalse(thold_notification_probe_elapsed(42, static function () {
throw new RuntimeException('ps failed');
}));
}
/**
* @return void
*/
public function testDefaultProcessIdentityProbeIsTimezoneIndependent(): void {
$timezone = date_default_timezone_get();
$now = time();
try {
foreach (['UTC', 'America/Los_Angeles'] as $candidate) {
date_default_timezone_set($candidate);
$this->assertTrue(thold_notification_process_matches(getmypid(), $now, $now));
}
} finally {
date_default_timezone_set($timezone);
}
}
/**
* @return void
*/
public function testDefaultUnixProbeKeepsALiveWorkerRegistered(): void {
$now = time();
CactiStubs::willReturn('db_fetch_row_prepared', [
'pid' => getmypid(),
'started_at' => $now,
'heartbeat_at' => $now - 400,
'current_timestamp' => $now,
]);
$this->assertFalse(thold_notification_register_process(2, 300, static function () {
return true;
}));
$this->assertSame(
['db_fetch_row_prepared', 'db_fetch_cell_prepared', 'cacti_log'],
array_column(CactiStubs::$calls, 'fn')
);
}
/**
* @return void
*/
public function testRegistrationFailureAfterReclaimReleasesTheLease(): void {
CactiStubs::willReturn('db_fetch_row_prepared', [
'pid' => 42,
'started_at' => 500,
'heartbeat_at' => 600,
'current_timestamp' => 1000,
]);
CactiStubs::willReturn('register_process_start', false);
$this->assertFalse(thold_notification_register_process(2, 300, static function () {
return true;
}, static function () {
return false;
}));
$last = end(CactiStubs::$calls);
$this->assertSame('db_fetch_cell_prepared', $last['fn']);
$this->assertStringContainsString('RELEASE_LOCK', $last['sql']);
}
/**
* @return void
*/
public function testASuspendedRunReleasesItsClaimAndRemainsScoped(): void {
CactiStubs::$configOptions['thold_notification_suspended'] = '1';
CactiStubs::willReturn('db_affected_rows', 2);
$heartbeats = 0;
$this->assertSame(2, thold_notification_run(77, 'all', static function () use (&$heartbeats) {
$heartbeats++;
}));
$this->assertSame(5, $heartbeats);
foreach ($this->queueQueries() as $sql) {
if (strpos($sql, 'SELECT *') !== false) {
$this->assertStringContainsString('process_id = 77', $sql);
}
}
$calls = CactiStubs::$calls;
$release = end($calls);
$this->assertSame('db_execute_prepared', $release['fn']);
$this->assertStringContainsString('SET process_id = 0', $release['sql']);
$this->assertSame([77], $release['params']);
}
/**
* @return void
*/
public function testAHeartbeatFailureCannotSkipClaimRelease(): void {
CactiStubs::$configOptions['thold_notification_suspended'] = '1';
$heartbeats = 0;
try {
thold_notification_run(77, 'all', static function () use (&$heartbeats) {
$heartbeats++;
if ($heartbeats === 5) {
throw new RuntimeException('heartbeat failed');
}
});
$this->fail('Expected the heartbeat failure to propagate.');
} catch (RuntimeException $error) {
$this->assertSame('heartbeat failed', $error->getMessage());
}
$calls = CactiStubs::$calls;
$release = end($calls);
$this->assertSame('db_execute_prepared', $release['fn']);
$this->assertStringContainsString('SET process_id = 0', $release['sql']);
$this->assertSame([77], $release['params']);
}
/**
* @return void
*/
public function testQueueLoopsHeartbeatForEveryRecord(): void {
$records = [
['id' => 91, 'topic' => 'unknown-device'],
['id' => 92, 'topic' => 'unknown-device'],
];
CactiStubs::willReturn('db_fetch_assoc_prepared', $records);
CactiStubs::willReturn('db_fetch_assoc_prepared', $records);
$heartbeats = 0;
$heartbeat = static function () use (&$heartbeats) {
$heartbeats++;
};
process_device_notifications(77, 'all', 0, $heartbeat);
process_non_device_notifications(77, 'all', 0, $heartbeat);
$this->assertSame(4, $heartbeats);
$terminal = array_values(array_filter(CactiStubs::$calls, static function ($call) {
return $call['fn'] === 'db_execute_prepared'
&& strpos($call['sql'], 'error_code = 1, error_message') !== false;
}));
$this->assertCount(4, $terminal);
foreach ($terminal as $call) {
$this->assertStringContainsString('event_processed = 1', $call['sql']);
$this->assertSame(77, $call['params'][2]);
}
}
/**
* @return void
*/
public function testUnknownTopicMessageIsBoundAndTruncated(): void {
$this->assertTrue(thold_notification_reject_unknown_topic(91, 77, str_repeat('é', 200)));
$call = end(CactiStubs::$calls);
$this->assertSame('db_execute_prepared', $call['fn']);
$this->assertSame(128, mb_strlen($call['params'][0], 'UTF-8'));
$this->assertSame([91, 77], array_slice($call['params'], 1));
$this->assertStringContainsString('AND process_id = ?', $call['sql']);
}
/**
* @return void
*/
public function testCompletionDatabaseFailureUsesASafeTerminalFallback(): void {
CactiStubs::willReturn('db_execute_prepared', false);
$this->assertTrue(thold_notification_complete('UPDATE notification_queue SET event_processed = 1', [77], [91], 77));
$calls = CactiStubs::callsTo('db_execute_prepared');
$this->assertCount(2, $calls);
$this->assertStringContainsString('Completion update failed', $calls[1]['sql']);
$this->assertSame([91, 77], $calls[1]['params']);
}
/**
* @return void
*/
public function testCompletionAbortsWhenBothDatabaseUpdatesFail(): void {
CactiStubs::willReturn('db_execute_prepared', false);
CactiStubs::willReturn('db_execute_prepared', false);
$this->expectException(UnexpectedValueException::class);
$this->expectExceptionMessage('database update failed');
thold_notification_complete('UPDATE notification_queue SET event_processed = 1', [77], [91], 77);
}
/**
* @return void
*/
public function testNotificationErrorsNormalizeInvalidUtf8(): void {
$message = thold_notification_error_message("\xff\xfe" . str_repeat('a', 200));
$this->assertTrue(mb_check_encoding($message, 'UTF-8'));
$this->assertLessThanOrEqual(128, mb_strlen($message, 'UTF-8'));
}
/**
* @return void
*/
public function testCompletionRejectsAnEmptyClaim(): void {
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('queue row(s)');
thold_notification_complete('UPDATE notification_queue SET event_processed = 1', [77], [], 77);
}
/**
* @return void
*/
public function testRevokedSingleRowClaimIsLoggedAndAbortsTheDrain(): void {
CactiStubs::willReturn('db_affected_rows', 0);
try {
thold_notification_complete(
'UPDATE notification_queue SET event_processed = 1 WHERE id = ? AND process_id = ?',
[91, 77],
[91],
77
);
$this->fail('Expected revoked ownership to stop the drain.');
} catch (RuntimeException $error) {
$this->assertStringContainsString('queue row(s) 91', $error->getMessage());
}
$this->assertStringContainsString('process 77', end(CactiStubs::$log));
}
/**
* @return void
*/
public function testPartiallyRevokedGroupedClaimIsLoggedAndAbortsTheDrain(): void {
CactiStubs::willReturn('db_affected_rows', 1);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('queue row(s) 91, 92');
thold_notification_complete(
'UPDATE notification_queue SET event_processed = 1 WHERE id IN (91, 92) AND process_id = ?',
[77],
[91, 92],
77
);
}
/**
* @return void
*/
public function testDeviceCommandAndGroupedMailComplete(): void {
CactiStubs::$configOptions['alert_deadnotify_one_mail'] = 'on';
CactiStubs::$configOptions['alert_deadnotify_subject'] = 'Device alerts';
CactiStubs::willReturn('mailer', str_repeat('é', 200));
CactiStubs::willReturn('db_fetch_assoc_prepared', [
[
'id' => 101,
'topic' => 'thold_dhost_cmd',
'event_data' => json_encode([
'environment' => ['THOLD_DEVICE_TEST=1'],
'command' => "printf '%0130d' 0",
'data' => ['id' => 7],
]),
],
[
'id' => 102,
'topic' => 'thold_dhost_mail',
'event_data' => json_encode([
'from' => ['sender@example.com'],
'to' => 'recipient@example.com',
'cc' => '',
'bcc' => '',
'replyto' => '',
'subject' => 'Device down',
'body' => '<body>Down</body>',
'body_text' => 'Down',
'attachments' => [],
'headers' => [],
'html' => true,
]),
],
]);
$heartbeats = 0;
process_device_notifications(77, 'all', 0, static function () use (&$heartbeats) {
$heartbeats++;
});
putenv('THOLD_DEVICE_TEST');
$updates = array_values(array_filter(CactiStubs::$calls, static function ($call) {
return $call['fn'] === 'db_execute_prepared'
&& (strpos($call['sql'], 'event_processed = 1') !== false
|| strpos($call['sql'], 'attempt_count = CASE id') !== false);
}));
$this->assertCount(2, $updates);
$this->assertSame(101, $updates[0]['params'][3]);
$this->assertSame(77, $updates[0]['params'][4]);
$this->assertSame(128, strlen($updates[0]['params'][1]));
$this->assertSame(1, $updates[1]['params'][0]);
$this->assertSame(128, mb_strlen($updates[1]['params'][1], 'UTF-8'));
$this->assertSame([102, 77], array_slice($updates[1]['params'], -2));
$this->assertStringContainsString('AND process_id = ?', $updates[0]['sql']);
$this->assertStringContainsString('AND process_id = ?', $updates[1]['sql']);
$this->assertSame(3, $heartbeats);
}
/**
* @return void
*/
public function testIndividualDeviceMailCompletionRequiresItsOwner(): void {
CactiStubs::willReturn('mailer', str_repeat('é', 200));
CactiStubs::willReturn('db_fetch_assoc_prepared', [[
'id' => 104,
'topic' => 'thold_dhost_mail',
'event_data' => json_encode([
'from' => ['sender@example.com'],
'to' => 'recipient@example.com',
'bcc' => '',
'replyto' => '',
'subject' => 'Device down',
'body' => '<body>Down</body>',
'body_text' => 'Down',
'attachments' => [['attachment' => base64_encode('attachment')]],
'headers' => [],
'html' => true,
]),
]]);
process_device_notifications(77, 'all', 0);
$call = end(CactiStubs::$calls);
$this->assertStringContainsString('AND process_id = ?', $call['sql']);
$this->assertSame(128, mb_strlen($call['params'][1], 'UTF-8'));
$this->assertSame([104, 77], array_slice($call['params'], -2));
}
/**
* @return void
*/
public function testNonDeviceCommandCompletesWithItsEnvironment(): void {
CactiStubs::willReturn('db_fetch_assoc_prepared', [[
'id' => 103,
'topic' => 'thold_cmd',
'event_data' => json_encode([
'environment' => ['THOLD_COMMAND_TEST=1'],
'command' => "printf '%0130d' 0",
'data' => ['id' => 8],
]),
]]);
process_non_device_notifications(77, 'all', 0);
putenv('THOLD_COMMAND_TEST');
$call = end(CactiStubs::$calls);
$this->assertSame('db_execute_prepared', $call['fn']);
$this->assertStringContainsString('event_processed = 1', $call['sql']);
$this->assertSame(128, strlen($call['params'][1]));
$this->assertSame(103, $call['params'][3]);
$this->assertSame(77, $call['params'][4]);
$this->assertStringContainsString('AND process_id = ?', $call['sql']);
}
/**
* @return void
*/
public function testNonDeviceMailCompletionRequiresItsOwner(): void {
CactiStubs::willReturn('mailer', str_repeat('é', 200));
CactiStubs::willReturn('db_fetch_assoc_prepared', [[
'id' => 105,
'topic' => 'thold_mail',
'event_data' => json_encode([
'from' => ['sender@example.com'],
'to' => 'recipient@example.com',
'bcc' => '',
'replyto' => '',
'subject' => 'Threshold alert',
'body' => '<body>Alert</body>',
'body_text' => 'Alert',
'attachments' => [],
'headers' => [],
'html' => true,
]),
]]);
process_non_device_notifications(77, 'all', 0);
$call = end(CactiStubs::$calls);
$this->assertStringContainsString('AND process_id = ?', $call['sql']);
$this->assertSame(128, mb_strlen($call['params'][1], 'UTF-8'));
$this->assertSame([105, 77], array_slice($call['params'], -2));
}
/**
* @return void
*/
public function testCleanupReleasesAndUnregistersOnlyOnce(): void {
$registered = true;
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
$this->assertTrue(thold_notification_cleanup(77, 2, $registered));
$this->assertFalse($registered);
$this->assertSame(
['db_execute_prepared', 'unregister_process', 'db_fetch_cell_prepared'],
array_column(CactiStubs::$calls, 'fn')
);
$this->assertTrue(thold_notification_cleanup(77, 2, $registered));
$this->assertCount(3, CactiStubs::$calls);
}
/**
* @return void
*/
public function testCleanupReportsAClaimReleaseFailure(): void {
$registered = true;
CactiStubs::willReturn('db_execute_prepared', false);
$this->assertFalse(thold_notification_cleanup(77, 2, $registered));
$this->assertFalse($registered);
$this->assertNotEmpty(CactiStubs::$log);
}
/**
* @return void
*/
public function testNamedShutdownIsIdempotent(): void {
$GLOBALS['notification_registered'] = true;
$GLOBALS['pid'] = 77;
$GLOBALS['thread'] = 2;
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
thold_notification_shutdown();
thold_notification_shutdown();
$this->assertFalse($GLOBALS['notification_registered']);
$this->assertCount(3, CactiStubs::$calls);
}
/**
* @return void
*/
public function testMainInstallsShutdownBeforeRegistrationAndFailsClosed(): void {
$events = [];
$this->assertFalse(thold_notification_main(
2,
300,
77,
static function ($callback) use (&$events) {
$events[] = 'shutdown:' . $callback;
},
static function ($thread, $timeout) use (&$events) {
$events[] = "register:$thread:$timeout";
return false;
},
static function () use (&$events) {
$events[] = 'run';
return 0;
}
));
$this->assertSame(['shutdown:thold_notification_shutdown', 'register:2:300'], $events);
$this->assertFalse($GLOBALS['notification_registered']);
}
/**
* @return void
*/
public function testMainLogsAndCleansUpARegistrationException(): void {
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
$this->assertFalse(thold_notification_main(
2,
300,
77,
static function () {
},
static function () {
throw new RuntimeException('registration failed');
},
static function () {
throw new RuntimeException('drain must not run');
}
));
$this->assertStringContainsString('registration failed', end(CactiStubs::$log));
$this->assertFalse($GLOBALS['notification_registered']);
$this->assertSame(
['cacti_log', 'db_execute_prepared', 'unregister_process', 'db_fetch_cell_prepared'],
array_column(CactiStubs::$calls, 'fn')
);
}
/**
* @return void
*/
public function testMainRunsTheLeaseHeartbeatAndAlwaysCleansUp(): void {
$events = [];
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
$this->assertTrue(thold_notification_main(
2,
300,
77,
static function ($callback) use (&$events) {
$events[] = 'shutdown:' . $callback;
},
static function ($thread, $timeout) use (&$events) {
$events[] = "register:$thread:$timeout";
return true;
},
static function ($pid, $limit, $heartbeat) use (&$events) {
$events[] = "run:$pid:$limit";
$heartbeat();
return 4;
}
));
$this->assertSame(
['shutdown:thold_notification_shutdown', 'register:2:300', 'run:77:all'],
$events
);
$this->assertFalse($GLOBALS['notification_registered']);
$this->assertSame(2, $GLOBALS['thread']);
$this->assertContains('heartbeat_process', array_column(CactiStubs::$calls, 'fn'));
$this->assertStringContainsString('Notifications:4', end(CactiStubs::$log));
}
/**
* @return void
*/
public function testHeartbeatThrowsAsSoonAsTheLeaseIsLost(): void {
CactiStubs::willReturn('db_fetch_cell_prepared', 0);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Notification worker lease was lost.');
thold_notification_heartbeat(2);
}
/**
* @return void
*/
public function testMainLogsLeaseLossAndReturnsFailureAfterCleanup(): void {
CactiStubs::willReturn('db_fetch_cell_prepared', 1);
$this->assertFalse(thold_notification_main(
2,
300,
77,
static function () {
},
static function () {
return true;
},
static function () {
throw new RuntimeException('lease lost');
}
));
$this->assertFalse($GLOBALS['notification_registered']);
$this->assertStringContainsString('lease lost', end(CactiStubs::$log));
}
}