Merge remote-tracking branch 'origin' into releases_refactor

This commit is contained in:
DariusIII
2026-08-14 10:57:57 +02:00
32 changed files with 2288 additions and 513 deletions
+1 -2
View File
@@ -124,7 +124,6 @@ final class NzbServicePathResolutionTest extends TestCase
{
$tempDir = sys_get_temp_dir().'/nzb-path-permissions-'.uniqid('', true);
$basePath = $tempDir.'/';
mkdir($basePath, 0775, true);
$previousUmask = umask(0022);
try {
@@ -140,7 +139,7 @@ final class NzbServicePathResolutionTest extends TestCase
$path = $service->buildNzbPath('4aabfe07-daff-4d28-9d1d-d2a4ab7b6511', 4, true);
$this->assertSame($basePath.'4/a/a/b/', $path);
foreach ([$basePath.'4', $basePath.'4/a', $basePath.'4/a/a', $basePath.'4/a/a/b'] as $directory) {
foreach ([rtrim($basePath, '/'), $basePath.'4', $basePath.'4/a', $basePath.'4/a/a', $basePath.'4/a/a/b'] as $directory) {
$this->assertSame(02775, fileperms($directory) & 07777);
}
} finally {
+33
View File
@@ -32,6 +32,39 @@ class BookServiceTest extends TestCase
$this->assertSame('9780321125217', $parsed->isbn);
}
public function test_parse_release_name_parses_title_by_author_in_conventional_order(): void
{
$parsed = $this->makeService()->parseReleaseName('Clean Code by Robert C. Martin 2008 EPUB');
$this->assertSame('Clean Code', $parsed->title);
$this->assertSame('Robert C Martin', $parsed->author);
$this->assertSame(2008, $parsed->year);
}
public function test_parse_release_name_captures_year_before_removing_release_noise(): void
{
$parsed = $this->makeService()->parseReleaseName('The Pragmatic Programmer 1999 EPUB');
$this->assertSame('The Pragmatic Programmer', $parsed->title);
$this->assertSame(1999, $parsed->year);
}
public function test_parse_release_name_preserves_series_and_volume_evidence(): void
{
$parsed = $this->makeService()->parseReleaseName('Frank Herbert - Dune (Book 2) EPUB');
$this->assertSame('Dune (Book 2)', $parsed->title);
$this->assertSame('Frank Herbert', $parsed->author);
}
public function test_extract_isbn_ignores_invalid_check_digits(): void
{
$service = $this->makeService();
$this->assertNull($service->extractIsbn('Fake Book 978-0-13-235088-5 EPUB'));
$this->assertNull($service->extractIsbn('Fake Book 0132350883 EPUB'));
}
public function test_parse_release_name_flags_software_as_junk(): void
{
$service = $this->makeService();
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Exceptions\BookProviderException;
use App\Services\GoogleBooksService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
@@ -61,4 +62,19 @@ class GoogleBooksServiceTest extends TestCase
$this->assertTrue($service->isConfigured());
$this->assertFalse($service->hasApiKey());
}
public function test_server_error_is_reported_as_provider_unavailability(): void
{
$service = new GoogleBooksService(
new Client([
'handler' => HandlerStack::create(new MockHandler([new Response(503)])),
'http_errors' => false,
]),
null
);
$this->expectException(BookProviderException::class);
$service->searchBooks('unavailable query');
}
}
+169
View File
@@ -4,16 +4,28 @@ declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Exceptions\BookProviderException;
use App\Services\BookService;
use App\Services\IsbnDbService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class IsbnDbServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_is_configured_returns_false_when_key_is_missing(): void
{
$service = new IsbnDbService(null, '');
@@ -67,6 +79,163 @@ class IsbnDbServiceTest extends TestCase
$this->assertSame('isbndb:9780321125217', $book['asin']);
}
public function test_search_books_uses_the_documented_title_and_match_parameters(): void
{
$mock = new MockHandler([
new Response(200, [], json_encode(['data' => []])),
]);
$history = [];
$handler = HandlerStack::create($mock);
$handler->push(Middleware::history($history));
$service = new IsbnDbService(
new Client(['handler' => $handler, 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
$this->assertSame([], $service->searchBooks('Clean Code', 'Robert C Martin', true));
$this->assertCount(1, $history);
parse_str($history[0]['request']->getUri()->getQuery(), $query);
$this->assertArrayNotHasKey('author', $query);
$this->assertSame('title', $query['column']);
$this->assertSame('true', $query['shouldMatchAll']);
$this->assertSame('10', $query['pageSize']);
}
public function test_successful_empty_search_is_cached(): void
{
$mock = new MockHandler([
new Response(200, [], json_encode(['data' => []])),
]);
$history = [];
$handler = HandlerStack::create($mock);
$handler->push(Middleware::history($history));
$service = new IsbnDbService(
new Client(['handler' => $handler, 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
$this->assertSame([], $service->searchBooks('Unique Empty Book Query'));
$this->assertSame([], $service->searchBooks('Unique Empty Book Query'));
$this->assertCount(1, $history);
}
public function test_rate_limit_response_throws_provider_exception_and_sets_cooldown(): void
{
$mock = new MockHandler([
new Response(429, ['Retry-After' => '120']),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
try {
$service->searchBooks('Rate Limited Query');
$this->fail('Expected the ISBNdb request to report provider unavailability.');
} catch (BookProviderException $exception) {
$this->assertSame('isbndb', $exception->provider);
$this->assertSame(429, $exception->statusCode);
$this->assertSame(120, $exception->retryAfterSeconds);
}
$this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY));
}
public function test_malformed_success_response_throws_provider_exception(): void
{
$mock = new MockHandler([
new Response(200, [], '{not-json'),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
$this->expectException(BookProviderException::class);
$service->searchBooks('Malformed Response Query');
}
public function test_success_response_without_documented_data_envelope_is_retryable(): void
{
$mock = new MockHandler([
new Response(200, [], json_encode(['books' => []])),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
$this->expectException(BookProviderException::class);
$service->searchBooks('Missing Data Envelope Query');
}
public function test_authentication_failure_is_distinguished_without_transient_cooldown(): void
{
$mock = new MockHandler([
new Response(401),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
try {
$service->searchBooks('Authentication Failure Query');
$this->fail('Expected an authentication provider exception.');
} catch (BookProviderException $exception) {
$this->assertSame(401, $exception->statusCode);
$this->assertNull($exception->retryAfterSeconds);
}
$this->assertFalse(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY));
}
public function test_server_failure_applies_default_cooldown(): void
{
$mock = new MockHandler([
new Response(503),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
try {
$service->searchBooks('Server Failure Query');
$this->fail('Expected a transient provider exception.');
} catch (BookProviderException $exception) {
$this->assertSame(503, $exception->statusCode);
$this->assertSame(300, $exception->retryAfterSeconds);
}
$this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY));
}
public function test_network_failure_applies_default_cooldown(): void
{
$request = new Request('GET', 'https://api2.isbndb.com/books/Network');
$mock = new MockHandler([
new ConnectException('Connection failed', $request),
]);
$service = new IsbnDbService(
new Client(['handler' => HandlerStack::create($mock), 'base_uri' => 'https://api2.isbndb.com']),
'test-key'
);
try {
$service->searchBooks('Network Failure Query');
$this->fail('Expected a network provider exception.');
} catch (BookProviderException $exception) {
$this->assertNull($exception->statusCode);
$this->assertSame(300, $exception->retryAfterSeconds);
}
$this->assertTrue(Cache::has(IsbnDbService::COOLDOWN_CACHE_KEY));
}
public function test_find_by_isbn_reads_book_payload(): void
{
$mock = new MockHandler([
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Exceptions\BookProviderException;
use App\Services\OpenLibraryService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
@@ -66,4 +67,17 @@ class OpenLibraryServiceTest extends TestCase
$this->assertSame('Robert C. Martin', $book['author']);
$this->assertSame('9780134494166', $book['isbn']);
}
public function test_malformed_search_response_is_reported_as_provider_unavailability(): void
{
$service = new OpenLibraryService(
new Client(['handler' => HandlerStack::create(new MockHandler([
new Response(200, [], '{invalid-json'),
]))])
);
$this->expectException(BookProviderException::class);
$service->searchBooks('malformed query');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Support;
use App\Support\BookIsbn;
use PHPUnit\Framework\TestCase;
class BookIsbnTest extends TestCase
{
public function test_normalizes_and_validates_isbn_10_and_isbn_13(): void
{
$this->assertSame('0132350882', BookIsbn::normalize('0-13-235088-2'));
$this->assertSame('9780132350884', BookIsbn::normalize('978-0-13-235088-4'));
$this->assertNull(BookIsbn::normalize('0132350883'));
$this->assertNull(BookIsbn::normalize('9780132350885'));
}
public function test_converts_and_compares_equivalent_isbn_versions(): void
{
$this->assertSame('9780132350884', BookIsbn::toIsbn13('0132350882'));
$this->assertSame('0132350882', BookIsbn::toIsbn10('9780132350884'));
$this->assertTrue(BookIsbn::equivalent('0132350882', '9780132350884'));
$this->assertFalse(BookIsbn::equivalent('0132350882', '9780321125217'));
}
public function test_returns_all_equivalent_identifiers_without_duplicates(): void
{
$this->assertSame(
['0132350882', '9780132350884'],
BookIsbn::equivalents('9780132350884')
);
}
}
+148
View File
@@ -31,6 +31,154 @@ class BookMatchScorerTest extends TestCase
$this->assertSame(1.0, $score);
}
public function test_returns_perfect_score_for_equivalent_isbn_10_candidate(): void
{
$parsed = new BookParseResult(
rawName: 'Clean Code 9780132350884',
title: 'Clean Code',
isbn: '9780132350884'
);
$score = (new BookMatchScorer)->score([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
'isbn' => null,
'ean' => '0132350882',
], $parsed);
$this->assertSame(1.0, $score);
}
public function test_rejects_candidate_with_conflicting_explicit_isbn(): void
{
$parsed = new BookParseResult(
rawName: 'Clean Code 9780132350884',
title: 'Clean Code',
author: 'Robert C. Martin',
isbn: '9780132350884'
);
$score = (new BookMatchScorer)->score([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
'isbn' => '9780321125217',
'ean' => '0321125215',
'publisher' => 'Excellent Publisher',
'cover' => 1,
], $parsed);
$this->assertSame(0.0, $score);
}
public function test_rejects_candidate_when_only_one_of_two_explicit_identifiers_matches(): void
{
$parsed = new BookParseResult(
rawName: 'Clean Code 9780132350884',
title: 'Clean Code',
isbn: '9780132350884'
);
$score = (new BookMatchScorer)->score([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
'isbn' => '9780132350884',
'ean' => '0321125215',
], $parsed);
$this->assertSame(0.0, $score);
}
public function test_author_matching_is_order_insensitive_and_supports_initials(): void
{
$parsed = new BookParseResult(
rawName: 'Clean Code by Robert C Martin',
title: 'Clean Code',
author: 'Robert C Martin'
);
$score = (new BookMatchScorer)->score([
'title' => 'Clean Code',
'author' => 'Martin, Robert C.',
], $parsed);
$this->assertGreaterThanOrEqual(0.82, $score);
}
public function test_cover_and_publisher_do_not_increase_identity_score(): void
{
$parsed = new BookParseResult(
rawName: 'Completely Different Book',
title: 'Completely Different Book'
);
$scorer = new BookMatchScorer;
$withoutMetadata = $scorer->score([
'title' => 'Another Unrelated Work',
'publisher' => '',
'cover' => 0,
], $parsed);
$withMetadata = $scorer->score([
'title' => 'Another Unrelated Work',
'publisher' => 'Publisher',
'cover' => 1,
], $parsed);
$this->assertSame($withoutMetadata, $withMetadata);
}
public function test_rejects_conflicting_volume_numbers(): void
{
$parsed = new BookParseResult(
rawName: 'Dune Book 2',
title: 'Dune Book 2',
author: 'Frank Herbert'
);
$score = (new BookMatchScorer)->score([
'title' => 'Dune Book 3',
'author' => 'Frank Herbert',
], $parsed);
$this->assertSame(0.0, $score);
}
public function test_rejects_conflicting_edition_numbers(): void
{
$parsed = new BookParseResult(
rawName: 'Effective Java 3rd Edition',
title: 'Effective Java 3rd Edition',
author: 'Joshua Bloch'
);
$score = (new BookMatchScorer)->score([
'title' => 'Effective Java 2nd Edition',
'author' => 'Joshua Bloch',
], $parsed);
$this->assertSame(0.0, $score);
}
public function test_multilingual_punctuation_normalizes_without_metadata_boosts(): void
{
$parsed = new BookParseResult(
rawName: 'Cien años de soledad by Gabriel García Márquez',
title: 'Cien años de soledad',
author: 'Gabriel García Márquez'
);
$score = (new BookMatchScorer)->score([
'title' => 'Cien años de soledad!',
'author' => 'Márquez, Gabriel García',
], $parsed);
$this->assertSame(1.0, $score);
}
public function test_generic_stop_words_do_not_count_as_significant_title_evidence(): void
{
$this->assertSame(2, (new BookMatchScorer)->meaningfulTitleTokenCount('The Art of War'));
}
public function test_higher_score_for_better_title_author_match(): void
{
$parsed = new BookParseResult(
+49
View File
@@ -4,8 +4,12 @@ namespace Tests\Unit;
use App\Http\Controllers\BasePageController;
use App\Http\Controllers\SearchController;
use Illuminate\Container\Container;
use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Http\Request;
use Illuminate\Routing\RouteCollection;
use Illuminate\Routing\UrlGenerator;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\HttpFoundation\Response;
@@ -71,6 +75,46 @@ class UserViewRequestInputTest extends TestCase
$this->assertSame([-1], $method->invoke($controller, Request::create('/search', 'GET', ['searchadvcat' => ''])));
}
public function test_local_return_url_accepts_local_targets_and_rejects_external_urls(): void
{
$originalContainer = Container::getInstance();
$container = new Container;
$container->instance(
UrlGeneratorContract::class,
new UrlGenerator(new RouteCollection, Request::create('https://nntmux.test')),
);
Container::setInstance($container);
try {
$controller = $this->controller();
$this->assertSame(
'https://nntmux.test/mymovies',
$controller->returnUrl(Request::create('https://nntmux.test/mymovies'), '/mymovies'),
);
$this->assertSame(
'https://nntmux.test/browse',
$controller->returnUrl(Request::create('https://nntmux.test/mymovies', 'GET', ['from' => '/browse']), '/mymovies'),
);
$this->assertSame(
'https://nntmux.test/browse?view=covers',
$controller->returnUrl(
Request::create('https://nntmux.test/mymovies', 'GET', ['from' => 'https://nntmux.test/browse?view=covers']),
'/mymovies',
),
);
$this->assertSame(
'https://nntmux.test/mymovies',
$controller->returnUrl(
Request::create('https://nntmux.test/mymovies', 'GET', ['from' => 'https://external.example/browse']),
'/mymovies',
),
);
} finally {
Container::setInstance($originalContainer);
}
}
private function controller(): object
{
return new class extends BasePageController
@@ -112,6 +156,11 @@ class UserViewRequestInputTest extends TestCase
{
return $this->paginationOffset($page, $perPage);
}
public function returnUrl(Request $request, string $fallback): string
{
return $this->localReturnUrl($request, $fallback);
}
};
}
}