DateTime-Format-Genealogy

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

#!perl

# Destructive, pathological, boundary-condition, and security tests for
# DateTime::Format::Genealogy.
#
# Strategy: every subtest here deliberately tries to break or subvert the
# module.  Inputs that well-behaved callers would never supply are fed in;
# upstream collaborators are replaced with mocks that return failure modes
# (undef, 0, "", exceptions, malformed data).  Global-variable clobbering,
# list/scalar context abuse, and security-relevant strings are all covered.
#
# Calling convention reminder: Params::Get does NOT support mixing a
# positional scalar with named key-value flags:
#   WRONG: parse_datetime('date string', quiet => 1)
#   RIGHT: parse_datetime(date => 'date string', quiet => 1)
#   RIGHT: parse_datetime({ date => 'date string', quiet => 1 })

use strict;
use warnings;

use Test::Most;
use Test::Mockingbird;
use Test::Returns;
use Readonly;
use Scalar::Util qw(blessed weaken);
use Time::HiRes qw(gettimeofday tv_interval);

BEGIN { use_ok('DateTime::Format::Genealogy') || BAIL_OUT('Cannot load module') }

Readonly my $PKG => 'DateTime::Format::Genealogy';

# ---------------------------------------------------------------------------
# Calendar back-end stubs (succeed by default; overridden in specific tests).
# ---------------------------------------------------------------------------
{
	package DateTime::Calendar::Hebrew;
	sub new {
		my ($class, %a) = @_;
		return bless { %a }, $class;
	}
}
$INC{'DateTime/Calendar/Hebrew.pm'} = 1;

{
	package DateTime::Calendar::FrenchRevolutionary;
	sub new {
		my ($class, %a) = @_;
		return bless { %a }, $class;
	}
}
$INC{'DateTime/Calendar/FrenchRevolutionary.pm'} = 1;

# Standard "safe" mock for DateTime::from_object used by calendar tests
my $FROM_OBJECT_SENTINEL = DateTime->new(year => 2022, month => 10, day => 9);

# ===========================================================================
# SECTION 1: Hostile inputs to new()
# ===========================================================================

subtest 'new() - hostile constructor arguments' => sub {
	# Verify the constructor does not die on zero-ish flag values.
	for my $val (0, '', undef) {
		my $label = defined $val ? "'$val'" : 'undef';
		my $obj;
		lives_ok(
			sub { $obj = $PKG->new(quiet => $val, strict => $val) },
			"new(quiet => $label, strict => $label) does not throw",
		);
		isa_ok($obj, $PKG, "new() with $label flags returns blessed object");
	}

	# Extra unknown keys passed to new() should not crash (Object::Configure
	# absorbs them; they are simply stored on the object).
	my $obj_extra;
	lives_ok(sub { $obj_extra = $PKG->new(unknown_key => 99) },
		'new() with unknown key does not throw');
	isa_ok($obj_extra, $PKG, 'still returns a blessed object');

	# Circular reference as constructor argument: must not crash.
	my $circ = {};
	$circ->{self} = $circ;
	weaken($circ->{self});
	lives_ok(sub { $PKG->new(quiet => 0) },
		'new() survives when caller holds a circular reference');

	diag('Constructor hostile-input section completed') if $ENV{TEST_VERBOSE};
};

subtest 'new() - invocant is undef (bare function call form)' => sub {
	# Calling DateTime::Format::Genealogy::new() with no invocant should
	# default to the package name and still construct a valid object.
	my $obj;
	lives_ok(sub { $obj = DateTime::Format::Genealogy::new() },
		'bare function new() does not throw');
	isa_ok($obj, $PKG, 'bare new() returns a blessed reference');
};

# ===========================================================================
# SECTION 2: Hostile date values — parse_datetime croak conditions
# ===========================================================================

subtest 'parse_datetime - all falsy date values croak' => sub {
	my $obj = $PKG->new();

	Readonly my %FALSY => (
		'undef'         => undef,
		'empty string'  => '',
		'string zero'   => '0',
		'integer zero'  => 0,
	);

	while (my ($label, $val) = each %FALSY) {
		throws_ok(

t/edge_cases.t  view on Meta::CPAN


subtest 'parse_datetime - 3-digit year DFN failure emits carp (not silent)' => sub {
	my $obj = $PKG->new();

	# With quiet off, the DFN error carp must be audible for sub-1000 dates.
	warning_like(
		sub { $obj->parse_datetime('20 Dec 100') },
		qr/does not parse/,
		'3-digit year triggers DFN error carp when quiet is off',
	);

	# With quiet on, the carp must be suppressed.
	warnings_are(
		sub { $obj->parse_datetime(date => '20 Dec 100', quiet => 1) },
		[],
		'3-digit year carp suppressed by quiet => 1',
	);
};

# ===========================================================================
# SECTION 5: Calendar month boundary conditions
# ===========================================================================

subtest 'parse_datetime - February 29 in leap vs non-leap year' => sub {
	my $obj = $PKG->new(quiet => 1);

	# 2000 is a leap year; 29 Feb 2000 is valid.
	my $leap = $obj->parse_datetime('29 Feb 2000');
	isa_ok($leap, 'DateTime', '29 Feb 2000 (leap year) parses to DateTime');
	is($leap->dmy, '29-02-2000', 'leap day correct') if defined $leap;

	# 1900 is not a leap year (century year not divisible by 400).
	# DFN raises an exception; the module must not propagate it.
	my $nonleap;
	lives_ok(sub { $nonleap = $obj->parse_datetime('29 Feb 1900') },
		'29 Feb 1900 (non-leap) does not throw');
	diag('29 Feb 1900 result: ' . (defined $nonleap ? $nonleap->dmy : 'undef'))
		if $ENV{TEST_VERBOSE};
};

subtest 'parse_datetime - 31 November is always rejected' => sub {
	my $obj = $PKG->new();

	# The 31-Nov carp is deliberately not gated on quiet — it fires always.
	warning_like(
		sub { $obj->parse_datetime('31 Nov 2022') },
		qr/31 Nov 2022 is invalid.*30 days in November/,
		'31 Nov triggers mandatory carp',
	);
	warning_like(
		sub { $obj->parse_datetime(date => '31 Nov 2022', quiet => 1) },
		qr/31 Nov.*invalid/,
		'31 Nov carp fires even with quiet => 1',
	);

	ok(!defined $obj->parse_datetime('31 Nov 2022'),
		'31 Nov returns undef');
};

# ===========================================================================
# SECTION 6: GEDCOM calendar escape edge cases
# ===========================================================================

subtest 'parse_datetime - GEDCOM escape with no date after it' => sub {
	my $obj = $PKG->new(quiet => 1);

	# Escape with nothing following: the remaining string is empty or
	# whitespace, which fails all downstream checks and returns undef.
	ok(!defined $obj->parse_datetime('@#DJULIAN@'),
		'@#DJULIAN@ with no date returns undef');
	ok(!defined $obj->parse_datetime('@#DJULIAN@ '),
		'@#DJULIAN@ + whitespace returns undef');
};

subtest 'parse_datetime - unknown GEDCOM calendar type is tolerated' => sub {
	my $obj = $PKG->new();

	# An unknown calendar escape (DROMAN, DGREEK, etc.) must carp and return
	# the Gregorian-interpreted DateTime rather than crashing.
	my $result;
	warning_like(
		sub { $result = $obj->parse_datetime('@#DROMAN@ 25 Dec 2022') },
		qr/Calendar type DROMAN not supported/,
		'@#DROMAN@: calendar-not-supported carp emitted',
	);
	isa_ok($result, 'DateTime',
		'@#DROMAN@: still returns the Gregorian-interpreted DateTime');

	# Very long unknown calendar type must not crash the regex.
	my $long_type = 'A' x 500;
	my $long_result;
	lives_ok(
		sub { $long_result = $obj->parse_datetime("\@#D${long_type}\@ 25 Dec 2022") },
		'Extremely long calendar type does not crash',
	);
	diag("Long calendar type result: " . (defined $long_result ? $long_result->dmy : 'undef'))
		if $ENV{TEST_VERBOSE};
};

subtest 'parse_datetime - lowercase GEDCOM escape is not recognised' => sub {
	# The regex @#D([A-Z ]+?)@ requires uppercase letters; lowercase should
	# not be treated as a calendar escape and falls through to normal parsing.
	my $obj = $PKG->new(quiet => 1);

	my $result = $obj->parse_datetime('@#djulian@ 25 Dec 2022');
	ok(!defined $result,
		'Lowercase GEDCOM escape not recognised: date cannot be parsed normally');
};

subtest 'parse_datetime - GEDCOM Julian offset for each century tier' => sub {
	# Verify all four offset tiers (<1700=>10, <1800=>11, <1900=>12, >=1900=>13)
	# are applied correctly end-to-end.
	my $obj = $PKG->new();

	Readonly my @TIER_CASES => (
		# [ julian_date,      expected_day, expected_offset ]
		['@#DJULIAN@ 1 Mar 1620',  11, 10],
		['@#DJULIAN@ 1 Mar 1750',  12, 11],
		['@#DJULIAN@ 1 Mar 1850',  13, 12],
		['@#DJULIAN@ 1 Mar 1920',  14, 13],
	);

	for my $case (@TIER_CASES) {
		my ($input, $expected_day, $offset) = @{$case};
		my $dt = $obj->parse_datetime($input);
		isa_ok($dt, 'DateTime', "$input parses to DateTime");
		is($dt->day, $expected_day, "$input: day advanced by $offset") if defined $dt;
	}
};

# ===========================================================================
# SECTION 7: Date range edge cases and list/scalar context abuse
# ===========================================================================

subtest 'parse_datetime - range in scalar context always returns undef' => sub {
	my $obj = $PKG->new();

	# These must NOT return a DateTime or throw; scalar context must give undef.
	Readonly my @RANGE_STRINGS => (
		'bet 1 Jan 2000 and 31 Dec 2000',
		'from 1 Jan 2000 to 31 Dec 2000',
		'1 Jan 2000 - 31 Dec 2000',
	);

	for my $range (@RANGE_STRINGS) {
		my $scalar = $obj->parse_datetime($range);
		ok(!defined $scalar, "'$range' in scalar context returns undef");
	}
};

subtest 'parse_datetime - range with invalid first endpoint' => sub {
	# When one endpoint of a bet...and... range is itself invalid (31 Nov),
	# the recursive parse_datetime call returns the empty list.  In list
	# context that causes the outer return to have fewer than 2 elements.
	# This is a known limitation: callers must check scalar(@result) == 2.
	my $obj = $PKG->new(quiet => 1);

	my @range;
	lives_ok(
		sub { @range = $obj->parse_datetime('bet 31 Nov 2000 and 31 Dec 2000') },
		'bet range with invalid first endpoint does not crash',
	);

t/edge_cases.t  view on Meta::CPAN

	my $r;
	lives_ok(sub { $r = $obj->parse_datetime('25 Dec 2022') },
		'undef canonical field: parse_datetime does not crash');
	ok(!defined $r, 'undef canonical field: parse_datetime returns undef');

	restore_all();
};

subtest 'parse_datetime - DateTime::Format::Natural->parse_datetime throws' => sub {
	# If DFN itself throws, parse_datetime must not propagate the exception
	# because the caller cannot anticipate it.
	mock 'DateTime::Format::Natural::parse_datetime' => sub {
		die "Simulated DFN hard error\n";
	};

	my $obj = $PKG->new(quiet => 1);
	# We allow the exception to surface here because the module does not
	# wrap the DFN call in an eval; this test documents the current contract.
	my $r = eval { $obj->parse_datetime('25 Dec 2022') };
	diag("DFN throw result: " . ($@ ? "propagated: $@" : "caught, got " . (defined $r ? $r->dmy : 'undef')))
		if $ENV{TEST_VERBOSE};
	# Whether it propagates or is caught is acceptable; it must not corrupt state.
	pass('DFN throwing: no Perl VM crash');

	restore_all();
};

subtest 'parse_datetime - DFN success() returns false for parseable-looking date' => sub {
	# Simulate DFN returning a DateTime but reporting failure via success().
	# parse_datetime must trust success() and return undef (not the bad DateTime).
	my $sentinel = DateTime->new(year => 1999, month => 1, day => 1);

	mock 'DateTime::Format::Natural::parse_datetime' => sub { return $sentinel };
	mock 'DateTime::Format::Natural::success'        => sub { return 0 };
	mock 'DateTime::Format::Natural::error'          => sub { return "'25 Dec 2022' does not parse" };

	my $obj = $PKG->new(quiet => 1);
	my $r = $obj->parse_datetime('25 Dec 2022');

	ok(!defined $r,
		'parse_datetime returns undef when DFN success() is false (GGD-cached path)');

	restore_all();

	diag("DFN success=false result: " . (defined $r ? $r->dmy : 'undef'))
		if $ENV{TEST_VERBOSE};
};

subtest 'parse_datetime - DFN returns undef from parse_datetime' => sub {
	# Some DFN versions return undef rather than a DateTime-with-success=false.
	mock 'DateTime::Format::Natural::parse_datetime' => sub { return undef };

	my $obj = $PKG->new(quiet => 1);
	my $r;
	lives_ok(sub { $r = $obj->parse_datetime('25 Dec 2022') },
		'DFN returning undef does not crash parse_datetime');

	restore_all();
};

subtest 'parse_datetime - DateTime::from_object throws during calendar conversion' => sub {
	# If DateTime->from_object throws, _convert_calendar must carp and return
	# undef (not propagate the exception).
	no warnings 'redefine';
	local *DateTime::from_object = sub { die "from_object hard failure\n" };

	my $obj = $PKG->new();
	my $result;
	warning_like(
		sub { $result = $obj->parse_datetime('@#DHEBREW@ 9 Oct 2022') },
		qr/Hebrew calendar conversion failed/,
		'from_object throwing: conversion-failed carp emitted',
	);
	ok(!defined $result, 'from_object throwing: parse_datetime returns undef');
};

# ===========================================================================
# SECTION 10: Global variable integrity under hostile conditions
# ===========================================================================

subtest 'global variables are not clobbered by hostile inputs' => sub {
	my $obj = $PKG->new(quiet => 1);

	local $_  = 'sentinel_dollar_underscore';
	local $,  = 'sentinel_list_separator';
	local $\  = '';                            # output separator must stay empty
	local $/  = "\n";                          # input separator must stay \n

	Readonly my @HOSTILE => (
		"xyzzy",
		"\0",
		"A" x 5_000,
		"bet 1 Jan 2000 and 31 Dec 2000",
		"20 Dec 100",
		"31 Nov 2022",
	);

	for my $date (@HOSTILE) {
		eval { $obj->parse_datetime($date) };
		is($_, 'sentinel_dollar_underscore', "\$_ intact after '${\substr($date,0,20)}'");
		is($,, 'sentinel_list_separator',    "\$, intact after '${\substr($date,0,20)}'");
	}

	# $@ must not be set to an error after a quiet successful-or-rejected parse.
	eval { 1 };
	my $pre_at = $@;
	$obj->parse_datetime('25 Dec 2022');
	is($@, $pre_at, '$@ not clobbered after successful parse');
};

subtest 'alarm() is not called by the module' => sub {
	# alarm() is a POSIX feature not implemented on Windows.  Skip there.
	if($^O eq 'MSWin32') {
		plan skip_all => 'alarm() not supported on Windows';
		return;
	}

	# A paranoid check: the module must not call alarm() and disrupt any
	# countdown set by the caller.
	my $remaining = alarm(3600);              # set a 1-hour countdown
	my $obj = $PKG->new(quiet => 1);
	$obj->parse_datetime('25 Dec 2022');
	my $after = alarm($remaining);            # restore and read back

	cmp_ok($after, '>=', 3599,
		'alarm countdown was not cleared or shortened by parse_datetime');

	alarm($remaining);                        # restore original countdown
};

# ===========================================================================



( run in 0.477 second using v1.01-cache-2.11-cpan-364913b4093 )