mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Improve book matching
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user