HTML-OSM
view release on metacpan or search on metacpan
t/integration.t view on Meta::CPAN
my $with_zoom = HTML::OSM->new(config_file => $path, zoom => 14);
is($from_cfg->zoom(), 7, 'config file zoom overrides programmatic default (12)');
is($with_zoom->zoom(), 7, 'config file zoom wins over constructor param');
};
subtest 'config file: YAML css_url appears in rendered head' => sub {
my ($fh, $path) = tempfile(SUFFIX => '.yml', UNLINK => 1);
print $fh "---\ncss_url: https://mycdn.example.com/leaflet.css\n";
close $fh;
my $m = HTML::OSM->new(config_file => $path);
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
my ($head) = $m->onload_render();
like($head, qr{mycdn\.example\.com/leaflet\.css}, 'config css_url in head');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 7: Multi-step state transitions
# State accumulated across many method calls must be coherent at render time.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'state: final zoom after multiple setter calls' => sub {
my $m = HTML::OSM->new();
$m->zoom(3);
$m->zoom(8);
$m->zoom(16);
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
my (undef, $body) = $m->onload_render();
like($body, qr/setView\([^)]+,\s*16\)/, 'final zoom 16 in setView');
unlike($body, qr/setView\([^)]+,\s*3\)/, 'initial zoom 3 not present');
};
subtest 'state: second center() call wins over first' => sub {
my $m = HTML::OSM->new();
$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
$m->center([$C{LAT_NYC}, $C{LON_NYC}]);
$m->add_geojson({ type => 'FeatureCollection', features => [] });
my (undef, $body) = $m->onload_render();
like($body, qr/setView\(\[$C{LAT_NYC}/, 'second center used');
unlike($body, qr/setView\(\[$C{LAT_LONDON}/, 'first center discarded');
};
subtest 'state: sequential add_* calls all visible in single render' => sub {
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => 'L1');
$m->add_geojson({ type => 'FeatureCollection', features => [] },
style => { color => '#aabbcc' });
$m->add_heatmap([[$C{LAT_PARIS}, $C{LON_PARIS}]], radius => 30);
my (undef, $body) = $m->onload_render();
like($body, qr/L1/, 'marker label present');
like($body, qr/#aabbcc/, 'GeoJSON colour present');
like($body, qr/radius:\s*30/, 'heatmap custom radius present');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 8: JS escaping pipeline â end-to-end injection attack vectors
# Every user-supplied string that reaches the rendered JS must be escaped.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'XSS: </script> injection in popup label neutralised' => sub {
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}],
html => '</script><script>alert(1)</script>');
my (undef, $body) = $m->onload_render();
unlike($body, qr|</script><script>|, 'raw </script> absent');
like($body, qr|<\\/script>|, 'escaped form present');
};
subtest 'XSS: backslash + single-quote combo fully escaped in popup' => sub {
# Input: C:\path\' â backslash then quote
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => "C:\\path\\'s");
my (undef, $body) = $m->onload_render();
like($body, qr/C:\\\\path/, 'backslash doubled');
};
subtest 'XSS: newline in popup label converted to literal \\n in JS' => sub {
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => "Line1\nLine2");
my (undef, $body) = $m->onload_render();
unlike($body, qr/Line1\nLine2/, 'raw newline absent');
like($body, qr/Line1\\nLine2/, 'escaped \\n present');
};
subtest 'XSS: GPX URL with single quote JS-escaped end-to-end' => sub {
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
$m->add_gpx("https://example.com/user's%20track.gpx");
my (undef, $body) = $m->onload_render();
unlike($body, qr|user's%20track|, 'raw single quote absent from GPX URL');
like($body, qr|user\\'s%20track|, 'escaped quote present');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 9: Center computation strategies
# POD pseudocode step 3: caller-supplied center > midpoint of marker bounds.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'center strategy: single marker is its own center' => sub {
my $m = HTML::OSM->new();
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}]);
my (undef, $body) = $m->onload_render();
like($body, qr/setView\(\[$C{LAT_LONDON}/, 'single-marker center is the marker itself');
};
subtest 'center strategy: four markers â midpoint computed correctly' => sub {
# min_lat=10 max_lat=20 â 15; min_lon=30 max_lon=50 â 40.
my $m = HTML::OSM->new();
$m->add_marker([10, 30]);
$m->add_marker([10, 50]);
$m->add_marker([20, 30]);
$m->add_marker([20, 50]);
my (undef, $body) = $m->onload_render();
like($body, qr/setView\(\[15, 40\]/, 'midpoint (15, 40) used');
};
subtest 'center strategy: explicit center beats midpoint' => sub {
my $m = HTML::OSM->new();
$m->add_marker([10, 10]);
$m->add_marker([-10, -10]); # midpoint would be (0, 0)
$m->center([$C{LAT_NYC}, $C{LON_NYC}]);
my (undef, $body) = $m->onload_render();
like($body, qr/setView\(\[$C{LAT_NYC}/, 'explicit center wins');
unlike($body, qr/setView\(\[0, 0\]/, 'midpoint (0,0) NOT used');
};
subtest 'center strategy: no markers + no explicit center â croak' => sub {
my $m = HTML::OSM->new();
$m->add_geojson({ type => 'FeatureCollection', features => [] });
throws_ok { $m->onload_render() }
qr/center\(\) must be called when no point markers are provided/,
'croak when center cannot be determined';
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 10: Clustering pipeline
# cluster => 1 must wrap every marker in L.markerClusterGroup.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'clustering: three markers wrapped in one clusterGroup' => sub {
my $m = HTML::OSM->new(cluster => 1);
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}], html => 'London');
$m->add_marker([$C{LAT_PARIS}, $C{LON_PARIS}], html => 'Paris');
$m->add_marker([$C{LAT_NYC}, $C{LON_NYC}], html => 'NYC');
my ($head, $body) = $m->onload_render();
like($head, qr/markercluster.*\.js/i, 'cluster JS in head');
like($head, qr/MarkerCluster\.css/, 'cluster CSS in head');
like($head, qr/MarkerCluster\.Default\.css/, 'cluster default CSS in head');
my @adds = ($body =~ /clusterGroup\.addLayer/g);
is(scalar @adds, 3, 'three addLayer calls for three markers');
like($body, qr/map\.addLayer\(clusterGroup\)/, 'clusterGroup added to map');
# No individual addTo(map) for static numeric-coord markers when clustering is active.
# The search-handler template also emits L.marker([lat,lon]).addTo(map) with JS
# variable names; restrict the match to numeric literals to avoid false positives.
unlike($body, qr/L\.marker\(\[-?\d[^;]*addTo\(map\)/, 'no direct addTo(map) for individual markers');
};
subtest 'clustering: icon marker uses clusterGroup.addLayer in IIFE' => sub {
my $m = HTML::OSM->new(cluster => 1);
$m->add_marker([$C{LAT_LONDON}, $C{LON_LONDON}],
html => 'London', icon => 'https://ex.com/pin.png');
my (undef, $body) = $m->onload_render();
like($body, qr/L\.icon/, 'L.icon call present');
like($body, qr/clusterGroup\.addLayer\(m\)/, 'icon marker via clusterGroup');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 11: Choropleth full pipeline
# Verify that colours computed in Perl land in the rendered JS lookup object.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'choropleth: five-feature graduated scale ends up in rendered body' => sub {
my @feats = map {
{ type => 'Feature', properties => { name => "R$_" },
geometry => { type => 'Point', coordinates => [0,0] } }
} 1..5;
my $m = HTML::OSM->new();
$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
$m->add_choropleth(\@feats,
{ R1 => 10, R2 => 20, R3 => 30, R4 => 40, R5 => 50 },
scale => ['#col0', '#col1', '#col2', '#col3', '#col4'],
);
my (undef, $body) = $m->onload_render();
like($body, qr/#col0/, 'low-end colour in body');
like($body, qr/#col4/, 'high-end colour in body');
like($body, qr/fillColor/, 'fillColor property emitted');
like($body, qr/choroplethValues/, 'value lookup emitted');
};
subtest 'choropleth: popup template binds key and value' => sub {
my $m = HTML::OSM->new();
$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
$m->add_choropleth(\@FEATURES, { England => 100, Scotland => 50 });
my (undef, $body) = $m->onload_render();
# Emitted JS: k + ': ' + choroplethValues[k]
like($body, qr/choroplethValues\[k\]/, 'value lookup present in popup template');
};
subtest 'choropleth: multiple layers all rendered in body' => sub {
my $m = HTML::OSM->new();
$m->center([$C{LAT_LONDON}, $C{LON_LONDON}]);
$m->add_choropleth(\@FEATURES, { England => 100 });
$m->add_choropleth(\@FEATURES, { England => 200 });
my (undef, $body) = $m->onload_render();
my @decls = ($body =~ /var choroplethColors/g);
is(scalar @decls, 2, 'two choroplethColors declarations emitted');
};
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Integration 12: Rate-limiting interaction
# min_interval > 0 must cause Time::HiRes::sleep when a request follows too
# soon after the previous one. We spy on sleep without real sleeping.
# âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
subtest 'rate limiting: sleep called when elapsed < min_interval' => sub {
my $sleep_calls = 0;
my $slept_for = 0;
# Spy on sleep so no real wall time is consumed.
mock 'Time::HiRes::sleep' => sub { $sleep_calls++; $slept_for = $_[0] };
# Fix time() inside HTML::OSM to a constant so elapsed = 0.
mock 'HTML::OSM::time' => sub { 1_000_000 };
my $json = '[{"lat":"51.5","lon":"-0.1"}]';
my $resp = bless {}, 'RLResp';
mock 'RLResp::is_success' => sub { 1 };
mock 'RLResp::decoded_content' => sub { $json };
my $ua = bless {}, 'RLUA';
mock 'RLUA::default_header' => sub { };
mock 'RLUA::env_proxy' => sub { };
mock 'RLUA::get' => sub { $resp };
my $m = HTML::OSM->new(min_interval => $C{MIN_INTERVAL}, ua => $ua);
# Simulate: a request was made "just now" so elapsed = 0 < 2.
$m->{last_request} = 1_000_000;
$m->add_marker('London');
ok($sleep_calls > 0, 'sleep was called');
cmp_ok($slept_for, '>', 0, 'slept for positive duration');
cmp_ok($slept_for, '<=', $C{MIN_INTERVAL}, 'slept at most min_interval');
diag("sleep_calls=$sleep_calls slept_for=$slept_for") if $ENV{TEST_VERBOSE};
};
subtest 'rate limiting: no sleep when min_interval is 0' => sub {
my $sleep_calls = 0;
mock 'Time::HiRes::sleep' => sub { $sleep_calls++ };
my $json = '[{"lat":"51.5","lon":"-0.1"}]';
my $resp = bless {}, 'RL0Resp';
mock 'RL0Resp::is_success' => sub { 1 };
mock 'RL0Resp::decoded_content' => sub { $json };
my $ua = bless {}, 'RL0UA';
mock 'RL0UA::default_header' => sub { };
( run in 1.381 second using v1.01-cache-2.11-cpan-364913b4093 )