Compare commits
8 Commits
c398ff843d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d59b9cf837 | |||
| 77a45cc58e | |||
| 17eb69fed0 | |||
| 7aa400b936 | |||
| 1a82fbac12 | |||
| 285d25223e | |||
| d88e35b965 | |||
| b0aa64053b |
@@ -20,48 +20,6 @@ sub get {
|
||||
return $promise;
|
||||
}
|
||||
|
||||
sub set {
|
||||
my ( $self, $key, $value ) = @_;
|
||||
my $promise = Mojo::Promise->new;
|
||||
$self->redis->set(
|
||||
$key => $value,
|
||||
sub {
|
||||
my ( $redis, $err, $result ) = @_;
|
||||
$err ? $promise->reject($err) : $promise->resolve($result);
|
||||
}
|
||||
);
|
||||
return $promise;
|
||||
}
|
||||
|
||||
sub incr {
|
||||
my ( $self, $key ) = @_;
|
||||
my $promise = Mojo::Promise->new;
|
||||
$self->redis->incr(
|
||||
$key => sub {
|
||||
my ( $redis, $err, $value ) = @_;
|
||||
$err ? $promise->reject($err) : $promise->resolve($value);
|
||||
}
|
||||
);
|
||||
return $promise;
|
||||
}
|
||||
|
||||
sub exists {
|
||||
my ( $self, $key ) = @_;
|
||||
my $promise = Mojo::Promise->new;
|
||||
$self->redis->exists(
|
||||
$key => sub {
|
||||
my ( $redis, $err, $exists ) = @_;
|
||||
if ($err) {
|
||||
$promise->reject($err);
|
||||
}
|
||||
else {
|
||||
$promise->resolve( $exists ? 1 : 0 );
|
||||
}
|
||||
}
|
||||
);
|
||||
return $promise;
|
||||
}
|
||||
|
||||
sub setnx {
|
||||
my ( $self, $key, $value ) = @_;
|
||||
my $promise = Mojo::Promise->new;
|
||||
@@ -81,7 +39,7 @@ sub setnx {
|
||||
}
|
||||
|
||||
sub ping {
|
||||
my $self = shift;
|
||||
my $self = shift;
|
||||
my $promise = Mojo::Promise->new;
|
||||
$self->redis->ping(
|
||||
sub {
|
||||
|
||||
@@ -4,6 +4,7 @@ use strict;
|
||||
use warnings;
|
||||
use Exporter 'import';
|
||||
use Mojo::URL;
|
||||
use Mojo::Path;
|
||||
use Mojo::Util qw(url_unescape decode);
|
||||
|
||||
our @EXPORT_OK = qw(
|
||||
@@ -78,7 +79,7 @@ sub sanitize_url {
|
||||
if ( $url =~ /%[0-9a-f]{2}/i ) {
|
||||
my $path = url_unescape( $parsed->path->to_string );
|
||||
$path = decode( 'UTF-8', $path ) if length $path;
|
||||
$parsed->path($path);
|
||||
$parsed->path( Mojo::Path->new($path) );
|
||||
|
||||
my $query = $parsed->query->to_string;
|
||||
if ( length $query ) {
|
||||
|
||||
@@ -439,35 +439,30 @@ sub check_url_reachable_async {
|
||||
|
||||
sub check_ssl_certificate {
|
||||
my ( $self, $url ) = @_;
|
||||
|
||||
return Mojo::Promise->reject('URL is required')
|
||||
unless defined $url && length($url) > 0;
|
||||
return Mojo::Promise->resolve(1) unless defined $url && length $url;
|
||||
|
||||
my $parsed = $self->_parse_url($url);
|
||||
return Mojo::Promise->resolve(1)
|
||||
unless $parsed && $parsed->scheme && $parsed->scheme eq 'https';
|
||||
|
||||
return $self->ua->head_p($url)->then( sub { return 1; } )->catch(
|
||||
sub {
|
||||
my $err = shift;
|
||||
my $err_str = "$err";
|
||||
my $error_type = $self->_classify_error($err_str);
|
||||
$self->_fire_and_forget(
|
||||
$self->ua->head_p($url)->then( sub { return 1; } )->catch(
|
||||
sub {
|
||||
my $err = shift;
|
||||
my $err_str = "$err";
|
||||
my $error_type = $self->_classify_error($err_str);
|
||||
|
||||
if ( $error_type eq 'ssl' ) {
|
||||
return Mojo::Promise->reject(
|
||||
"Invalid SSL certificate: $err_str");
|
||||
}
|
||||
|
||||
if ( $error_type eq 'ssl' ) {
|
||||
return Mojo::Promise->reject(
|
||||
"Invalid SSL certificate: $err_str");
|
||||
$self->_format_error_message( $error_type, $err_str ) );
|
||||
}
|
||||
|
||||
return Mojo::Promise->reject(
|
||||
$self->_format_error_message( $error_type, $err_str ) );
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
sub check_ssl_certificate_async {
|
||||
my ( $self, $url ) = @_;
|
||||
return Mojo::Promise->resolve(1) unless defined $url && length $url;
|
||||
$self->_fire_and_forget( $self->check_ssl_certificate($url) );
|
||||
return Mojo::Promise->resolve(1);
|
||||
}
|
||||
|
||||
@@ -507,7 +502,7 @@ sub validate_url_with_checks {
|
||||
|
||||
my $ssl_check =
|
||||
$parsed->scheme eq 'https'
|
||||
? $self->check_ssl_certificate_async($normalized)
|
||||
? $self->check_ssl_certificate($normalized)
|
||||
: Mojo::Promise->resolve(1);
|
||||
|
||||
return $ssl_check->then(
|
||||
|
||||
30
t/03_db.t
30
t/03_db.t
@@ -61,36 +61,6 @@ subtest 'get' => sub {
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'set' => sub {
|
||||
test_method(
|
||||
'set',
|
||||
'set',
|
||||
[ 'test_key', 'test_value' ],
|
||||
[ [ 'OK', undef, 'OK', 'returns success result' ] ],
|
||||
[ [ 'Write error', 'error is returned' ] ]
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'incr' => sub {
|
||||
test_method(
|
||||
'incr', 'incr', ['test_key'],
|
||||
[ [ 42, undef, 42, 'returns correct value' ] ],
|
||||
[ [ 'Increment error', 'error is returned' ] ]
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'exists' => sub {
|
||||
test_method(
|
||||
'exists', 'exists',
|
||||
['test_key'],
|
||||
[
|
||||
[ 1, undef, 1, 'returns 1 when key exists' ],
|
||||
[ 0, undef, 0, 'returns 0 when key does not exist' ],
|
||||
],
|
||||
[ [ 'Exists error', 'error is returned' ] ]
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'setnx' => sub {
|
||||
test_method(
|
||||
'setnx', 'setnx',
|
||||
|
||||
@@ -151,6 +151,38 @@ subtest '_format_error_message' => sub {
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'classify and format error string' => sub {
|
||||
my @cases = (
|
||||
[
|
||||
'SSL certificate verification failed',
|
||||
'SSL certificate error: SSL certificate verification failed',
|
||||
'ssl error classified and formatted'
|
||||
],
|
||||
[
|
||||
'Name or service not known',
|
||||
'DNS resolution failed: Name or service not known',
|
||||
'dns error classified and formatted'
|
||||
],
|
||||
[
|
||||
'Connection refused',
|
||||
'Cannot reach URL: Connection refused',
|
||||
'connection error classified and formatted'
|
||||
],
|
||||
[
|
||||
'Some unknown error',
|
||||
'URL validation failed: Some unknown error',
|
||||
'unknown error classified and formatted'
|
||||
],
|
||||
);
|
||||
|
||||
for my $case (@cases) {
|
||||
my ( $err, $expected, $label ) = @$case;
|
||||
my $type = $validator->_classify_error($err);
|
||||
is( $validator->_format_error_message( $type, $err ),
|
||||
$expected, $label );
|
||||
}
|
||||
};
|
||||
|
||||
subtest '_is_valid_ipv4' => sub {
|
||||
my @valid = (
|
||||
[ '192.168.1.1', 'valid IPv4 passes' ],
|
||||
@@ -542,8 +574,8 @@ subtest 'check_ssl_certificate - SSL error' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate('https://example.com') );
|
||||
|
||||
is( $result, undef, 'SSL error has no result' );
|
||||
like( $error, qr/Invalid SSL certificate/, 'SSL error is detected' );
|
||||
is( $result, 1, 'SSL error is async' );
|
||||
is( $error, undef, 'SSL error has no error' );
|
||||
};
|
||||
|
||||
subtest 'check_ssl_certificate - non-SSL error' => sub {
|
||||
@@ -551,8 +583,8 @@ subtest 'check_ssl_certificate - non-SSL error' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate('https://example.com') );
|
||||
|
||||
is( $result, undef, 'non-SSL error has no result' );
|
||||
like( $error, qr/Cannot reach URL/, 'non-SSL error is classified' );
|
||||
is( $result, 1, 'non-SSL error is async' );
|
||||
is( $error, undef, 'non-SSL error has no error' );
|
||||
};
|
||||
|
||||
subtest 'check_ssl_certificate - DNS error' => sub {
|
||||
@@ -560,8 +592,8 @@ subtest 'check_ssl_certificate - DNS error' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate('https://example.com') );
|
||||
|
||||
is( $result, undef, 'DNS error has no result' );
|
||||
like( $error, qr/DNS resolution failed/, 'DNS error is classified' );
|
||||
is( $result, 1, 'DNS error is async' );
|
||||
is( $error, undef, 'DNS error has no error' );
|
||||
};
|
||||
|
||||
subtest 'check_ssl_certificate - unknown error' => sub {
|
||||
@@ -569,24 +601,24 @@ subtest 'check_ssl_certificate - unknown error' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate('https://example.com') );
|
||||
|
||||
is( $result, undef, 'unknown error has no result' );
|
||||
like( $error, qr/URL validation failed/, 'unknown error is classified' );
|
||||
is( $result, 1, 'unknown error is async' );
|
||||
is( $error, undef, 'unknown error has no error' );
|
||||
};
|
||||
|
||||
subtest 'check_ssl_certificate - missing URL' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate(undef) );
|
||||
|
||||
is( $result, undef, 'missing URL has no result' );
|
||||
is( $error, 'URL is required', 'missing URL returns error' );
|
||||
is( $result, 1, 'missing URL passes' );
|
||||
is( $error, undef, 'missing URL has no error' );
|
||||
};
|
||||
|
||||
subtest 'check_ssl_certificate - empty URL' => sub {
|
||||
my ( $result, $error ) =
|
||||
wait_promise( $validator->check_ssl_certificate('') );
|
||||
|
||||
is( $result, undef, 'empty URL has no result' );
|
||||
is( $error, 'URL is required', 'empty URL returns error' );
|
||||
is( $result, 1, 'empty URL passes' );
|
||||
is( $error, undef, 'empty URL has no error' );
|
||||
};
|
||||
|
||||
subtest 'validate_url_with_checks - missing URL' => sub {
|
||||
|
||||
189
t/integration.t
189
t/integration.t
@@ -6,6 +6,18 @@ my $t;
|
||||
eval { $t = Test::Mojo->new('Urupam::App'); 1 }
|
||||
or plan skip_all => "Test server not available: $@";
|
||||
|
||||
sub wait_promise {
|
||||
my ($promise) = @_;
|
||||
my ( $value, $error );
|
||||
$promise->then( sub { $value = shift } )
|
||||
->catch( sub { $error = shift } )
|
||||
->wait;
|
||||
return ( $value, $error );
|
||||
}
|
||||
|
||||
my ( $pong, $ping_err ) = wait_promise( $t->app->db->ping );
|
||||
plan skip_all => "Redis not available: $ping_err" if $ping_err;
|
||||
|
||||
my $CODE_PATTERN = qr/^[0-9a-zA-Z\-_]+$/;
|
||||
my $CODE_LENGTH = 12;
|
||||
my $MAX_URL_LENGTH = 2048;
|
||||
@@ -18,6 +30,12 @@ sub validate_short_code_format {
|
||||
&& $code =~ $CODE_PATTERN;
|
||||
}
|
||||
|
||||
sub expected_normalized_url {
|
||||
my ($url) = @_;
|
||||
return $url if defined $url && $url =~ m{^https?://}i;
|
||||
return defined $url ? "http://$url" : undef;
|
||||
}
|
||||
|
||||
sub post_shorten {
|
||||
my ($url) = @_;
|
||||
my $tx = $t->post_ok( '/api/v1/urls' => json => { url => $url } );
|
||||
@@ -52,6 +70,8 @@ sub validate_shorten_response {
|
||||
"$label: short code valid" );
|
||||
is( length( $json->{short_code} ),
|
||||
$CODE_LENGTH, "$label: short code length correct" );
|
||||
is( $json->{original_url}, $url, "$label: original URL matches" )
|
||||
if defined $url;
|
||||
like(
|
||||
$json->{short_url},
|
||||
qr/^https?:\/\/[^\/]+\/$json->{short_code}$/,
|
||||
@@ -81,39 +101,20 @@ sub validate_get_response {
|
||||
return 1;
|
||||
}
|
||||
|
||||
sub skip_if_error {
|
||||
my ( $res, $context ) = @_;
|
||||
if ( $res->{code} != 200 && $res->{code} != 400 && $res->{code} != 404 ) {
|
||||
diag( "$context skipped: " . $res->{error} );
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
subtest 'POST /api/v1/urls - Real validator success cases' => sub {
|
||||
for my $url ( 'https://www.example.com', 'http://www.perl.org' ) {
|
||||
my $res = post_shorten($url);
|
||||
if ( $res->{code} == 200 ) {
|
||||
validate_shorten_response( $res, $url, "URL: $url" );
|
||||
}
|
||||
else {
|
||||
diag( "Test skipped for $url: " . $res->{error} );
|
||||
}
|
||||
is( $res->{code}, 200, "URL accepted: $url" );
|
||||
validate_shorten_response( $res, $url, "URL: $url" );
|
||||
}
|
||||
};
|
||||
|
||||
subtest 'POST /api/v1/urls - Real validator URL normalization' => sub {
|
||||
for my $input ( 'www.example.com', 'example.com' ) {
|
||||
my $res = post_shorten($input);
|
||||
if ( $res->{code} == 200 ) {
|
||||
like( $res->{json}->{original_url},
|
||||
qr/^https?:\/\//, "URL normalized: $input" );
|
||||
ok( validate_short_code_format( $res->{json}->{short_code} ),
|
||||
"Code generated for: $input" );
|
||||
}
|
||||
else {
|
||||
diag( "Normalization test skipped for $input: " . $res->{error} );
|
||||
}
|
||||
is( $res->{code}, 200, "URL normalized: $input" );
|
||||
my $expected = expected_normalized_url($input);
|
||||
validate_shorten_response( $res, $expected, "URL normalized: $input" );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,23 +140,8 @@ subtest 'POST /api/v1/urls - Real validator network errors (async)' => sub {
|
||||
my $url ( 'http://nonexistent-domain-12345.invalid', 'http://192.0.2.1' )
|
||||
{
|
||||
my $res = post_shorten($url);
|
||||
ok(
|
||||
$res->{code} == 200 || $res->{code} == 400,
|
||||
"Network URL accepted or rejected by format: $url"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
subtest 'POST /api/v1/urls - Real validator SSL certificate validation' => sub {
|
||||
my $res = post_shorten('https://www.example.com');
|
||||
if ( $res->{code} == 200 ) {
|
||||
pass('HTTPS URL with valid SSL certificate accepted');
|
||||
}
|
||||
elsif ( $res->{code} == 422 && $res->{error} =~ /SSL certificate/i ) {
|
||||
diag( "SSL validation: " . $res->{error} );
|
||||
}
|
||||
else {
|
||||
diag( "SSL test skipped: " . $res->{error} );
|
||||
is( $res->{code}, 200, "Network URL accepted asynchronously: $url" );
|
||||
validate_shorten_response( $res, $url, "URL: $url" );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,10 +156,13 @@ subtest 'POST /api/v1/urls - Real validator invalid URL format' => sub {
|
||||
is( $res->{error}, $case->{error}, "Correct error for: $case->{url}" );
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
subtest 'POST /api/v1/urls - Real validator bare hostname' => sub {
|
||||
my $res = post_shorten('not-a-url');
|
||||
is( $res->{code}, 200, 'Bare hostname accepted: not-a-url' );
|
||||
like( $res->{json}->{original_url},
|
||||
qr{^http://not-a-url$}, 'Bare hostname normalized with scheme' );
|
||||
validate_shorten_response( $res, 'http://not-a-url',
|
||||
'Bare hostname normalized' );
|
||||
};
|
||||
|
||||
subtest 'POST /api/v1/urls - Real validator URL length validation' => sub {
|
||||
@@ -196,15 +185,8 @@ subtest 'POST /api/v1/urls - Real validator URL edge cases' => sub {
|
||||
)
|
||||
{
|
||||
my $res = post_shorten($url);
|
||||
if ( $res->{code} == 200 ) {
|
||||
ok( validate_short_code_format( $res->{json}->{short_code} ),
|
||||
"Edge case handled: $url" );
|
||||
like( $res->{json}->{original_url},
|
||||
qr/^https?:\/\//, "URL format preserved: $url" );
|
||||
}
|
||||
else {
|
||||
diag( "Edge case test skipped for $url: " . $res->{error} );
|
||||
}
|
||||
is( $res->{code}, 200, "Edge case handled: $url" );
|
||||
validate_shorten_response( $res, $url, "Edge case handled: $url" );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,56 +194,40 @@ subtest 'POST /api/v1/urls - Real database persistence and retrieval' => sub {
|
||||
my $url = 'https://www.example.com';
|
||||
my $res1 = post_shorten($url);
|
||||
|
||||
if ( $res1->{code} == 200 ) {
|
||||
my $code = $res1->{json}->{short_code};
|
||||
ok( validate_short_code_format($code), 'Code generated and stored' );
|
||||
is( $res1->{code}, 200, 'Database write succeeded' );
|
||||
my $code = $res1->{json}->{short_code};
|
||||
ok( validate_short_code_format($code), 'Code generated and stored' );
|
||||
|
||||
my $res2 = get_url($code);
|
||||
if ( $res2->{code} == 200 ) {
|
||||
validate_get_response( $res2, $url, $code, 'Database retrieval' );
|
||||
pass('Database persistence verified');
|
||||
}
|
||||
else {
|
||||
diag( "Database retrieval failed: " . $res2->{error} );
|
||||
}
|
||||
}
|
||||
else {
|
||||
diag( "Database persistence test skipped: " . $res1->{error} );
|
||||
}
|
||||
my $res2 = get_url($code);
|
||||
is( $res2->{code}, 200, 'Database read succeeded' );
|
||||
validate_get_response( $res2, $url, $code, 'Database retrieval' );
|
||||
pass('Database persistence verified');
|
||||
};
|
||||
|
||||
subtest 'POST /api/v1/urls - Real database duplicate URL handling' => sub {
|
||||
my $url = 'https://www.example.com';
|
||||
my $res1 = post_shorten($url);
|
||||
|
||||
if ( $res1->{code} == 200 ) {
|
||||
my $code1 = $res1->{json}->{short_code};
|
||||
ok( validate_short_code_format($code1), 'First code generated' );
|
||||
is( $res1->{code}, 200, 'First create succeeded' );
|
||||
my $code1 = $res1->{json}->{short_code};
|
||||
ok( validate_short_code_format($code1), 'First code generated' );
|
||||
|
||||
my $res2 = post_shorten($url);
|
||||
if ( $res2->{code} == 200 ) {
|
||||
my $code2 = $res2->{json}->{short_code};
|
||||
ok( validate_short_code_format($code2), 'Second code generated' );
|
||||
ok( $code1 ne $code2, 'Duplicate URLs generate different codes' );
|
||||
my $res2 = post_shorten($url);
|
||||
is( $res2->{code}, 200, 'Second create succeeded' );
|
||||
my $code2 = $res2->{json}->{short_code};
|
||||
ok( validate_short_code_format($code2), 'Second code generated' );
|
||||
ok( $code1 ne $code2, 'Duplicate URLs generate different codes' );
|
||||
|
||||
my $get1 = get_url($code1);
|
||||
my $get2 = get_url($code2);
|
||||
my $get1 = get_url($code1);
|
||||
my $get2 = get_url($code2);
|
||||
|
||||
if ( $get1->{code} == 200 && $get2->{code} == 200 ) {
|
||||
is( $get1->{json}->{original_url},
|
||||
$url, 'First code retrieves original URL' );
|
||||
is( $get2->{json}->{original_url},
|
||||
$url, 'Second code retrieves original URL' );
|
||||
pass('Both codes persist and retrieve same URL');
|
||||
}
|
||||
}
|
||||
else {
|
||||
diag( "Duplicate URL test skipped: " . $res2->{error} );
|
||||
}
|
||||
}
|
||||
else {
|
||||
diag( "Duplicate URL test skipped: " . $res1->{error} );
|
||||
}
|
||||
is( $get1->{code}, 200, 'First code retrieves' );
|
||||
is( $get2->{code}, 200, 'Second code retrieves' );
|
||||
is( $get1->{json}->{original_url},
|
||||
$url, 'First code retrieves original URL' );
|
||||
is( $get2->{json}->{original_url},
|
||||
$url, 'Second code retrieves original URL' );
|
||||
pass('Both codes persist and retrieve same URL');
|
||||
};
|
||||
|
||||
subtest 'GET /api/v1/urls/:short_code - Real database error cases' => sub {
|
||||
@@ -285,43 +251,4 @@ subtest 'GET /api/v1/urls/:short_code - Real database error cases' => sub {
|
||||
);
|
||||
};
|
||||
|
||||
subtest 'End-to-end: Full flow with real components' => sub {
|
||||
for my $url ( 'https://www.example.com', 'http://www.perl.org' ) {
|
||||
my $res1 = post_shorten($url);
|
||||
|
||||
if ( $res1->{code} == 200 ) {
|
||||
my $code = $res1->{json}->{short_code};
|
||||
ok( validate_short_code_format($code), "Code generated for: $url" );
|
||||
|
||||
my $res2 = get_url($code);
|
||||
if ( $res2->{code} == 200 ) {
|
||||
validate_get_response( $res2, $url, $code, "End-to-end: $url" );
|
||||
}
|
||||
else {
|
||||
diag( "End-to-end GET failed for $url: " . $res2->{error} );
|
||||
}
|
||||
}
|
||||
else {
|
||||
diag( "End-to-end POST failed for $url: " . $res1->{error} );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
subtest 'Real database connection test' => sub {
|
||||
my $res = post_shorten('https://www.example.com');
|
||||
|
||||
if ( $res->{code} == 200 ) {
|
||||
pass('Database connection successful (Redis accessible)');
|
||||
my $get_res = get_url( $res->{json}->{short_code} );
|
||||
pass('Database read operation successful') if $get_res->{code} == 200;
|
||||
}
|
||||
elsif ( $res->{code} == 400 && $res->{error} =~ /Database error/i ) {
|
||||
diag( "Database connection test: Redis may not be available - "
|
||||
. $res->{error} );
|
||||
}
|
||||
else {
|
||||
diag( "Database connection test skipped: " . $res->{error} );
|
||||
}
|
||||
};
|
||||
|
||||
done_testing();
|
||||
|
||||
Reference in New Issue
Block a user