diff --git a/.github/tests/unit/cloudflare.t b/.github/tests/unit/cloudflare.t
new file mode 100644
index 0000000..eb607ed
--- /dev/null
+++ b/.github/tests/unit/cloudflare.t
@@ -0,0 +1,173 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+BEGIN {
+ # Stub LWP::UserAgent so CloudFlare.pm can be loaded on hosts (incl. CI
+ # runners) without libwww-perl installed. Each subtest still injects its
+ # own behaviour via `local *LWP::UserAgent::method = sub { ... }`.
+ $INC{'LWP/UserAgent.pm'} = __FILE__;
+ no strict 'refs';
+ *{'LWP::UserAgent::new'} = sub { my $c = shift; bless {}, $c };
+ *{'LWP::UserAgent::post'} = sub { die 'LWP::UserAgent::post must be stubbed by the active subtest' };
+ *{'LWP::UserAgent::delete'} = sub { die 'LWP::UserAgent::delete must be stubbed by the active subtest' };
+}
+
+use TestBootstrap ();
+
+{
+ package Local::FakeCFResponse;
+
+ sub new { my ($class, %args) = @_; return bless \%args, $class }
+ sub is_success { return $_[0]->{is_success} }
+ sub content { return $_[0]->{content} }
+ sub status_line { return $_[0]->{status_line} // 'fake-status' }
+}
+
+sub load_cloudflare {
+ my (%config) = @_;
+ $config{DEBUG} //= 0;
+ $config{URLGET} //= 1;
+ $config{CF_BLOCK} //= 'block';
+ $config{CF_CPANEL} //= 0;
+ TestBootstrap::reload_module_with_config('ConfigServer::CloudFlare', \%config);
+ return;
+}
+
+subtest 'checktarget classifies countries, CIDR ranges, and bare IPs' => sub {
+ load_cloudflare();
+
+ is(ConfigServer::CloudFlare::checktarget('US'), 'country', 'two-letter token is treated as a country code');
+ is(ConfigServer::CloudFlare::checktarget('192.0.2.0/24'), 'ip_range', '/24 CIDR is treated as an ip_range target');
+ is(ConfigServer::CloudFlare::checktarget('192.0.0.0/16'), 'ip_range', '/16 CIDR is treated as an ip_range target');
+ is(ConfigServer::CloudFlare::checktarget('192.0.2.10'), 'ip', 'plain IPv4 falls through to the ip target');
+ is(ConfigServer::CloudFlare::checktarget('2001:db8::1'), 'ip', 'plain IPv6 falls through to the ip target');
+};
+
+subtest 'getscope parses DOMAIN, DISABLE, and ANY directives from csf.cloudflare' => sub {
+ load_cloudflare(CF_CPANEL => 0);
+
+ no warnings qw(redefine once);
+ local *ConfigServer::CloudFlare::slurp = sub {
+ return (
+ '# example csf.cloudflare contents',
+ 'DOMAIN:example.com:USER:alice:ACCOUNT:alice@example.com:APIKEY:key-alice',
+ 'DOMAIN:any:USER:bob:ACCOUNT:bob@example.com:APIKEY:key-bob',
+ 'DISABLE:carol',
+ 'ANY:bob',
+ '',
+ );
+ };
+
+ my $scope = ConfigServer::CloudFlare::getscope();
+
+ is($scope->{domain}{'example.com'}{user}, 'alice', 'DOMAIN line records the owning user');
+ is($scope->{domain}{'example.com'}{account}, 'alice@example.com', 'DOMAIN line records the cloudflare account');
+ is($scope->{domain}{'example.com'}{apikey}, 'key-alice', 'DOMAIN line records the cloudflare API key');
+ is($scope->{user}{alice}{domain}{'example.com'}, 'example.com', 'user->domain mapping is populated from DOMAIN lines');
+ is($scope->{user}{bob}{any}, 1, 'a DOMAIN line with the special "any" name flags the user as catch-all');
+};
+
+subtest 'block POSTs JSON-encoded rule body and returns the new rule id on success' => sub {
+ load_cloudflare(CF_BLOCK => 'block');
+
+ my %posted;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ my ($self, $url, %rest) = @_;
+ $posted{url} = $url;
+ $posted{content} = $rest{Content};
+ return Local::FakeCFResponse->new(
+ is_success => 1,
+ content => '{"result":{"id":"rule-block-1"}}',
+ );
+ };
+
+ my ($id, $status) = ConfigServer::CloudFlare::block('192.0.2.10');
+
+ is($id, 'rule-block-1', 'success path returns the new rule id from the parsed JSON response');
+ is($status, 'CloudFlare: block ip 192.0.2.10', 'success path returns a human-readable status string');
+ is($posted{url}, 'https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules', 'POST hits the access_rules endpoint');
+ like($posted{content}, qr/"target"\s*:\s*"ip"/, 'JSON body declares the ip target for a bare IPv4');
+ like($posted{content}, qr/"value"\s*:\s*"192\.0\.2\.10"/, 'JSON body carries the requested IP value');
+ like($posted{content}, qr/"mode"\s*:\s*"block"/, 'JSON body propagates the configured CF_BLOCK mode');
+};
+
+subtest 'block reports the status line when the upstream request fails' => sub {
+ load_cloudflare(CF_BLOCK => 'block');
+
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ return Local::FakeCFResponse->new(
+ is_success => 0,
+ content => '{"errors":[{"code":1000}]}',
+ status_line => '500 Internal Server Error',
+ );
+ };
+
+ my @result = ConfigServer::CloudFlare::block('192.0.2.10');
+
+ is(scalar @result, 1, 'failure path returns a single error string instead of (id, status)');
+ is($result[0], 'CloudFlare: [192.0.2.10] block failed: 500 Internal Server Error', 'error string includes the IP and the upstream status line');
+};
+
+subtest 'whitelist tags the rule with the whitelist mode and notes' => sub {
+ load_cloudflare();
+
+ my $body;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ my ($self, $url, %rest) = @_;
+ $body = $rest{Content};
+ return Local::FakeCFResponse->new(
+ is_success => 1,
+ content => '{"result":{"id":"rule-allow-1"}}',
+ );
+ };
+
+ my ($id, $status) = ConfigServer::CloudFlare::whitelist('US');
+
+ is($id, 'rule-allow-1', 'whitelist returns the new rule id on success');
+ is($status, 'CloudFlare: whitelisted country US', 'whitelist status string includes the target classification');
+ like($body, qr/"mode"\s*:\s*"whitelist"/, 'whitelist body sets mode=whitelist regardless of CF_BLOCK');
+ like($body, qr/"target"\s*:\s*"country"/, 'whitelist body classifies a two-letter token as country');
+};
+
+subtest 'remove returns an id-not-found error when no id is supplied and lookup is empty' => sub {
+ load_cloudflare();
+
+ no warnings qw(redefine once);
+ local *ConfigServer::CloudFlare::getid = sub { return '' };
+
+ my $status = ConfigServer::CloudFlare::remove('192.0.2.10', 'block', '');
+
+ is($status, 'CloudFlare: [192.0.2.10] remove failed: id not found', 'missing rule id surfaces a clear error from remove()');
+};
+
+subtest 'remove issues DELETE and reports success when an explicit id is given' => sub {
+ load_cloudflare();
+
+ my %deleted;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::delete = sub {
+ my ($self, $url, %rest) = @_;
+ $deleted{url} = $url;
+ return Local::FakeCFResponse->new(is_success => 1, content => '{}');
+ };
+
+ my $status = ConfigServer::CloudFlare::remove('192.0.2.10', 'block', 'rule-123');
+
+ is($status, 'CloudFlare: removed ip 192.0.2.10', 'remove returns a human-readable success status');
+ is($deleted{url}, 'https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules/rule-123', 'DELETE targets the rule id endpoint');
+};
+
+done_testing;
diff --git a/.github/tests/unit/messenger.t b/.github/tests/unit/messenger.t
new file mode 100644
index 0000000..292551d
--- /dev/null
+++ b/.github/tests/unit/messenger.t
@@ -0,0 +1,165 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+{
+ package Local::FakeMessengerClient;
+
+ sub new {
+ my ($class, $payload) = @_;
+ return bless { buf => [ split //, $payload ] }, $class;
+ }
+
+ sub read {
+ my $self = shift;
+ # Caller passes: $client->read($char, 1)
+ # $_[0] aliases the caller's $char, $_[1] aliases the length argument.
+ if (@{ $self->{buf} }) {
+ $_[0] = shift @{ $self->{buf} };
+ } else {
+ $_[0] = "\n";
+ }
+ return 1;
+ }
+}
+
+sub load_messenger {
+ my (%config) = @_;
+ $config{IPV6} //= 0;
+ $config{MESSENGER6} //= 0;
+ $config{RECAPTCHA_NAT} //= '';
+ $config{DEBUG} //= 0;
+ TestBootstrap::reload_module_with_config('ConfigServer::Messenger', \%config);
+ return;
+}
+
+# Override ConfigServer::GetEthDev so init(1) can run without probing real
+# network interfaces. Callers wrap a `local` block around their init() call.
+sub _stub_getethdev {
+ no warnings qw(redefine once);
+ *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' };
+ *ConfigServer::GetEthDev::ipv4 = sub { return () };
+ *ConfigServer::GetEthDev::ipv6 = sub { return () };
+ return;
+}
+
+subtest 'module exposes the documented public surface and a numeric VERSION' => sub {
+ load_messenger();
+
+ ok(ConfigServer::Messenger->can('init'), 'init() is part of the public API');
+ ok(ConfigServer::Messenger->can('start'), 'start() is part of the public API');
+ ok(ConfigServer::Messenger->can('messengerv2'), 'messengerv2() is callable as a class/package method');
+ ok(defined $ConfigServer::Messenger::VERSION, '$VERSION is defined at package level');
+ cmp_ok($ConfigServer::Messenger::VERSION, '>=', 3.00,
+ '$VERSION is at or above the documented 3.x baseline');
+};
+
+subtest 'init returns a blessed instance for version 2 without touching ssl directories' => sub {
+ load_messenger();
+
+ my $obj = ConfigServer::Messenger->init(2);
+
+ isa_ok($obj, 'ConfigServer::Messenger', 'init returns a Messenger object for the v2 (TEXT) flavor');
+ can_ok($obj, 'start');
+};
+
+subtest 'init(1) returns a blessed instance once GetEthDev is stubbed out' => sub {
+ load_messenger();
+
+ no warnings qw(redefine once);
+ local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' };
+ local *ConfigServer::GetEthDev::ipv4 = sub { return () };
+ local *ConfigServer::GetEthDev::ipv6 = sub { return () };
+
+ my $obj = ConfigServer::Messenger->init(1);
+
+ isa_ok($obj, 'ConfigServer::Messenger', 'init(1) returns a Messenger object for the v1 (HTTP/HTTPS) flavor');
+};
+
+subtest 'init(3) returns a blessed instance without dying on mkdir failures' => sub {
+ load_messenger();
+
+ my $obj = eval { ConfigServer::Messenger->init(3) };
+ is($@, '', 'init(3) does not throw when /var/lib/csf/ssl is not writable');
+ isa_ok($obj, 'ConfigServer::Messenger', 'init(3) returns a Messenger object for the v3 (HTTPS-CT) flavor');
+};
+
+subtest 'init(1) tolerates a populated RECAPTCHA_NAT list with spaces and multiple entries' => sub {
+ load_messenger(RECAPTCHA_NAT => '192.168.1.1, 10.0.0.1, 172.16.0.5');
+
+ no warnings qw(redefine once);
+ local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' };
+ local *ConfigServer::GetEthDev::ipv4 = sub { return () };
+ local *ConfigServer::GetEthDev::ipv6 = sub { return () };
+
+ my $obj = eval { ConfigServer::Messenger->init(1) };
+ is($@, '', 'init(1) does not die when RECAPTCHA_NAT carries a comma-separated IP list with whitespace');
+ isa_ok($obj, 'ConfigServer::Messenger', 'init(1) still returns a Messenger object with RECAPTCHA_NAT populated');
+};
+
+subtest 'init(1) tolerates the MESSENGER6 + IPV6 combination' => sub {
+ load_messenger(MESSENGER6 => 1, IPV6 => 1);
+
+ no warnings qw(redefine once);
+ local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' };
+ local *ConfigServer::GetEthDev::ipv4 = sub { return () };
+ local *ConfigServer::GetEthDev::ipv6 = sub { return () };
+
+ my $obj = eval { ConfigServer::Messenger->init(1) };
+ is($@, '', 'init(1) does not die when MESSENGER6 and IPV6 are both enabled');
+ isa_ok($obj, 'ConfigServer::Messenger', 'init(1) returns a Messenger object on an IPv6-enabled config');
+};
+
+subtest '_read_request_line returns an LF-terminated request line without the trailing newline' => sub {
+ load_messenger();
+
+ my $client = Local::FakeMessengerClient->new("GET / HTTP/1.0\n");
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ is($line, 'GET / HTTP/1.0', 'trailing LF is chomped off the returned line');
+};
+
+subtest '_read_request_line strips a trailing CR when the request uses CRLF' => sub {
+ load_messenger();
+
+ my $client = Local::FakeMessengerClient->new("GET /resource HTTP/1.1\r\n");
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ is($line, 'GET /resource HTTP/1.1', 'CRLF terminator is reduced to a bare request line');
+};
+
+subtest '_read_request_line stops reading once MAX_LINE_LENGTH bytes have been consumed' => sub {
+ load_messenger();
+
+ my $payload = ('A' x 5000) . "\n";
+ my $client = Local::FakeMessengerClient->new($payload);
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ ok(length($line) > ConfigServer::Messenger::MAX_LINE_LENGTH(),
+ 'overlong request lines exit the read loop without consuming the entire payload');
+ ok(length($line) <= ConfigServer::Messenger::MAX_LINE_LENGTH() + 1,
+ 'read stops within one byte of the documented MAX_LINE_LENGTH cap');
+ unlike($line, qr/\n/, 'returned line never contains an embedded newline');
+};
+
+subtest '_read_request_line accepts a line of exactly MAX_LINE_LENGTH bytes terminated by LF' => sub {
+ load_messenger();
+
+ my $max = ConfigServer::Messenger::MAX_LINE_LENGTH();
+ my $payload = ('C' x $max) . "\n";
+ my $client = Local::FakeMessengerClient->new($payload);
+
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ is(length($line), $max, 'a line exactly at MAX_LINE_LENGTH is read in full and the LF chomped');
+ is($line, 'C' x $max, 'the returned line is exactly the bytes that were provided');
+};
+
+done_testing;
diff --git a/.github/tests/unit/rblcheck.t b/.github/tests/unit/rblcheck.t
new file mode 100644
index 0000000..0415e85
--- /dev/null
+++ b/.github/tests/unit/rblcheck.t
@@ -0,0 +1,102 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+# RBLCheck pulls in GetIPs and RBLLookup, which both validate IPTABLES/HOST in
+# Config::loadconfig at import time. Load the whole chain once with a benign
+# mocked config before any other test code touches the module.
+BEGIN {
+ TestBootstrap::reload_module_with_config(
+ 'ConfigServer::RBLCheck',
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ also_delete => [qw(ConfigServer::GetIPs ConfigServer::RBLLookup)],
+ );
+}
+
+{
+ package Local::FakeEthDev;
+
+ sub new {
+ my ($class, %args) = @_;
+ return bless { ipv4 => $args{ipv4} || {} }, $class;
+ }
+
+ sub ipv4 { return %{ $_[0]->{ipv4} } }
+ sub ipv6 { return () }
+ sub ifaces { return () }
+}
+
+sub run_report_with_ips {
+ my ($ipv4) = @_;
+
+ # RBLCheck caches $output as a file-scope lexical that persists across
+ # report() calls. Reload the module so each test sees a fresh output buffer.
+ TestBootstrap::reload_module_with_config(
+ 'ConfigServer::RBLCheck',
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ );
+
+ no warnings qw(redefine once);
+ local *ConfigServer::GetEthDev::new = sub {
+ return Local::FakeEthDev->new(ipv4 => $ipv4);
+ };
+ local *ConfigServer::RBLCheck::slurp = sub { return () };
+ local *ConfigServer::RBLCheck::rbllookup = sub { return ('', '') };
+
+ my @result;
+ TestBootstrap::with_mock_config(
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ sub { @result = ConfigServer::RBLCheck::report(0, 0, 0) },
+ );
+ return @result;
+}
+
+subtest 'module loads cleanly and exposes the public reporting surface' => sub {
+ can_ok('ConfigServer::RBLCheck', qw(report addline addtitle startoutput endoutput getethdev));
+};
+
+subtest 'report() returns zero failures and a minimal output when no IPs are discovered' => sub {
+ my ($failures, $output) = run_report_with_ips({});
+
+ is($failures, 0, 'no IPs means no failures are recorded');
+ ok(defined $output && length $output, 'report still emits the endoutput marker even with no IPs');
+ like($output, qr/
/, 'output contains the closing
from endoutput');
+ unlike($output, qr/Not Checked/, 'no IPs means no per-IP "Not Checked" placeholder is added');
+};
+
+subtest 'report() marks a fresh PUBLIC IP as Not Checked in non-verbose mode' => sub {
+ my $ip = '8.8.8.8';
+
+ SKIP: {
+ skip "cached RBL state for $ip already exists on this host", 3
+ if -e "/var/lib/csf/$ip.rbls";
+
+ eval { require Net::IP; 1 } or skip 'Net::IP is not available', 3;
+ my $type = eval { Net::IP->new($ip)->iptype };
+ skip "Net::IP classifies $ip as $type (expected PUBLIC)", 3
+ unless defined $type && $type eq 'PUBLIC';
+
+ my ($failures, $output) = run_report_with_ips({ $ip => 1 });
+
+ is($failures, 0, 'a non-verbose Not Checked entry does not count as a failure');
+ like($output, qr/\QNew $ip (PUBLIC)\E/, 'addtitle announces the new public IP that has no cached result');
+ like($output, qr/Not Checked/, 'placeholder confirms the IP has not been actively checked');
+ }
+};
+
+subtest 'report() silently skips non-PUBLIC IPs when verbose is off' => sub {
+ my ($failures, $output) = run_report_with_ips({ '10.0.0.1' => 1 });
+
+ is($failures, 0, 'a skipped private IP does not raise the failure count');
+ unlike($output, qr/10\.0\.0\.1/, 'a non-PUBLIC IP produces no titled output in non-verbose mode');
+ unlike($output, qr/Not Checked/, 'no Not Checked placeholder is emitted for skipped IPs');
+};
+
+done_testing;
diff --git a/.github/tests/unit/servercheck.t b/.github/tests/unit/servercheck.t
new file mode 100644
index 0000000..f97fb6b
--- /dev/null
+++ b/.github/tests/unit/servercheck.t
@@ -0,0 +1,71 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+# ServerCheck pulls in ConfigServer::Service, which caches loadconfig() at
+# import time and rejects an empty IPTABLES path. Prime the module chain with
+# a benign mocked config before any other test code touches it.
+#
+# NOTE: ServerCheck.pm intentionally has a small unit-test surface here.
+# Almost every public sub (report, firewallcheck, servicescheck, mailcheck,
+# apachecheck, phpcheck, whmcheck, sshtelnetcheck, dacheck) opens hardcoded
+# paths under /etc/csf, /proc, and various control-panel install prefixes,
+# shells out to iptables/systemctl/chkconfig, and writes results to a
+# file-scope $output lexical. Without refactor-time seams these cannot be
+# driven from a unit test on this machine. Phase B should extract the
+# parsing helpers (firewall config line parser, services list filter,
+# /proc/net port scan) into pure modules.
+BEGIN {
+ TestBootstrap::reload_module_with_config(
+ 'ConfigServer::ServerCheck',
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ also_delete => [qw(ConfigServer::Service ConfigServer::GetIPs)],
+ );
+}
+
+subtest 'module loads cleanly and exposes the documented check entry points' => sub {
+ can_ok(
+ 'ConfigServer::ServerCheck',
+ qw(
+ report startoutput endoutput addline addtitle
+ firewallcheck servercheck mailcheck apachecheck phpcheck
+ whmcheck dacheck sshtelnetcheck servicescheck getportinfo
+ ),
+ );
+};
+
+subtest 'ipv4reg and ipv6reg class methods return non-empty regex strings' => sub {
+ require ConfigServer::Config;
+
+ my $v4 = ConfigServer::Config->ipv4reg;
+ my $v6 = ConfigServer::Config->ipv6reg;
+
+ ok(defined $v4 && length $v4, 'ipv4reg returns a non-empty regex string');
+ ok(defined $v6 && length $v6, 'ipv6reg returns a non-empty regex string');
+ like('192.0.2.10', qr/^$v4$/, 'ipv4reg matches a dotted-quad IPv4 address');
+ like('2001:db8::1', qr/^$v6$/, 'ipv6reg matches a compact IPv6 address');
+};
+
+subtest 'getportinfo returns 0 for ports that cannot occur in /proc/net' => sub {
+ SKIP: {
+ skip 'Linux /proc/net is required for getportinfo', 2
+ unless -r '/proc/net/tcp';
+
+ # Port numbers above 65535 cannot be present in any /proc/net file, so
+ # getportinfo must return 0 regardless of what is actually bound.
+ my $hit = ConfigServer::ServerCheck::getportinfo(99999);
+ is($hit, 0, 'out-of-range port number is reported as not listening');
+
+ my $hit_neg = ConfigServer::ServerCheck::getportinfo(-1);
+ is($hit_neg, 0, 'negative port number is reported as not listening');
+ }
+};
+
+done_testing;
diff --git a/.github/tests/unit/serverstats.t b/.github/tests/unit/serverstats.t
new file mode 100644
index 0000000..75e1664
--- /dev/null
+++ b/.github/tests/unit/serverstats.t
@@ -0,0 +1,95 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+use ConfigServer::ServerStats;
+
+sub reset_serverstats_state {
+ delete $INC{'ConfigServer/ServerStats.pm'};
+ require ConfigServer::ServerStats;
+ return;
+}
+
+subtest 'init returns undef when GD::Graph::bars is unavailable' => sub {
+ my $loaded = eval { require GD::Graph::bars; 1 };
+
+ SKIP: {
+ skip 'GD::Graph::bars is installed in this environment', 1 if $loaded;
+
+ my $result = ConfigServer::ServerStats::init();
+ is($result, undef, 'init returns undef when GD::Graph backends cannot load');
+ }
+};
+
+subtest 'charts_html assembles the standard four-image layout' => sub {
+ my $html = ConfigServer::ServerStats::charts_html(0, '/img/');
+
+ like($html, qr{
| Min:10\.00 | Max:30\.00 | Avg:60\.00}, 'HOUR cpu row reports MIN=10, MAX=30, and the cumulative AVG sum'); +}; + +subtest 'minmaxavg keeps each (graph, name) bucket isolated' => sub { + reset_serverstats_state(); + + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 10); + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 20); + ConfigServer::ServerStats::minmaxavg('HOUR', '2load', 5); + ConfigServer::ServerStats::minmaxavg('DAY', '1cpu', 100); + ConfigServer::ServerStats::minmaxavg('WEEK', '3disk', 99); + ConfigServer::ServerStats::minmaxavg('MONTH', '4mem', 512); + + my $html = ConfigServer::ServerStats::graphs_html('/g/'); + + like($html, qr{cpu | Min:10\.00 | Max:20\.00}, 'HOUR cpu bucket shows only the HOUR samples'); + like($html, qr{load | Min:5\.00 | Max:5\.00}, 'HOUR load bucket is independent of cpu'); + like($html, qr{cpu | Min:100\.00 | Max:100\.00}, 'DAY cpu bucket is independent of HOUR cpu bucket'); + like($html, qr{disk | Min:99\.00}, 'WEEK bucket shows the WEEK sample only'); + like($html, qr{mem | Min:512\.00}, 'MONTH bucket shows the MONTH sample only'); +}; + +subtest 'graphs_html targets the configured image directory for every timeframe' => sub { + reset_serverstats_state(); + + ConfigServer::ServerStats::minmaxavg('HOUR', '1x', 1); + ConfigServer::ServerStats::minmaxavg('DAY', '1x', 1); + ConfigServer::ServerStats::minmaxavg('WEEK', '1x', 1); + ConfigServer::ServerStats::minmaxavg('MONTH', '1x', 1); + + my $html = ConfigServer::ServerStats::graphs_html('/cdn/'); + + like($html, qr{src='/cdn/lfd_systemhour\.gif\?text=\d+}, 'HOUR image src uses the supplied imgdir'); + like($html, qr{src='/cdn/lfd_systemday\.gif\?text=\d+}, 'DAY image src uses the supplied imgdir'); + like($html, qr{src='/cdn/lfd_systemweek\.gif\?text=\d+}, 'WEEK image src uses the supplied imgdir'); + like($html, qr{src='/cdn/lfd_systemmonth\.gif\?text=\d+}, 'MONTH image src uses the supplied imgdir'); +}; + +done_testing; |