use Test::More; use Test::Mojo; use Mojo::Promise; use Urupam::App; use_ok('Urupam::API'); package Mock::Validator; use Mojo::Base -base; has validate_url_cb => sub { sub { Mojo::Promise->resolve( $_[1] ) } }; has validate_short_code_cb => sub { sub { 1 } }; sub validate_url_with_checks { my ( $self, $url ) = @_; return $self->validate_url_cb->( $self, $url ); } sub validate_short_code { my ( $self, $code ) = @_; return $self->validate_short_code_cb->( $self, $code ); } package Mock::URLService; use Mojo::Base -base; has create_short_url_cb => sub { sub { Mojo::Promise->resolve('AbCdEf123456') } }; has get_original_url_cb => sub { sub { Mojo::Promise->resolve('https://example.com') } }; sub create_short_url { my ( $self, $url ) = @_; return $self->create_short_url_cb->( $self, $url ); } sub get_original_url { my ( $self, $code ) = @_; return $self->get_original_url_cb->( $self, $code ); } package main; my $t = Test::Mojo->new('Urupam::App'); my $validator = Mock::Validator->new; my $url_service = Mock::URLService->new; $t->ua->server->url->scheme('http'); $t->ua->server->url->host('unit.test'); $t->ua->server->url->port(80); $t->app->helper( validator => sub { return $validator } ); $t->app->helper( url_service => sub { return $url_service } ); my ( $validated_url, $created_url, $validated_code, $get_code ); my ( $validator_called, $url_service_called ); sub reset_mocks { ( $validated_url, $created_url, $validated_code, $get_code ) = (undef) x 4; ( $validator_called, $url_service_called ) = ( 0, 0 ); $validator->validate_url_cb( sub { my ( $self, $url ) = @_; $validator_called = 1; $validated_url = $url; return Mojo::Promise->resolve($url); } ); $validator->validate_short_code_cb( sub { my ( $self, $code ) = @_; $validated_code = $code; return 1; } ); $url_service->create_short_url_cb( sub { my ( $self, $url ) = @_; $url_service_called = 1; $created_url = $url; return Mojo::Promise->resolve('AbCdEf123456'); } ); $url_service->get_original_url_cb( sub { my ( $self, $code ) = @_; $url_service_called = 1; $get_code = $code; return Mojo::Promise->resolve('https://example.com'); } ); } subtest 'POST /api/v1/urls - invalid JSON' => sub { reset_mocks(); $t->post_ok( '/api/v1/urls' => { 'Content-Type' => 'application/json' } => 'invalid json' ) ->status_is(400) ->json_is( '/error' => 'Invalid JSON format' ); ok( !$validator_called, 'Validator not called for invalid JSON' ); ok( !$url_service_called, 'URL service not called for invalid JSON' ); }; subtest 'POST /api/v1/urls - invalid JSON types' => sub { reset_mocks(); $t->post_ok( '/api/v1/urls' => json => [] ) ->status_is(400) ->json_is( '/error' => 'Invalid JSON format' ); $t->post_ok( '/api/v1/urls' => json => 'not a hash' ) ->status_is(400) ->json_is( '/error' => 'Invalid JSON format' ); ok( !$validator_called, 'Validator not called for invalid JSON types' ); ok( !$url_service_called, 'URL service not called for invalid JSON types' ); }; subtest 'POST /api/v1/urls - missing URL' => sub { reset_mocks(); $t->post_ok( '/api/v1/urls' => json => {} ) ->status_is(400) ->json_is( '/error' => 'URL is required' ); ok( !$validator_called, 'Validator not called for missing URL' ); ok( !$url_service_called, 'URL service not called for missing URL' ); }; subtest 'POST /api/v1/urls - whitespace URL' => sub { reset_mocks(); $t->post_ok( '/api/v1/urls' => json => { url => ' ' } ) ->status_is(400) ->json_is( '/error' => 'URL is required' ); ok( !$validator_called, 'Validator not called for whitespace URL' ); ok( !$url_service_called, 'URL service not called for whitespace URL' ); }; subtest 'POST /api/v1/urls - whitespace-only with tabs/newlines' => sub { reset_mocks(); $t->post_ok( '/api/v1/urls' => json => { url => "\n\t " } ) ->status_is(400) ->json_is( '/error' => 'URL is required' ); ok( !$validator_called, 'Validator not called for tab/newline URL' ); ok( !$url_service_called, 'URL service not called for tab/newline URL' ); }; subtest 'POST /api/v1/urls - success path' => sub { reset_mocks(); my $tx = $t->post_ok( '/api/v1/urls' => json => { url => ' https://example.com/path ' } ); my $base_url = $t->ua->server->url->clone->path('')->to_abs; $tx->status_is(200)->json_is( '/success' => 1 ); $tx->json_is( '/short_code' => 'AbCdEf123456' ); $tx->json_is( '/original_url' => 'https://example.com/path' ); $tx->json_like( '/short_url' => qr{^\Q$base_url\EAbCdEf123456$} ); ok( $validator_called, 'Validator called' ); ok( $url_service_called, 'URL service called' ); is( $validated_url, 'https://example.com/path', 'URL sanitized before validation' ); is( $created_url, 'https://example.com/path', 'URL passed to URL service' ); }; subtest 'POST /api/v1/urls - validator normalization' => sub { reset_mocks(); $validator->validate_url_cb( sub { $validator_called = 1; return Mojo::Promise->resolve('http://normalized.test/path'); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'normalized.test/path' } ) ->status_is(200) ->json_is( '/short_code' => 'AbCdEf123456' ); is( $created_url, 'http://normalized.test/path', 'URL service receives normalized URL' ); }; subtest 'POST /api/v1/urls - validator error' => sub { reset_mocks(); $validator->validate_url_cb( sub { $validator_called = 1; return Mojo::Promise->reject('SSL certificate error: bad cert'); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'https://example.com' } ) ->status_is(422) ->json_is( '/error' => 'SSL certificate error: bad cert' ); ok( $validator_called, 'Validator called' ); ok( !$url_service_called, 'URL service not called after validator error' ); }; subtest 'POST /api/v1/urls - error sanitization' => sub { reset_mocks(); $url_service->create_short_url_cb( sub { $url_service_called = 1; return Mojo::Promise->reject("Database error: \n\t!!"); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'https://example.com' } ) ->status_is(400) ->json_is( '/error' => 'Database error: bad' ); ok( $validator_called, 'Validator called' ); ok( $url_service_called, 'URL service called' ); }; subtest 'POST /api/v1/urls - error sanitization truncation' => sub { reset_mocks(); my $long_error = 'Database error: ' . ( 'a' x 210 ); my $expected = substr( $long_error, 0, 200 ) . '...'; $url_service->create_short_url_cb( sub { $url_service_called = 1; return Mojo::Promise->reject($long_error); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'https://example.com' } ) ->status_is(400) ->json_is( '/error' => $expected ); ok( $validator_called, 'Validator called' ); ok( $url_service_called, 'URL service called' ); }; subtest 'POST /api/v1/urls - URL service error' => sub { reset_mocks(); $url_service->create_short_url_cb( sub { $url_service_called = 1; return Mojo::Promise->reject('Database error: connection failed'); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'https://example.com' } ) ->status_is(400) ->json_is( '/error' => 'Database error: connection failed' ); ok( $validator_called, 'Validator called' ); ok( $url_service_called, 'URL service called' ); }; subtest 'POST /api/v1/urls - status mapping for network error' => sub { reset_mocks(); $validator->validate_url_cb( sub { $validator_called = 1; return Mojo::Promise->reject( 'Cannot reach URL: Connection refused'); } ); $t->post_ok( '/api/v1/urls' => json => { url => 'https://example.com' } ) ->status_is(422) ->json_is( '/error' => 'Cannot reach URL: Connection refused' ); ok( $validator_called, 'Validator called' ); ok( !$url_service_called, 'URL service not called after validator error' ); }; subtest 'GET /api/v1/urls/:short_code - invalid short code format' => sub { reset_mocks(); $validator->validate_short_code_cb( sub { 0 } ); $t->get_ok('/api/v1/urls/bad@code') ->status_is(400) ->json_is( '/error' => 'Invalid short code format' ); ok( !$url_service_called, 'URL service not called for invalid short code' ); }; subtest 'GET /api/v1/urls - missing short_code param' => sub { reset_mocks(); $t->get_ok('/api/v1/urls')->status_is(404); ok( !$url_service_called, 'URL service not called for missing short_code' ); }; subtest 'GET /api/v1/urls/:short_code - empty short_code param' => sub { reset_mocks(); $t->get_ok('/api/v1/urls/')->status_is(404); ok( !$url_service_called, 'URL service not called for empty short_code' ); }; subtest 'GET /api/v1/urls/:short_code - not found' => sub { reset_mocks(); $url_service->get_original_url_cb( sub { $url_service_called = 1; return Mojo::Promise->resolve(undef); } ); $t->get_ok('/api/v1/urls/AbCdEf123456') ->status_is(404) ->json_is( '/error' => 'Short code not found' ); ok( $url_service_called, 'URL service called' ); }; subtest 'GET /api/v1/urls/:short_code - success path' => sub { reset_mocks(); my $base_url = $t->ua->server->url->clone->path('')->to_abs; $t->get_ok('/api/v1/urls/AbCdEf123456') ->status_is(200) ->json_is( '/success' => 1 ) ->json_is( '/short_code' => 'AbCdEf123456' ) ->json_is( '/original_url' => 'https://example.com' ) ->json_like( '/short_url' => qr{^\Q$base_url\EAbCdEf123456$} ); ok( $url_service_called, 'URL service called' ); is( $validated_code, 'AbCdEf123456', 'Short code validated' ); is( $get_code, 'AbCdEf123456', 'Short code passed to URL service' ); }; subtest 'GET /api/v1/urls/:short_code - URL service error' => sub { reset_mocks(); $url_service->get_original_url_cb( sub { $url_service_called = 1; return Mojo::Promise->reject('DNS resolution failed: timeout'); } ); $t->get_ok('/api/v1/urls/AbCdEf123456') ->status_is(422) ->json_is( '/error' => 'DNS resolution failed: timeout' ); ok( $url_service_called, 'URL service called' ); }; subtest 'GET /api/v1/urls/:short_code - non-422 error mapping' => sub { reset_mocks(); $url_service->get_original_url_cb( sub { $url_service_called = 1; return Mojo::Promise->reject('Database error: connection failed'); } ); $t->get_ok('/api/v1/urls/AbCdEf123456') ->status_is(400) ->json_is( '/error' => 'Database error: connection failed' ); ok( $url_service_called, 'URL service called' ); }; done_testing();