The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl
#
# Test Email::Fingerprint::Cache.

use lib 'build_lib';

use strict;
use English;
use warnings;
use Email::Fingerprint;

use POSIX;
use FindBin;
use Test::More;
use Test::Exception;
use Test::Warn;

# Helper class for testing output
{
    package Test::Stdout;

    sub TIEHANDLE
    {
        my $class = shift;
        my $self = {};
        $self->{string} = shift;
        ${ $self->{string} } ||= '';

        return bless $self, $class;
    }

    sub PRINT
    {
        my $self = shift;
        ${ $self->{string} } .= join '', @_;
    }

    sub PRINTF
    {
        my $self = shift;
        my $format = shift;
        ${ $self->{string} } .= sprintf($format, @_);
    }
}

# Sentinel object to delete data files
package Sentinel;

sub DESTROY {
    my $self = shift;
    unlink $_ for glob $self->{file} . "*";
}

# Back to our show...
package main;

eval "use Email::Fingerprint::Cache";
if ($@)
{
    plan ( skip_all => "Failed to load Email::Fingerprint::Cache" );
}
else
{
    plan ( tests => 43 );
}

############################################################################
# First, run through the general cache functionality, without stressing
# anything or doing anything extra.
############################################################################

my $cache;
my %fingerprints;
my $file     = "t/data/tmp_cache";

lives_ok {
    $cache     =  new Email::Fingerprint::Cache({
        backend   => "AnyDBM",
        hash      => \%fingerprints,
        file      => $file,         # Created if doesn't exist
        ttl       => 60,            # Purge records after one minute
    });
} "Constructing a cache with reasonable values should succeed";

# Look up the TTL
ok $cache->can('get_ttl'), "The cache should support a get_ttl() method";
ok $cache->get_ttl == 60, "... and the TTL value should be 60 seconds";

# Arrange for cleanup on exit
my $sentinel = bless { file => $file }, "Sentinel";

# Create the file
ok $cache->can('open'), "The cache should support an open() method";
ok $cache->open, "... and opening the cache should succeed";

# Add a bunch of "fingerprints", all older than one minute
for my $n ( 1..100 ) {
    my $timestamp = time - 60 - int(rand(100_000_000));
    $fingerprints{$n} = $timestamp;
}

# Now confirm that they're there
ok scalar(keys %fingerprints) == 100, "(Successfully added 100 fingerprints to cache)";

# Double-check using the value returned by get_hash()
ok $cache->can('get_hash'), "The cache should support a get_hash() method";
ok $cache->get_hash, "... and getting the hash of fingerprints should succeed";
ok scalar(keys %{ $cache->get_hash }) == 100, "... and the hash should contain 100 fingerprints";

# ...and purge them.
$cache->purge;
ok scalar(keys %fingerprints) == 0, "Purged cache successfully";

# Verify that the hash is tied
ok tied %fingerprints ? 1 : 0, "Fingerprints tied";
lives_ok { $cache->close } "Closed cache without incident";
ok tied %fingerprints ? 0 : 1, "Fingerprints untied";
warning_is { undef $cache } undef, "Destroyed without warnings";


############################################################################
# Now, exercise the constructor more thoroughly
############################################################################

{
    # We define a package to suppress warnings we don't care about.
    package BOGUS;
    sub is_open {}
    sub unlock {}

    package main;

    # Simple constructor call, all defaults
    lives_ok { $cache = new Email::Fingerprint::Cache } "Default constructor";

    # Construction with an invalid backend should fail
    throws_ok { $cache = new Email::Fingerprint::Cache({ backend => 'BOGUS' }) }
        qr{Can't load},
        "Constructing an object with an invalid backend";
}

############################################################################
# Now exercise the file() method, which supports several cases for a
# generality of backends.
############################################################################

# Default backend, default filename
$cache = new Email::Fingerprint::Cache({ backend => undef });

# Backend with file() method
{
    package Backend1;
    sub new { my $scalar; return bless \$scalar, "Backend1"; }
    sub unlock {}
    sub is_open {0}

    package main;
    $cache = new Email::Fingerprint::Cache({ backend => "Backend1" });

    ok $cache, "Cache using locally defined class as backend";
}

# Backend with AUTOLOAD method supplying a filename
{
    package Backend2;
    sub new { my $scalar; return bless \$scalar, "Backend2"; }
    sub AUTOLOAD { return "foo" }
    sub unlock {}
    sub is_open {0}

    package main;
    $cache = new Email::Fingerprint::Cache({ backend => "Backend2" });

    ok $cache, "Another cache using locally defined class as backend";
}

# Constructor returns undef
{
    package Backend8;
    sub new  {}
    sub unlock {}
    sub is_open {0}

    package main;

    # Construction should fail
    throws_ok
        { $cache = new Email::Fingerprint::Cache({ backend => 'Backend8' }) }
        qr{Can't load},
        "Dies when constructor returns undef";
}

# Clean up a little
undef $cache;


############################################################################
# Exercise the lock() and unlock() methods
############################################################################
SKIP: {
    my $perl = $EXECUTABLE_NAME;
    my $lib  = "$FindBin::Bin/../lib";

    # We make a massive effort to make this test work on Windows,
    # even though fork() is completely broken there. We do skip this
    # part of the test if we simply can't launch Perl, though.
    my $status = system (
        $perl, '-I', $lib, qw/ -MPOSIX -MEmail::Fingerprint::Cache -e 0 /
    );
    if ($status != 0)
    {
        diag "Perl: $perl";
        diag "Lib: $lib";
        diag "\$0: $0";

        skip "can't run perl; your system looks broken", 3 unless $status == 0;
    }

    # Clean up the lockfile from any crashed test runs
    unlink "$file.lock";

    # Open two caches and make 'em fight.
    my $cache1 = new Email::Fingerprint::Cache({ file => $file });
    skip "failed to create cache for lock test", 3 unless $cache1;

    # Locking cache 1 should prevent locking the same cache in another process.
    # NOTE: It prevents locking cache2 in the *same* process on most UNIX
    # variants, except Solaris.
    ok $cache1->lock   ? 1 : 0,
        "Locking a cache should succeed when nobody else has a lock";

    # Now attempt a second lock, in a separate process, without forking.
    # Good luck with that!
    $status = system(
        $perl, '-I', $lib, qw/ -MPOSIX -MEmail::Fingerprint::Cache -e /,
        qq{
            \$cache = Email::Fingerprint::Cache->new({ file => '$file' });
            POSIX::_exit(0) if \$cache->lock;
            POSIX::_exit(1);
        },
    );

    ok +($status >> 8 == 1), "... and other processes should be unable to lock the locked cache";
    skip "failed to unlock test cache", 1 unless $cache1->unlock;

    $status = system(
        $perl, '-I', $lib, qw/ -MPOSIX -MEmail::Fingerprint::Cache -e /,
        qq{
            \$cache = Email::Fingerprint::Cache->new({ file => '$file' });
            POSIX::_exit(0) unless \$cache->lock;
            POSIX::_exit(0) unless \$cache->unlock;
            POSIX::_exit(1);
        },
    );

    ok +($status >> 8 == 1), "... until after the original process releases its lock";

    # Destroy the caches
    undef $cache1;
}

############################################################################
# Test the ugly failsafe in the DESTROY() method
############################################################################

$cache = new Email::Fingerprint::Cache({
    file => $file,
    hash => {},
});

# Open and lock the cache.
if (not $cache or not $cache->open or not $cache->lock)
{
    # Suppress spurious warnings
    $cache->close;
    $cache->unlock;
    undef $cache;

    ok 0, "Create an open, locked cache for warning test";
}
else
{
    warning_like
        { undef $cache }
        { carped => qr/before it was close/ },
        "Destroying an open, locked cache should generate a warning";
}

# Create another one
$cache = new Email::Fingerprint::Cache({
    file => $file,
    hash => {},
});

# This time, we open but don't lock the cache.
if (not $cache or not $cache->open)
{
    $cache->close;
    undef $cache;

    ok 0, "Create an open, unlocked cache for warning test";
}
else
{
    warning_like
        { undef $cache }
        { carped => qr/before it was close/},
        "Destroying an open, unlocked cache should generate a warning";
}

# Create another one
$cache = new Email::Fingerprint::Cache({
    file => $file,
    hash => {},
});

# This time, we lock but don't open the cache.
if (not $cache or not $cache->lock)
{
    $cache->close;
    undef $cache;

    ok 0, "Create a locked but unopened cache for warning test";
}
else
{
    warning_is { undef $cache } undef,
        "Destroying a locked but unopened cache should not generate warnings";
}



############################################################################
# Exercise purge() more thoroughly
############################################################################

SKIP: {
    %fingerprints = ();

    $cache = new Email::Fingerprint::Cache({
        file => $file,
        hash => \%fingerprints,
        ttl  => 60,
    });

    # Open the cache.
    ok $cache,       "New cache for TTL test";
    ok $cache->open, "Opened cache";
    ok scalar(keys %fingerprints) == 0, "Cache initially empty";

    # Populate the cache with 500 items each: less than 60 seconds old;
    # between 60 and 119 seconds old; older than 120 seconds.
    for my $n ( 1..500 ) {
        my $timestamp = time;
        my $key       = sprintf "%03i", $n;

        $fingerprints{"a$key"} = $timestamp - int(rand(58)) -   1;  # Less than 1 minute
        $fingerprints{"b$key"} = $timestamp - int(rand(58)) -  61;  # Less than 2 minutes
        $fingerprints{"c$key"} = $timestamp - int(rand(59)) - 121;  # More than 2 minutes
    }

    # Add a fingerprint with no defined timestamp, or a timestamp
    # that evaluates to false.
    $fingerprints{101} = undef;
    $fingerprints{102} = 0;
    $fingerprints{103} = '';

    # And finally, add one entry that WON'T be purged.
    $fingerprints{104} = 60 + time; # Just in case the test runs slow...

    # Now confirm that they're there
    my $count = scalar(keys %fingerprints);
    ok $count == 1504, "The cache should contain 1,504 fingerprints, and contains: $count";

    # First, purge the invalid timestamps
    $cache->purge( ttl => 200 );
    $count = scalar(keys %fingerprints);
    ok $count == 1501, "...   3 with invalid timestamps, which should now be purged ($count remaining)";

    # Next, purge items older than 2 minutes, and check. The "false"
    # fingerprints should also be gone.
    $cache->purge( ttl => 120 );
    $count = scalar(keys %fingerprints);
    ok $count == 1001, "... 500 older than two minutes, which should now be purged ($count remaining)";

    # Then purge using the default TTL, which we set earlier to 60.
    # Confirm that the default TTL is used and not, e.g., 120.
    $cache->purge;
    $count = scalar(keys %fingerprints);
    ok $count == 501, "... 500 older than one minute, which should now be purged ($count remaining)";

    # Purge all entries older than this second
    $cache->purge( ttl => 0 );
    $count = scalar(keys %fingerprints);
    ok scalar(keys %fingerprints) == 1, "... 500 older than one second, which should now be purged ($count remaining)";

    # Purge using a TTL of -1, which should remove everything
    $cache->purge( ttl => -1 );
    $count = scalar(keys %fingerprints);
    ok $count == 0, "...   1 younger than one second, which should now be purged ($count remaining)";

    # Clean up
    $cache->close;
    undef $cache;
}


############################################################################
# Test the dump() method, which prints to STDOUT
############################################################################

$file = 't/data/cache';
my %hash;
our $data;

# Read the data, stored in Perl format: loads hashref $data
require "$file.pl";

$cache = new Email::Fingerprint::Cache({
    file => $file,
    hash => \%hash,
});

# Open the cache file
ok $cache->open, "Opening the test cache should succeed";

# Purge the cache, in case there's leftover test data around
$cache->purge( ttl => -1 );

# Add our data to the hash
$hash{$_} = $data->{$_} for keys %$data;

# Close and reopen
ok $cache->close, "... and closing it should also be successful";
ok $cache->open, "... as should reopening it";

my $output;

# Dump the cache, catching the output
tie *STDOUT, 'Test::Stdout', \$output;
$cache->dump;
untie *STDOUT;

# Read the test data
open IN, '<', "$file.txt";
my $standard = join '', <IN>;
close IN;

# Compare
ok $output eq $standard, "... and the contents should match our test data";

# Clean up
lives_ok { $cache->close } "Closing the cache should not throw an exception";
warning_is { undef $cache } undef, "Destroying the closed cache should not generate warnings";

############################################################################
# Test the set_file method, which only works when no file is open.
############################################################################

# Get a fresh cache
$cache = new Email::Fingerprint::Cache({
    file => $file,
    hash => {},
});

# Nothing should happen, either, if the file is locked
$cache->lock;
ok !defined $cache->set_file('foo'), "Setting a new cache file should fail when the cache is locked";
$cache->unlock;

# Nothing should happen, either, if the file is locked
$cache->open;
ok !defined $cache->set_file('foo'), "... or when the file is open";
$cache->close;

# Finally, the file is closed and unlocked, so it should work
ok $cache->set_file('foo'), "... but it should be successful if the cache is closed and unlocked";

# Clean up
unlink "t/data/cache.db";

# That's all, folks!
done_testing();