App-karr
view release on metacpan or search on metacpan
t/232-claim-timeout-zero-never-expires.t view on Meta::CPAN
# t/232-claim-timeout-zero-never-expires.t
#
# Ticket #232. `claim_timeout: 0s` is documented as "claims never expire" --
# in App::karr::Role::ClaimTimeout/claim_timeout_secs, in the comment over
# _parse_timeout, and by the implementation it is parity with (kanban-md's
# internal/board/filter.go, whose IsUnclaimed asks `timeout > 0 && ClaimedAt !=
# nil` and falls through to "still claimed" on a zero timeout).
#
# It did the exact opposite. _parse_timeout handed the zero through verbatim,
# which is right, and _claim_expired then asked `(now - claimed_at) > 0` -- true
# of every claim more than a second old. So the one setting a board uses to say
# "claims are binding here" was the setting that made every card free for the
# taking, seconds after it was claimed, with nothing said anywhere:
#
# karr config set claim_timeout 0s
# karr move 1 in-progress --claim agent-a
# karr pick --claim agent-b
# -> Picked task 1: A (claimed by agent-b)
#
# Four things are pinned here, and the last three matter as much as the first:
#
# * zero means never: pick, edit and move all leave a claimed card alone, and
# expired_claim_report has nothing to report because nothing expired;
# * a real timeout still expires, and taking the claim over is still
# announced (#177) -- the fix must not be "no claim ever expires again";
# * a negative or unparseable claim_timeout still falls back to one hour
# rather than reaching the new guard and reading as "never";
# * the same zero on the lock side -- App::karr::Lock/expired, reached from
# `lock_timeout: 0s` -- answers the same way. Those are two separate
# guards that have to agree, and drifting apart is what produced this
# ticket: the lock side read its zero correctly for as long as the claim
# side read it backwards, because they only look alike. Both answers are
# asserted in this one file so losing either of them goes red here.
#
# Never the developer's own board: every repository below is a tempdir.
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 MockStore;
use File::Temp qw( tempdir );
use Time::Piece;
use App::karr::Config;
use App::karr::Git;
use App::karr::Lock;
use App::karr::Task;
my $HOLDER = 'agent-holder';
my $TAKEOVER = 'agent-takeover';
{
package ZeroTimeoutConsumer;
use Moo;
# The three names the role requires. store is the real question here: unlike
# t/72's stub, check_claim below has to read a board's claim_timeout back out
# of it, so this one is a MockStore carrying the value under test.
has store => ( is => 'ro' );
sub json { 0 }
sub quiet { 0 }
with 'App::karr::Role::ClaimTimeout';
}
sub _consumer_for {
my ($timeout) = @_;
my $ec = App::karr::Config->default_config;
$ec->{claim_timeout} = $timeout;
return ZeroTimeoutConsumer->new( store => MockStore->new( ec => $ec ) );
}
sub _claimed_secs_ago {
my ($secs) = @_;
return App::karr::Task->new(
id => 1,
title => 'Held card',
status => 'in-progress',
claimed_by => $HOLDER,
claimed_at => gmtime( time - $secs )->datetime . 'Z',
);
}
subtest 'the comparison itself: zero is not a very short window' => sub {
my $c = _consumer_for('1h');
my $old = _claimed_secs_ago(7200);
# The counter-probe first, so "nothing ever expires" cannot pass this file:
# a two-hour-old claim under the default one-hour window is expired, and
# every assertion below is the same task judged against a different number.
ok( $c->_claim_expired( $old, 3600 ),
'a two-hour-old claim has expired under a 1h timeout' );
ok( !$c->_claim_expired( $old, 0 ),
'the same claim has NOT expired under a timeout of 0 -- zero disables expiry' );
ok( !$c->_claim_expired( _claimed_secs_ago(86400 * 30), 0 ),
'and neither has one a month old: zero is never, not sooner' );
# Answered like zero rather than like a duration, exactly as
# App::karr::Lock/expired answers a negative ttl. No board can produce this
# (see the _parse_timeout subtest), but "not a positive window" is the whole
# question the guard asks.
ok( !$c->_claim_expired( $old, -1 ),
'a negative timeout disables expiry too' );
ok( !$c->_claim_expired( _claimed_secs_ago(60), 3600 ),
'a fresh claim under a real window is still live' );
};
subtest 'the parse: which values ever reach that comparison' => sub {
my $c = _consumer_for('1h');
is( $c->_parse_timeout('0s'), 0,
'0s is honoured verbatim -- it must reach the comparison as a zero' );
is( $c->claim_timeout_secs, 3600, 'a board saying 1h reads back as 3600' );
is( _consumer_for('0s')->claim_timeout_secs, 0,
'a board saying 0s reads back as 0, not as the fallback' );
# The fallback still catches everything that is not a duration, so the new
# guard is never reached by an accident. A negative one especially: it parses
# and would otherwise be read as "claims never expire" on a board that meant
# nothing of the sort.
is( $c->_parse_timeout('-5m'), 3600, 'a negative duration falls back to 1h' );
is( _consumer_for('-5m')->claim_timeout_secs, 3600,
'...on the board level too, so it never lands on the zero guard' );
is( $c->_parse_timeout('7d'), 3600, 'an unsupported unit falls back to 1h' );
is( $c->_parse_timeout('later'), 3600, 'a non-duration falls back to 1h' );
is( $c->_parse_timeout('0'), 3600,
'a bare 0 with no unit keeps its historical fallback, unchanged by this fix' );
is( $c->_parse_timeout(undef), 3600, 'an absent value falls back to 1h' );
( run in 1.148 second using v1.01-cache-2.11-cpan-85d3896f969 )