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
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Auth\GenericUser;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Cache;
use PDO;
use Tests\TestCase;
@@ -101,6 +103,13 @@ class AuthRedirectMiddlewareTest extends TestCase
->assertRedirect('/');
}
public function test_is_verified_alias_uses_laravels_email_verification_middleware(): void
{
$middlewareAliases = app(Router::class)->getMiddleware();
$this->assertSame(EnsureEmailIsVerified::class, $middlewareAliases['isVerified'] ?? null);
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use PDO;
use Tests\TestCase;
class BookInfoIsbnIndexesMigrationTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
private array $originalEnvironment = [];
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-bookinfo-isbn-indexes.sqlite';
$this->originalEnvironment = [
'APP_ENV' => getenv('APP_ENV'),
'DB_CONNECTION' => getenv('DB_CONNECTION'),
'DB_DATABASE' => getenv('DB_DATABASE'),
];
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings (name, value) VALUES ('categorizeforeign', '0'), ('catwebdl', '1')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => $this->databasePath,
]);
Schema::create('bookinfo', function (Blueprint $table): void {
$table->increments('id');
$table->string('isbn')->nullable();
$table->string('ean')->nullable();
});
}
protected function tearDown(): void
{
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
foreach ($this->originalEnvironment as $key => $value) {
$this->setEnvironmentValue($key, $value === false ? null : $value);
}
}
public function test_it_adds_and_removes_isbn_indexes_idempotently(): void
{
$migration = require database_path('migrations/2026_08_13_133021_add_isbn_indexes_to_bookinfo_table.php');
$migration->up();
$migration->up();
$this->assertTrue(Schema::hasIndex('bookinfo', 'ix_bookinfo_isbn'));
$this->assertTrue(Schema::hasIndex('bookinfo', 'ix_bookinfo_ean'));
$migration->down();
$this->assertFalse(Schema::hasIndex('bookinfo', 'ix_bookinfo_isbn'));
$this->assertFalse(Schema::hasIndex('bookinfo', 'ix_bookinfo_ean'));
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
+433
View File
@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Exceptions\BookProviderException;
use App\Models\BookInfo;
use App\Services\BookService;
use App\Services\GoogleBooksService;
use App\Services\IsbnDbService;
use App\Services\ItunesService;
use App\Services\OpenLibraryService;
use App\Services\ReleaseImageService;
use App\Support\BookMatchScorer;
use App\Support\Data\BookParseResult;
use App\Support\Data\ImageProcessingResult;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use PDO;
use Tests\TestCase;
class BookServiceMatchingTest extends TestCase
{
private string $databasePath;
/**
* @var array<string, string|false>
*/
private array $originalEnvironment = [];
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-book-service-matching.sqlite';
$this->originalEnvironment = [
'APP_ENV' => getenv('APP_ENV'),
'DB_CONNECTION' => getenv('DB_CONNECTION'),
'DB_DATABASE' => getenv('DB_DATABASE'),
];
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$pdo->exec("INSERT INTO settings (name, value) VALUES ('maxbooksprocessed', '50'), ('amazonsleep', '0'), ('lookupbooks', '1')");
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => $this->databasePath,
]);
DB::purge();
DB::reconnect();
Schema::create('bookinfo', function (Blueprint $table): void {
$table->increments('id');
$table->string('title');
$table->string('author')->default('');
$table->string('asin')->nullable()->unique();
$table->string('isbn')->nullable()->index();
$table->string('ean')->nullable()->index();
$table->string('url')->nullable();
$table->unsignedInteger('salesrank')->nullable();
$table->string('publisher')->nullable();
$table->dateTime('publishdate')->nullable();
$table->string('pages')->nullable();
$table->text('overview')->nullable();
$table->string('genre')->default('');
$table->boolean('cover')->default(false);
$table->timestamps();
});
}
protected function tearDown(): void
{
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
parent::tearDown();
foreach ($this->originalEnvironment as $key => $value) {
$this->setEnvironmentValue($key, $value === false ? null : $value);
}
}
public function test_rejected_isbndb_candidates_fall_through_to_google_books(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')->once()->andReturn([
$this->candidate('Unrelated Cookbook', 'Different Author', 'isbndb:wrong'),
]);
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldReceive('searchBooks')->once()->andReturn([
$this->candidate('Clean Code', 'Robert C. Martin', 'googlebooks:clean-code'),
]);
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldNotReceive('searchBooks');
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldNotReceive('findEbooks');
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $this->cleanCodeParseResult();
$bookId = $service->updateBookInfo('Robert C Martin Clean Code');
$this->assertIsInt($bookId);
$this->assertGreaterThan(0, $bookId);
$this->assertSame('Clean Code', BookInfo::query()->findOrFail($bookId)->title);
}
public function test_accepted_isbndb_candidate_stops_the_provider_cascade(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')->once()->andReturn([
$this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code'),
]);
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldNotReceive('searchBooks');
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldNotReceive('searchBooks');
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldNotReceive('findEbooks');
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $this->cleanCodeParseResult();
$this->assertGreaterThan(0, $service->updateBookInfo('Robert C Martin Clean Code'));
}
public function test_duplicate_editions_of_the_same_work_do_not_create_false_ambiguity(): void
{
$first = $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:edition-one');
$second = $this->candidate('Clean Code', 'Martin, Robert C.', 'isbndb:edition-two');
$second['publisher'] = 'Prentice Hall';
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')->once()->andReturn([$first, $second]);
$service = $this->makeService(
$isbnDb,
Mockery::mock(GoogleBooksService::class),
Mockery::mock(OpenLibraryService::class),
Mockery::mock(ItunesService::class),
);
$service->parsedBookResult = $this->cleanCodeParseResult();
$this->assertGreaterThan(0, $service->updateBookInfo('Robert C Martin Clean Code'));
}
public function test_close_distinct_runner_up_prevents_a_premature_match(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')->once()->andReturn([
$this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code'),
$this->candidate('Clean Coder', 'Robert C. Martin', 'isbndb:clean-coder'),
]);
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldReceive('searchBooks')->once()->andReturn([]);
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldReceive('searchBooks')->once()->andReturn([]);
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldReceive('findEbooks')->once()->andReturn([]);
$itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse();
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $this->cleanCodeParseResult();
$this->assertSame(-2, $service->updateBookInfo('Robert C Martin Clean Code'));
}
public function test_ambiguous_author_dash_title_parse_retries_as_a_title_only_search(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')
->once()
->with('From Journeyman to Master', 'The Pragmatic Programmer', true)
->andReturn([]);
$isbnDb->shouldReceive('searchBooks')
->once()
->with('The Pragmatic Programmer - From Journeyman to Master', null, true)
->andReturn([
$this->candidate(
'The Pragmatic Programmer: From Journeyman to Master',
'Andrew Hunt, David Thomas',
'isbndb:pragmatic-programmer',
),
]);
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldNotReceive('searchBooks');
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldNotReceive('searchBooks');
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldNotReceive('findEbooks');
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $service->parseReleaseName(
'The Pragmatic Programmer - From Journeyman to Master EPUB'
);
$this->assertGreaterThan(0, $service->updateBookInfo($service->parsedBookResult->searchQuery()));
}
public function test_transient_provider_failure_leaves_the_match_pending(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('searchBooks')->once()->andThrow(
new BookProviderException('isbndb', 'Unavailable', 429, 300)
);
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldReceive('searchBooks')->once()->andReturn([]);
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldReceive('searchBooks')->once()->andReturn([]);
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldReceive('findEbooks')->once()->andReturn([]);
$itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse();
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $this->cleanCodeParseResult();
$this->assertNull($service->updateBookInfo('Robert C Martin Clean Code'));
}
public function test_deterministic_exhaustion_returns_failed_sentinel(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnFalse();
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldReceive('searchBooks')->once()->andReturn([]);
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldReceive('searchBooks')->once()->andReturn([]);
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldReceive('findEbooks')->once()->andReturn([]);
$itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse();
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = $this->cleanCodeParseResult();
$this->assertSame(-2, $service->updateBookInfo('Robert C Martin Clean Code'));
}
public function test_exact_local_isbn_lookup_checks_both_identifier_versions(): void
{
$bookId = DB::table('bookinfo')->insertGetId([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
'asin' => 'existing:clean-code',
'isbn' => '9780132350884',
'ean' => '0132350882',
'genre' => '',
'cover' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service = $this->makeService(
Mockery::mock(IsbnDbService::class),
Mockery::mock(GoogleBooksService::class),
Mockery::mock(OpenLibraryService::class),
Mockery::mock(ItunesService::class),
);
$this->assertSame($bookId, $service->getBookInfoByIsbn('0-13-235088-2')?->id);
}
public function test_equivalent_isbn_deduplication_preserves_metadata_and_failed_cover_state(): void
{
$bookId = DB::table('bookinfo')->insertGetId([
'title' => 'Clean Code',
'author' => 'Robert C. Martin',
'asin' => 'legacy:clean-code',
'isbn' => null,
'ean' => '0132350882',
'publisher' => 'Prentice Hall',
'genre' => 'Programming',
'cover' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$candidate = $this->candidate('Clean Code', 'Robert C. Martin', 'isbndb:clean-code');
$candidate['isbn'] = '9780132350884';
$candidate['publisher'] = '';
$candidate['genre'] = '';
$candidate['coverurl'] = 'https://example.com/cover.jpg';
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnTrue();
$isbnDb->shouldReceive('findByIsbn')->once()->with('9780132350884')->andReturn($candidate);
$isbnDb->shouldNotReceive('searchBooks');
$service = $this->makeService(
$isbnDb,
Mockery::mock(GoogleBooksService::class),
Mockery::mock(OpenLibraryService::class),
Mockery::mock(ItunesService::class),
);
$service->parsedBookResult = new BookParseResult(
rawName: 'Clean Code 9780132350884',
title: 'Clean Code',
author: 'Robert C Martin',
isbn: '9780132350884',
);
$this->assertSame($bookId, $service->updateBookInfo('Clean Code', '9780132350884'));
$book = BookInfo::query()->findOrFail($bookId);
$this->assertSame('legacy:clean-code', $book->asin);
$this->assertSame('Prentice Hall', $book->publisher);
$this->assertSame('Programming', $book->genre);
$this->assertFalse((bool) $book->cover);
$this->assertSame(1, BookInfo::query()->count());
}
public function test_two_word_unauthored_title_reaches_external_providers_but_is_not_loosely_accepted(): void
{
$isbnDb = Mockery::mock(IsbnDbService::class);
$isbnDb->shouldReceive('isConfigured')->andReturnFalse();
$google = Mockery::mock(GoogleBooksService::class);
$google->shouldReceive('searchBooks')->once()->andReturn([]);
$openLibrary = Mockery::mock(OpenLibraryService::class);
$openLibrary->shouldReceive('searchBooks')->once()->andReturn([]);
$itunes = Mockery::mock(ItunesService::class);
$itunes->shouldReceive('findEbooks')->once()->andReturn([]);
$itunes->shouldReceive('lastRequestFailed')->once()->andReturnFalse();
$service = $this->makeService($isbnDb, $google, $openLibrary, $itunes);
$service->parsedBookResult = new BookParseResult(
rawName: 'Atomic Habits EPUB',
title: 'Atomic Habits',
);
$this->assertSame(-2, $service->updateBookInfo('Atomic Habits'));
}
private function makeService(
IsbnDbService $isbnDb,
GoogleBooksService $googleBooks,
OpenLibraryService $openLibrary,
ItunesService $itunes,
): BookService {
$images = Mockery::mock(ReleaseImageService::class);
$images->shouldReceive('saveRemoteImage')
->zeroOrMoreTimes()
->andReturn(ImageProcessingResult::failure('No cover URL.'));
return new BookService(
isbnDb: $isbnDb,
googleBooks: $googleBooks,
openLibrary: $openLibrary,
itunes: $itunes,
scorer: new BookMatchScorer,
releaseImageService: $images,
);
}
private function cleanCodeParseResult(): BookParseResult
{
return new BookParseResult(
rawName: 'Clean Code by Robert C Martin',
title: 'Clean Code',
author: 'Robert C Martin',
year: 2008,
);
}
/**
* @return array<string, mixed>
*/
private function candidate(string $title, string $author, string $asin): array
{
return [
'title' => $title,
'author' => $author,
'asin' => $asin,
'isbn' => null,
'ean' => null,
'url' => '',
'salesrank' => '',
'publisher' => '',
'publishdate' => null,
'pages' => '',
'overview' => '',
'genre' => '',
'coverurl' => '',
'cover' => 0,
];
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
@@ -122,6 +122,46 @@ class PostProcessRunnerBooksGateTest extends TestCase
$this->assertStringContainsString('artisan postprocess:guid books a', $runner->captured[0]);
}
public function test_renamed_only_mode_skips_unrenamed_pending_books(): void
{
DB::table('settings')->where('name', 'lookupbooks')->update(['value' => '2']);
DB::table('releases')->insert([
'id' => 2,
'name' => 'A Plain Unrenamed Book',
'searchname' => 'A Plain Unrenamed Book',
'groups_id' => 1,
'size' => 1,
'postdate' => now(),
'adddate' => now(),
'guid' => str_repeat('b', 40),
'leftguid' => 'b',
'fromname' => 'poster@example.com',
'categories_id' => Category::BOOKS_EBOOK,
'bookinfo_id' => null,
'isrenamed' => 0,
]);
$runner = new class extends PostProcessRunner
{
public array $captured = [];
public function headerNone(): void {}
protected function headerStart(string $workType, int $count, int $maxProcesses): void {}
protected function executeCommand(string $command): string
{
$this->captured[] = $command;
return '';
}
};
$runner->processBooks();
$this->assertSame([], $runner->captured);
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
@@ -159,6 +199,7 @@ class PostProcessRunnerBooksGateTest extends TestCase
$table->string('fromname')->nullable();
$table->integer('categories_id')->default(Category::OTHER_MISC);
$table->integer('bookinfo_id')->nullable();
$table->tinyInteger('isrenamed')->default(0);
});
}
}
+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);
}
};
}
}