App-karr
view release on metacpan or search on metacpan
t/89-activity-log-refname.t view on Meta::CPAN
# ...and it died *after* the claim had been written, leaving the task
# claimed and its lock ref behind. The same mapping also collapsed four
# distinct addresses (a b@x, a-b@x, a+b@x, a/b@x) onto one log ref, and
# turned every non-ASCII byte into '_'.
#
# The identity is now percent-encoded: legal by construction, injective,
# and reversible via decode_identity.
sub _init_repo {
my ($email) = @_;
my $repo = tempdir( CLEANUP => 1 );
system( 'git', 'init', '-q', $repo );
system( 'git', '-C', $repo, 'config', 'user.email', $email );
system( 'git', '-C', $repo, 'config', 'user.name', 'Test User' );
return $repo;
}
sub _run_execute {
my ( $cmd, @args ) = @_;
my $out;
my $err = do {
local $@;
eval {
local *STDOUT;
open STDOUT, '>', \$out or die $!;
$cmd->execute( \@args, [] );
};
$@;
};
return ( $err, $out );
}
sub _log_for {
my ( $email, %args ) = @_;
my $log = App::karr::ActivityLog->new( git => undef, %args );
no warnings 'redefine';
local *App::karr::ActivityLog::_email = sub { $email };
return $log->identity;
}
subtest 'addresses git ref names cannot hold are encoded, not mangled' => sub {
for my $email (
'a..b@example.com', # '..' is illegal anywhere in a ref name
'ab@example.lock', # a component may not end in '.lock'
'.leading@example.com', # a component may not start with '.'
'trailing.@example.com', # ...nor end with '.'
'sp ace@example.com',
'q?uestion@example.com',
'star*@example.com',
'tilde~caret^@example.com',
'colon:@example.com',
'brack[et@example.com',
'back\\slash@example.com',
'at@{brace@example.com',
'@',
)
{
my $id = _log_for($email);
ok( Git::Native->reference_name_is_valid("refs/karr/log/$id"),
"'$email' -> refs/karr/log/$id is a valid ref name" );
my ( $role, $decoded ) = App::karr::ActivityLog->decode_identity($id);
is( $decoded, $email, "'$email' round-trips out of the ref name" );
is( $role, 'user', "'$email' keeps its role component" );
}
};
subtest 'distinct addresses never share one log ref' => sub {
my @colliding = ( 'a b@x.com', 'a-b@x.com', 'a+b@x.com', 'a/b@x.com', 'a_b@x.com' );
my %seen;
$seen{ _log_for($_) }++ for @colliding;
is( scalar keys %seen, scalar @colliding,
'five addresses that used to sanitize to a_b_x.com get five refs' );
};
subtest 'a non-ASCII address survives as UTF-8, not as mojibake' => sub {
my $octets = "j\xc3\xbcrgen\@example.com"; # what libgit2 hands back
my $chars = decode( 'UTF-8', $octets );
my $from_octets = _log_for($octets);
my $from_chars = _log_for($chars);
is( $from_chars, $from_octets,
'characters and octets encode to the same ref name' );
is( $from_octets, 'user/j%C3%BCrgen%40example.com',
'the UTF-8 octets are percent-encoded, not replaced by _' );
ok( Git::Native->reference_name_is_valid("refs/karr/log/$from_octets"),
'and the name is legal' );
my ( undef, $decoded ) = App::karr::ActivityLog->decode_identity($from_octets);
is( $decoded, $chars, 'decodes back to the original characters' );
};
subtest 'the role component is encoded too' => sub {
my $id = _log_for( 'dev@example.com', role => 'weird/role..name' );
ok( Git::Native->reference_name_is_valid("refs/karr/log/$id"),
"role 'weird/role..name' still yields a valid ref name" );
my ($role) = App::karr::ActivityLog->decode_identity($id);
is( $role, 'weird/role..name', 'role round-trips' );
};
subtest 'pick with an invalid-under-the-old-scheme email completes' => sub {
my $repo = _init_repo('a..b@example.com');
my $git = App::karr::Git->new( dir => $repo );
$git->write_ref( 'refs/karr/config', Dump( { version => 1, board => { name => 'T' } } ) );
$git->write_ref( 'refs/karr/meta/next-id', "2\n" );
my $store = App::karr::BoardStore->new( git => $git );
$store->save_task(
App::karr::Task->new(
id => 1,
title => 'Task 1',
status => 'todo',
priority => 'high',
class => 'standard',
)
);
my $cmd = App::karr::Cmd::Pick->new( store => $store, claim => 'agent-test' );
my ( $err, $out ) = _run_execute($cmd);
is( $err, '', 'pick does not die on the log write' ) or diag("died with: $err");
like( $out, qr/Picked task 1/, 'the pick was reported' );
ok( !$git->ref_exists('refs/karr/tasks/1/lock'),
'the task lock was released rather than left behind' );
my @entries = App::karr::ActivityLog->new( git => $git )->entries;
ok( ( grep { ( $_->{action} // '' ) eq 'pick' } @entries ),
'the pick reached the activity log' );
};
subtest 'refs written under the old naming schemes are still read' => sub {
my $repo = _init_repo('dev@example.com');
my $git = App::karr::Git->new( dir => $repo );
# Pre-role scheme: bare sanitized email.
$git->write_ref( 'refs/karr/log/dev_example.com',
'{"ts":"2026-01-01T00:00:00Z","agent":"old","action":"move","task_id":1}' );
# Role-qualified scheme with the pre-#75 sanitizer.
$git->write_ref( 'refs/karr/log/user/dev_example.com',
'{"ts":"2026-02-01T00:00:00Z","agent":"older","action":"edit","task_id":2}' );
my $log = App::karr::ActivityLog->new( git => $git );
$log->log_entry( agent => 'now', action => 'create', task_id => 3,
ts => '2026-03-01T00:00:00Z' );
ok( $git->ref_exists('refs/karr/log/user/dev%40example.com'),
'the new entry went to the percent-encoded ref' );
my @entries = $log->entries;
is_deeply( [ map { $_->{task_id} } @entries ], [ 1, 2, 3 ],
'both legacy refs are merged in ahead of the current one' );
( run in 1.714 second using v1.01-cache-2.11-cpan-364913b4093 )