App-karr
view release on metacpan or search on metacpan
t/223-terminal-claim-released.t view on Meta::CPAN
use strict;
use warnings;
use Test::More;
use lib 't/lib';
use TestGit qw( require_git_c );
require_git_c();
use TestKarr qw( run_karr );
use File::Temp qw( tempdir );
use Time::Piece;
use App::karr::Config;
use App::karr::Task;
use App::karr::Error qw( set_original_argv );
use MockStore;
# Ticket #223. App::karr::Role::ClaimTimeout::check_claim knew four cases --
# unclaimed, the claimant matches, the claim expired, otherwise refuse -- and
# not one of them asked what status the card was in. So the agent that finished
# a card went on guarding it for the rest of claim_timeout (1h by default):
#
# karr move 1 done --claim alpha-one
# karr edit 1 -a "a late note" -> Task 1 is claimed by alpha-one
# karr move 1 todo -> Task 1 is claimed by alpha-one
# karr archive 1 -> Task 1 is claimed by alpha-one
#
# CONTEXT.md defines a Claim as the lease an agent holds *while working* a task
# and calls it released once the task reaches a terminal status, with
# claimed_by kept there for provenance -- which is why `karr board` prints no
# claimant on a finished card and does not count it in its "N claimed" footer.
# The name the refusal demanded was therefore one the board deliberately hides,
# and the guard was never a lock anyway: `karr edit ID --release` takes any
# claim off without knowing whose it is.
#
# What this pins, in both directions:
#
# * a card in a terminal status is not guarded, for all five commands that
# apply the rule (edit, move, delete, archive, handoff);
# * a card in a working status still is, for the same five -- the fix must
# stay a fifth case and not become a deleted guard;
# * "terminal" is the board's own word (App::karr::Config/is_terminal_status),
# so a board that ends in `shipped` releases claims there and nowhere else;
# * the expired-claim trace of ticket #177 survives on a terminal card, which
# is why the new case is checked after the expiry test rather than before
# it.
# In-process runner (t/lib/TestKarr.pm): same ($cwd, @argv) signature and
# { exit, stdout, stderr } return as the open3 helper this file used to carry,
# dispatched through the shared App::karr::Dispatch path. KARR_TEST_SUBPROC=1
# restores the old open3 path.
sub _run_karr { return run_karr(@_) }
# Always a throwaway repo; never the developer's real board.
sub _setup_repo {
my (%opt) = @_;
my $repo = tempdir( CLEANUP => 1 );
system( 'git', 'init', '-q', $repo ) == 0 or die 'git init failed';
system( 'git', '-C', $repo, 'config', 'user.email', 'test@example.com' ) == 0
or die 'git config failed';
system( 'git', '-C', $repo, 'config', 'user.name', 'Test User' ) == 0
or die 'git config failed';
is( _run_karr( $repo, 'init', '--name', 'Ticket223 Board' )->{exit}, 0,
'setup: karr init exits 0' );
for my $n ( 1 .. ( $opt{tasks} || 1 ) ) {
is( _run_karr( $repo, 'create', "Card $n" )->{exit}, 0,
"setup: card $n created" );
}
return $repo;
}
sub _field_of {
my ( $repo, $id, $label ) = @_;
my $rv = _run_karr( $repo, 'show', $id );
my ($value) = $rv->{stdout} =~ /^\Q$label\E:\s+(\S+)/m;
t/223-terminal-claim-released.t view on Meta::CPAN
'and the card is gone' );
};
subtest 'handoff under a name that is not the holder' => sub {
my $rv = _run_karr( $repo, 'handoff', '5', '--claim', $OTHER,
'--note', 'back for another look' );
is( $rv->{exit}, 0, 'exit 0' ) or diag $rv->{stderr};
unlike( $rv->{stderr}, qr/is claimed by/, 'nothing refused' );
is( _field_of( $repo, 5, 'Status' ), 'review', 'and the card went back' );
};
subtest 'archived is terminal too, not just the done column' => sub {
is( _run_karr( $repo, 'move', '6', 'archived', '--claim', $HOLDER )->{exit},
0, 'the holder archives its own card' );
my $rv = _run_karr( $repo, 'edit', '6', '-a', 'a note on an archived card' );
is( $rv->{exit}, 0, 'exit 0' ) or diag $rv->{stderr};
unlike( $rv->{stderr}, qr/is claimed by/, 'nothing refused' );
};
};
subtest 'a card that is still being worked on is guarded exactly as before' => sub {
my $repo = _setup_repo( tasks => 5 );
is( _run_karr( $repo, 'move', '1,2,3,4,5', 'in-progress',
'--claim', $HOLDER )->{exit}, 0, 'the holder claims all five cards' );
my %refused = (
'edit' => [ 'edit', '1', '-a', 'not mine to write on' ],
'move' => [ 'move', '2', 'review' ],
'archive' => [ 'archive', '3' ],
'delete' => [ 'delete', '4', '--yes' ],
'handoff' => [ 'handoff', '5', '--claim', $OTHER ],
);
for my $cmd ( sort keys %refused ) {
my $rv = _run_karr( $repo, @{ $refused{$cmd} } );
isnt( $rv->{exit}, 0, "$cmd is still refused on a live claim" );
like( $rv->{stderr}, qr/is claimed by \Q$HOLDER\E/,
"...and still names the holder ($cmd)" );
}
is( _field_of( $repo, 1, 'Status' ), 'in-progress',
'and every card is still where its holder left it' );
is( _field_of( $repo, 4, 'Claimed' ), $HOLDER, 'claim intact' );
};
# ---------------------------------------------------------------------------
# The rule reads the board's terminal statuses, never a literal `done`.
# ---------------------------------------------------------------------------
{
package ClaimConsumer;
use Moo;
has store => ( is => 'ro' );
# The other two names the role requires. Nothing here prints: quiet keeps
# expired_claim_report's human copy out of the test output while its return
# value is still the pair the assertions read.
sub json { 0 }
sub quiet { 1 }
with 'App::karr::Role::ClaimTimeout';
}
sub _consumer_for {
my (@statuses) = @_;
my $ec = { %{ App::karr::Config->default_config } };
$ec->{statuses} = [@statuses] if @statuses;
return ClaimConsumer->new( store => MockStore->new( ec => $ec ) );
}
sub _claimed_card {
my ( $status, $secs_ago ) = @_;
return App::karr::Task->new(
id => 1,
title => 'Claimed card',
status => $status,
claimed_by => $HOLDER,
claimed_at => gmtime( time - ( $secs_ago // 5 ) )->datetime . 'Z',
);
}
# The answer plus the reason, so a check_claim that died of something other
# than the claim rule -- a missing method on the store, say -- cannot read as
# "the card was guarded".
sub _answer_of {
my ( $consumer, $status ) = @_;
# The refusal appends the caller's own command line with --claim HOLDER
# when App::karr::Error/original_argv was recorded (ticket #269). The CLI
# subtests above recorded theirs, and this direct call has none of its own
# -- so the recorded one is cleared, and the message is pinned as the
# no-argv shape: prose plus the release door, and nothing else.
set_original_argv();
my $ok = eval { $consumer->check_claim( _claimed_card($status), $OTHER ) };
return $ok ? 'allowed' : $@;
}
subtest 'terminal is whatever the board calls terminal' => sub {
my $default = _consumer_for();
is( _answer_of( $default, 'done' ), 'allowed',
'default board: a claim on `done` guards nothing' );
like( _answer_of( $default, 'review' ),
qr/\ATask 1 is claimed by \Q$HOLDER\E:\n karr edit 1 --release\n\z/,
'default board: a claim on `review` still guards the card' );
# A board imported from kanban-md may end anywhere; `done` is not even a
# column here, and `shipped` is the finished one (ticket #67/#98).
my $custom = _consumer_for(qw( backlog doing shipped archived ));
is( _answer_of( $custom, 'shipped' ), 'allowed',
'custom board: a claim on `shipped` guards nothing' );
is( _answer_of( $custom, 'archived' ), 'allowed',
'custom board: `archived` is terminal here too' );
like( _answer_of( $custom, 'doing' ),
qr/\ATask 1 is claimed by \Q$HOLDER\E:\n karr edit 1 --release\n\z/,
'custom board: a claim on `doing` still guards the card' );
# And the boards really do disagree: `done` is not a column on the custom
# board, so nothing there releases the claim for it.
like( _answer_of( $custom, 'done' ),
qr/\ATask 1 is claimed by \Q$HOLDER\E:\n karr edit 1 --release\n\z/,
'custom board: `done` is no longer the magic word' );
};
( run in 1.851 second using v1.01-cache-2.11-cpan-85d3896f969 )