mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Merge remote-tracking branch 'origin' into releases_refactor
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user