ForgeOps-Tracker
view release on metacpan or search on metacpan
t/changes.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use FindBin qw($Bin);
use lib "$Bin/../lib", "$Bin/..";
use JSON::PP qw(decode_json encode_json);
use ForgeOps::Tracker;
use ForgeOps::Tracker::Changes;
use ForgeOps::Tracker::Configuration;
use t::lib::EchoServer;
sub wait_until {
my ($predicate, $timeout) = @_;
$timeout //= 2;
my $deadline = time + $timeout;
while (time < $deadline) {
return 1 if $predicate->();
select(undef, undef, undef, 0.02);
}
return 0;
}
my $server = t::lib::EchoServer->start;
sub requests_to {
my ($path) = @_;
return [ grep { $_->{path} eq $path } @{ $server->requests } ];
}
sub init_enabled {
my (%overrides) = @_;
return ForgeOps::Tracker::init(
dsn => 'http://secret-key@127.0.0.1:' . $server->{port} . '/api/v1/events',
enabled_environments => { production => 1 },
environment => 'production',
%overrides,
);
}
sub configuration {
my $config = ForgeOps::Tracker::Configuration->new;
$config->{environment} = 'production';
return $config;
}
# record_change
subtest 'record_change posts the full payload to the changes endpoint' => sub {
ForgeOps::Tracker::_reset_for_testing();
init_enabled();
my $queued = ForgeOps::Tracker::record_change(
kind => 'feature_flag',
title => 'Enabled new_checkout for 10%',
details => { flag => 'new_checkout', rollout => 10 },
service => 'web',
actor => 'luke',
url => 'https://flags.example.com/new_checkout',
id => 'flag-42',
occurred_at => 1790337600, # 2026-09-25T12:00:00Z
);
is($queued, 1);
ok(wait_until(sub { @{ requests_to('/api/v1/changes') } >= 1 }));
my $request = requests_to('/api/v1/changes')->[-1];
is($request->{headers}{AUTHORIZATION}, 'Bearer secret-key');
is_deeply(decode_json($request->{body}), {
kind => 'feature_flag',
title => 'Enabled new_checkout for 10%',
details => { flag => 'new_checkout', rollout => 10 },
environment => 'production',
service => 'web',
actor => 'luke',
url => 'https://flags.example.com/new_checkout',
id => 'flag-42',
occurred_at => '2026-09-25T12:00:00.000Z',
});
};
subtest 'optional fields are left out and occurred_at defaults to now' => sub {
my $payload = ForgeOps::Tracker::Changes::build_change(configuration(), kind => 'config', title => 'Raised the timeout');
is_deeply([ sort keys %$payload ], [qw(details environment kind occurred_at title)]);
is(encode_json($payload->{details}), '{}');
like($payload->{occurred_at}, qr/\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\z/);
is(ForgeOps::Tracker::Changes::build_change(configuration(), kind => 'config', title => 'x', occurred_at => '2026-09-25T12:00:00Z')->{occurred_at}, '2026-09-25T12:00:00Z');
};
subtest 'an explicit environment overrides the configured one' => sub {
is(ForgeOps::Tracker::Changes::build_change(configuration(), kind => 'config', title => 'x', environment => 'staging')->{environment}, 'staging');
};
subtest 'every known kind is sent as is, and anything else as other' => sub {
for my $kind (@ForgeOps::Tracker::Changes::KINDS) {
is(ForgeOps::Tracker::Changes::build_change(configuration(), kind => $kind, title => 'x')->{kind}, $kind);
}
for my $kind ('deploy', '', undef, 'FEATURE_FLAG') {
is(ForgeOps::Tracker::Changes::build_change(configuration(), kind => $kind, title => 'x')->{kind}, 'other');
}
};
subtest 'the title is truncated to 200 characters and non-hash details are dropped' => sub {
my $payload = ForgeOps::Tracker::Changes::build_change(configuration(), kind => 'config', title => 'x' x 300, details => [1, 2]);
is($payload->{title}, 'x' x 200);
is_deeply($payload->{details}, {});
};
t/changes.t view on Meta::CPAN
subtest 'record_change does nothing when the client is not enabled' => sub {
ForgeOps::Tracker::_reset_for_testing();
init_enabled(environment => 'development');
my $before = @{ requests_to('/api/v1/changes') };
is(ForgeOps::Tracker::record_change(kind => 'config', title => 'Raised the timeout'), 0);
select(undef, undef, undef, 0.2);
is(scalar @{ requests_to('/api/v1/changes') }, $before);
};
subtest 'record_change never dies on an error response or an unreachable server' => sub {
ForgeOps::Tracker::_reset_for_testing();
ForgeOps::Tracker::init(
dsn => 'http://key@127.0.0.1:1/api/v1/events', # nothing listens on port 1
enabled_environments => { production => 1 },
environment => 'production',
timeout => 1,
);
my $queued = eval { ForgeOps::Tracker::record_change(kind => 'config', title => 'x') };
is($@, '');
is($queued, 1);
select(undef, undef, undef, 0.2);
};
# Startup snapshot
subtest 'the snapshot has the Perl runtime and no dependencies or env var names by default' => sub {
my $snapshot = ForgeOps::Tracker::Changes::build_snapshot(configuration());
is($snapshot->{environment}, 'production');
is($snapshot->{state}{runtime}, sprintf('perl %vd', $^V));
ok(!exists $snapshot->{state}{dependencies}, 'no reliable source in Perl, so left out');
ok(!exists $snapshot->{state}{schema_version});
ok(!exists $snapshot->{state}{env_var_names});
};
subtest 'env var names, never values, are sent when track_env_var_names is on' => sub {
my $config = configuration();
$config->{track_env_var_names} = 1;
local %ENV = (DATABASE_URL => 'postgres://secret', HOSTNAME => 'web-1', STRIPE_KEY => 'sk_live');
my $snapshot = ForgeOps::Tracker::Changes::build_snapshot($config);
is_deeply($snapshot->{state}{env_var_names}, [qw(DATABASE_URL STRIPE_KEY)]);
unlike(encode_json($snapshot), qr/sk_live|postgres:|web-1/);
};
subtest 'the env var denylist drops host-specific noise and the client\'s own settings' => sub {
my @names = qw(
HOSTNAME HOST HOME PATH PWD OLDPWD SHLVL _ TERM USER LOGNAME SHELL LANG LC_ALL LC_CTYPE TMPDIR TZ
PORT DYNO INVOCATION_ID JOURNAL_STREAM SYSTEMD_EXEC_PID MEMORY_PRESSURE_WATCH KUBERNETES_SERVICE_HOST
REDIS_SERVICE_HOST REDIS_SERVICE_PORT REDIS_SERVICE_PORT_HTTP REDIS_PORT_6379_TCP REDIS_PORT_6379_TCP_ADDR
FORGE_OPS_DSN FORGE_OPS_RELEASE SECRET_KEY PLACK_ENV DATABASE_URL
);
is_deeply(ForgeOps::Tracker::Changes::env_var_names({ map { $_ => 'value' } @names }), [qw(DATABASE_URL PLACK_ENV SECRET_KEY)]);
};
subtest 'init sends one snapshot to the change_snapshots endpoint, once per process' => sub {
ForgeOps::Tracker::_reset_for_testing(change_snapshot => 1);
my $before = @{ requests_to('/api/v1/change_snapshots') };
init_enabled();
init_enabled();
ok(wait_until(sub { @{ requests_to('/api/v1/change_snapshots') } > $before }));
my $request = requests_to('/api/v1/change_snapshots')->[-1];
is($request->{headers}{AUTHORIZATION}, 'Bearer secret-key');
my $body = decode_json($request->{body});
is($body->{environment}, 'production');
is($body->{state}{runtime}, sprintf('perl %vd', $^V));
select(undef, undef, undef, 0.2);
is(scalar @{ requests_to('/api/v1/change_snapshots') }, $before + 1, 'not once per init()');
};
subtest 'detect_changes => 0 sends no snapshot' => sub {
ForgeOps::Tracker::_reset_for_testing(change_snapshot => 1);
my $before = @{ requests_to('/api/v1/change_snapshots') };
init_enabled(detect_changes => 0);
select(undef, undef, undef, 0.2);
is(scalar @{ requests_to('/api/v1/change_snapshots') }, $before);
};
subtest 'no snapshot is sent when the client is not enabled' => sub {
ForgeOps::Tracker::_reset_for_testing(change_snapshot => 1);
my $before = @{ requests_to('/api/v1/change_snapshots') };
init_enabled(environment => 'development');
select(undef, undef, undef, 0.2);
is(scalar @{ requests_to('/api/v1/change_snapshots') }, $before);
};
subtest 'a failing snapshot never dies' => sub {
ForgeOps::Tracker::_reset_for_testing(change_snapshot => 1);
eval {
ForgeOps::Tracker::init(
dsn => 'http://key@127.0.0.1:1/api/v1/events',
enabled_environments => { production => 1 },
environment => 'production',
timeout => 1,
);
};
is($@, '');
select(undef, undef, undef, 0.2);
};
ForgeOps::Tracker::_reset_for_testing();
END { $server->stop if $server }
done_testing;
( run in 0.795 second using v1.01-cache-2.11-cpan-16aabcbfbcb )