Files
cacti/tests/integration/Issue7429SchedulerMonthlyNextStartIntegrationTest.php
Thomas VincentandTheWitness 96f03780f9 fix: roll monthly schedules over to the next year instead of resolving to false (#7429) (#7568)
* fix: roll monthly schedules over to the next year instead of resolving to false

A schedule whose selected dates had all passed returned false, which the
caller stored as a 1970 next_start and then read as overdue every cycle.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: add phpdoc to the issue 7429 test helpers

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: append the changelog entry at the end of the issue block

Matches .github/copilot-instructions.md, which appends rather than inserting
in numeric order.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix: check the calculated start strictly against false

empty() would also treat a timestamp of 0 as unschedulable, which is a
different state from having nothing to schedule.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: use the full licence header on the new test files

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: treat CACTI_PATH_LOG as a directory and pass ctor args to the statement class

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-08-06 09:25:17 -04:00

215 lines
8.0 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. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Integration coverage for issue #7429. The bug was not a single wrong value
* but a state that repeated: once api_scheduler_calculate_next_start() ran out
* of candidate dates it returned false, api_scheduler_is_time_to_start() wrote
* date('Y-m-d H:i', false) into next_start, and every later poller cycle read
* that 1970 timestamp as overdue and started discovery again.
*
* So this drives the real function over a sequence of cycles against a real
* (sqlite-backed) connection, the way poller_automation.php does, and watches
* what accumulates in next_start. The same scheduler serves reports, hence the
* second table.
*/
require_once dirname(__DIR__) . '/Helpers/FakeMySQLPDO.php';
require_once dirname(__DIR__, 2) . '/include/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/include/global_constants.php';
require_once dirname(__DIR__, 2) . '/lib/database.php';
require_once dirname(__DIR__, 2) . '/lib/functions.php';
require_once dirname(__DIR__, 2) . '/lib/api_scheduler.php';
if (!defined('CACTI_WEB')) {
define('CACTI_WEB', false);
}
if (!defined('CACTI_PATH_LOG')) {
define('CACTI_PATH_LOG', sys_get_temp_dir());
}
/**
* Seeds one schedule row in the given table.
*
* @param string $table Either automation_networks or reports.
* @param array $schedule The schedule values to store.
*
* @return FakeMySQLPDO A connection holding the seeded schema.
*/
function scheduler_seed(string $table, array $schedule) : FakeMySQLPDO {
$conn = new FakeMySQLPDO();
// the scheduler logs each candidate date, and cacti_log() reads its
// verbosity out of settings before deciding whether to write
$conn->exec('CREATE TABLE settings (name TEXT PRIMARY KEY, value TEXT)');
$conn->exec("CREATE TABLE $table (
id INTEGER PRIMARY KEY, name TEXT, sched_type INTEGER, start_at TEXT,
next_start TEXT, recur_every INTEGER, day_of_week TEXT, month TEXT,
day_of_month TEXT, monthly_week TEXT, monthly_day TEXT, enabled TEXT
)");
$conn->exec("INSERT INTO $table (id, name, sched_type, start_at, next_start,
recur_every, day_of_week, month, day_of_month, monthly_week, monthly_day, enabled)
VALUES (1, 'test', {$schedule['sched_type']}, '{$schedule['start_at']}',
'{$schedule['next_start']}', 1, '', '{$schedule['month']}',
'{$schedule['day_of_month']}', '{$schedule['monthly_week']}',
'{$schedule['monthly_day']}', 'on')");
return $conn;
}
/**
* Builds a monthly schedule whose date has already passed this year.
*
* @param array $overrides Schedule keys to replace in the default.
*
* @return array The values scheduler_seed() stores.
*/
function scheduler_schedule(array $overrides = []) : array {
return array_merge([
'sched_type' => SCHEDULE_MONTHLY,
'start_at' => '09:30',
'next_start' => '0000-00-00 00:00:00',
'month' => '3',
'day_of_month' => '15',
'monthly_week' => '',
'monthly_day' => '',
], $overrides);
}
/**
* One poller cycle: read the row, ask the scheduler, read it back. Returns the
* answer and the next_start the cycle left behind.
*
* @return array{started: bool, next_start: string}
*/
function scheduler_cycle(PDO $conn, string $table) : array {
$row = $conn->query("SELECT * FROM $table WHERE id = 1")->fetch(PDO::FETCH_ASSOC);
$started = api_scheduler_is_time_to_start($row, $table);
return [
'started' => $started,
'next_start' => $conn->query("SELECT next_start FROM $table WHERE id = 1")
->fetch(PDO::FETCH_ASSOC)['next_start'],
];
}
/**
* Points lib/database.php's default connection at the seeded handle.
*
* @param PDO $conn The connection the scheduler should read and write.
*
* @return void
*/
function scheduler_wire(PDO $conn) : void {
$GLOBALS['database_hostname'] = 'unittest';
$GLOBALS['database_port'] = 0;
$GLOBALS['database_default'] = 'unittest';
$GLOBALS['database_total_queries'] = 0;
$GLOBALS['config'] = $GLOBALS['config'] ?? [];
$GLOBALS['database_sessions'] = ['unittest:0:unittest' => $conn];
}
beforeEach(function () {
global $database_sessions, $database_hostname, $database_port, $database_default;
$this->db_globals = [$database_sessions, $database_hostname, $database_port, $database_default];
});
/* Left in place, the fake handle answers every later read_config_option() in
the run and throws on Cacti's MySQL SQL, aborting the suite. */
afterEach(function () {
global $database_sessions, $database_hostname, $database_port, $database_default;
[$database_sessions, $database_hostname, $database_port, $database_default] = $this->db_globals;
});
test('a monthly schedule whose date has passed writes a future next_start, not 1970', function () {
$conn = scheduler_seed('automation_networks', scheduler_schedule());
scheduler_wire($conn);
$cycle = scheduler_cycle($conn, 'automation_networks');
expect($cycle['next_start'])->not->toStartWith('1970')
->and(strtotime($cycle['next_start']))->toBeGreaterThan(time());
});
test('an overdue schedule fires once and then stops firing every cycle', function () {
// an overdue next_start, which is the state the poller finds after a
// missed run. The reported symptom was every later cycle firing too,
// because the recalculated next_start came back as 1970.
$conn = scheduler_seed('automation_networks', scheduler_schedule([
'next_start' => date('Y-m-d H:i:s', time() - 86400),
]));
scheduler_wire($conn);
$starts = [];
for ($cycle = 0; $cycle < 5; $cycle++) {
$starts[] = scheduler_cycle($conn, 'automation_networks')['started'];
}
expect($starts)->toBe([true, false, false, false, false]);
});
test('next_start stops drifting once it has been calculated', function () {
$conn = scheduler_seed('automation_networks', scheduler_schedule());
scheduler_wire($conn);
$first = scheduler_cycle($conn, 'automation_networks')['next_start'];
$second = scheduler_cycle($conn, 'automation_networks')['next_start'];
expect($second)->toBe($first);
});
test('an unschedulable configuration leaves next_start untouched', function () {
// every candidate date fails to parse, so the calculation returns false;
// pre-fix that false was written straight into next_start as 1970
$conn = scheduler_seed('automation_networks', scheduler_schedule([
'day_of_month' => '40',
'next_start' => '2026-03-15 09:30:00',
]));
scheduler_wire($conn);
$cycle = scheduler_cycle($conn, 'automation_networks');
expect($cycle['started'])->toBeFalse()
->and($cycle['next_start'])->toBe('2026-03-15 09:30:00');
});
test('the same guard applies to report schedules', function () {
$conn = scheduler_seed('reports', scheduler_schedule([
'sched_type' => SCHEDULE_MONTHLY_ON_DAY,
'day_of_month' => '',
'monthly_week' => '4',
'monthly_day' => '4',
]));
scheduler_wire($conn);
$cycle = scheduler_cycle($conn, 'reports');
// fourth Wednesday of March, which pre-fix was spelled 'forth' and so
// never parsed at all
expect($cycle['next_start'])->not->toStartWith('1970')
->and(date('D', strtotime($cycle['next_start'])))->toBe('Wed');
});