what the fuck

This commit is contained in:
boris committed 2026-04-02 13:46:07 +01:00
1 parent c1b1c2f372
commit 72a99a5bcd
69 files changed
+7519 -558

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ1DfAnq5rpIGEmuuV
TxzKdgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEGq9s3Pc5KgVqWPc
DWBTt+kEggTQKShV0ASL1ZVSQa+fpUE8Alq65oJsTccgZu2MXFx0DaomeB5DSHQX
MxrwmrXz+f9/17AzPD7dBw+2YP1UIXkiP6B89RvCc2g6G1yHL52ziEgtXPrYRrcs
kwUHPbcd6X5XATJRSxmu8VOBimxmCNgAtuhPl6wb7a+YZYLv7EVYGaqAlPiySOKP
Nnuv2ENAz958FNX2O7VyI4N+8W+DEtVo2CCo8luxb8SrxmtO7yxEMC9Fs2wZitgs
PgCWIYy+laB0KBP0qIi0kOkuySDSQgC8psz0b2lKVS/k2AP3SkoyIXfjCSIgZYBI
cFLSWlbbau/h+0AAuMmcWTheVvhSowFRVMJ5KyGK0Mw0+cADs4sd28OVtC8px+jS
4Qr1D8jDpiEuSJhdJ1E9vRC6SYq03N2XjLWl/r18aydBtsdbMGGCi2aQ4KAAprNX
siDGR5gKHYkY0Jef7K2VGe8ojcsjlXJfRR9B10A1+uvbMUIVcz3qk436jlNyBIsG
Doqbp/Vortfvj97rPgY4VROYNbDaVa0I5etScRVuY95zurxbhHbT4sUny73+jDbN
IxXslYKKqJ+d9tWVM9CCIkBZj075HgEnss8Yl4/59r2FjMcruNwxV9Ife019yOdO
YSG/HEYveYOeM966avUhJw3AxpCSoeGRyKCFGcbj6Yu0T/MR69DA2vpLKrI29I5C
w1Hig2PpWktduTCA+XlYAnl5wvJ60Z2BZg78RRZ/B7iEKmc5uEIAgFtjVNZ2QKtx
xyrT/5b+e90ga1elPrNzzvdbPM3PPBuRGoLJjgv3wnDHDtqVvIEyU48RhD+EHG/1
d8AsBgBIz12s/gbKuZbrINfIvMhIhNM5VZjfstid4hk58gycKfNV4rZgptpoXUQ9
w4tmfDgmMuDIKrQYw+LuDB3kbY75RasmaQxNOLs4/40G6SBQ6stPyWjzMCUvWZk8
9HjxbP3MvryoCb4YCMW8MwzJCiAtetB9on0d+KAh5t2fyCVXkN1fqyAW55uQVvuf
vDRShBfX19dGiy5cFW6f9/aXpYWmtktLT+5Kreh1lGlb+2hX7qvom/ND1MFMqwXz
nNs4P2kvOHx9xnZMxcHCJZhyvIOn8Wwv4FpX1t/Hw0YBW1LE7wUnsoL0rI7p2CCY
1EJwQ61F4K02b12xTcbGM8LfxrsZ959eCe7XxmNUWWPQsqR1gLJxF92vTZdpO3VZ
bEZKDQZmnAvT3BbgelYABlCl9QqTs9/9wk0IEU59D/fWsLKx9/Q5b1C5czMXyzmd
OjGA3G4ADT2IE2GeGrL3W45wldcgTL5mxv6kCAnFsKJy2LmyOPYgFunixz8gTkOa
G0FwAP3q71D8JPQIuYZ8k9RPiKthSxfNHaDytLzjmJwzPpikP4oo1LCuDOYlst86
ZSTIcEk2w19J66sSOhXFs5ByHhpna1/o12cHBE+p/Xv7CM6rQq54ASk8Ss+lWCuf
JuHck+67YOVesC4moelTlOa0yK2mll24rqSVo4D/uXkEz7VxSY+cdKFUy9KRboxl
BU2u8npv3n6s0A0R8bOWFo3/3A64NEJ+zn+gXUGsHLD9QVTrQfUwNxqhlvVi+SOQ
TYQKbGX5aGNLDuieqY9LAkSYTDMZ8HpJyMZXXPcjk8f6+U91shJrKpw=
-----END ENCRYPTED PRIVATE KEY-----
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqcRbql9j/cn0Wb3dOb9R
jpgNkp1+g3ZBridR3hxCAUMB1jPrZgwL4vt4/Tr8lNrstDxMSk0KRodtD2SnergH
MA1eDJBGhKJhIyxATrj3KhtpCw8SO3otq4JlT3gJWUTpenfEnQ9sCuJiK3muRLy5
EDTidwPG6RHca4t4hng62P8mjE3wZUMbFJ8Nf73y4kKi5BLmpk3VZ3/FZGb9o+6C
COXjRdeV1Ubhyd+Kxz3LQFcgVSzlqry3K8EaG0b82jyG6r9wJ/RGJJbJ67+q4jdD
+TFBcH0FaoO8KPM+CrPpJYvYJtYTz6HoCFbDHdGMOZnwKeF9oeIoW8sz+a6Pg0k5
hQIDAQAB
-----END PUBLIC KEY-----
@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Api;
use App\Entity\Album;
use App\Entity\AlbumTrack;
use App\Entity\Review;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class ApiHardeningFunctionalTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
self::ensureKernelShutdown();
$this->client = static::createClient();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->resetDatabase();
}
public function testReviewCreateWithoutJwtReturns401Envelope(): void
{
$this->client->request(
'POST',
'/api/v1/reviews',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'album_id' => 'missing-album',
'title' => 'Sample',
'content' => str_repeat('A', 30),
'rating' => 8,
], JSON_THROW_ON_ERROR)
);
$this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'authentication_required');
}
public function testReviewCreateWithInvalidJwtReturns401Envelope(): void
{
$this->client->request(
'POST',
'/api/v1/reviews',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer definitely-not-a-valid-jwt',
],
content: json_encode([
'album_id' => 'missing-album',
'title' => 'Sample',
'content' => str_repeat('A', 30),
'rating' => 8,
], JSON_THROW_ON_ERROR)
);
$this->assertErrorEnvelope(Response::HTTP_UNAUTHORIZED, 'invalid_token');
}
public function testReviewCreateWithInvalidJsonReturns400Envelope(): void
{
$user = $this->createUser('api-invalid-json@example.test');
$album = $this->createAlbum('album_invalid_json');
$token = $this->issueToken($user);
$this->client->request(
'POST',
'/api/v1/reviews',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
content: sprintf('{"album_id":"%s","title":"oops"', $album->getExternalId())
);
$this->assertErrorEnvelope(Response::HTTP_BAD_REQUEST, ['invalid_json', 'invalid_request']);
}
public function testReviewCreateValidationReturns422EnvelopeWithFieldDetails(): void
{
$user = $this->createUser('api-validation@example.test');
$album = $this->createAlbum('album_validation');
$token = $this->issueToken($user);
$this->client->request(
'POST',
'/api/v1/reviews',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
content: json_encode([
'album_id' => $album->getExternalId(),
'title' => '',
'content' => 'too short',
'rating' => 42,
], JSON_THROW_ON_ERROR)
);
$payload = $this->assertErrorEnvelope(Response::HTTP_UNPROCESSABLE_ENTITY, 'validation_error');
self::assertIsArray($payload['error']['details'] ?? null);
self::assertIsArray($payload['error']['details']['fields'] ?? null);
self::assertArrayHasKey('title', $payload['error']['details']['fields']);
self::assertArrayHasKey('content', $payload['error']['details']['fields']);
self::assertArrayHasKey('rating', $payload['error']['details']['fields']);
}
public function testReviewUpdateByDifferentUserReturns403Envelope(): void
{
$owner = $this->createUser('review-owner@example.test');
$intruder = $this->createUser('review-intruder@example.test');
$album = $this->createAlbum('album_forbidden');
$review = $this->createReview($album, $owner);
$token = $this->issueToken($intruder);
$this->client->request(
'PATCH',
'/api/v1/reviews/' . (string) $review->getId(),
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
],
content: json_encode([
'title' => 'Unauthorized update attempt',
], JSON_THROW_ON_ERROR)
);
$this->assertErrorEnvelope(Response::HTTP_FORBIDDEN, 'forbidden');
}
public function testAlbumReviewsUnknownAlbumReturns404Envelope(): void
{
$this->client->request('GET', '/api/v1/albums/not-a-real-album-id/reviews');
$this->assertErrorEnvelope(Response::HTTP_NOT_FOUND, 'album_not_found');
}
public function testUnsupportedMethodOnApiIndexReturns405Envelope(): void
{
$this->client->request('POST', '/api/v1');
$this->assertErrorEnvelope(Response::HTTP_METHOD_NOT_ALLOWED, 'method_not_allowed');
}
public function testReviewIndexDoesNotExposeAuthorEmail(): void
{
$author = $this->createUser('pii-check@example.test');
$album = $this->createAlbum('album_pii');
$this->createReview($album, $author);
$this->client->request('GET', '/api/v1/reviews?limit=1');
$response = $this->client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($payload);
self::assertIsArray($payload['data'] ?? null);
self::assertNotEmpty($payload['data']);
$first = $payload['data'][0] ?? null;
self::assertIsArray($first);
$authorPayload = $first['author'] ?? null;
self::assertIsArray($authorPayload);
self::assertArrayHasKey('id', $authorPayload);
self::assertArrayHasKey('display_name', $authorPayload);
self::assertArrayNotHasKey('email', $authorPayload);
}
public function testApiIndexExposesHypermediaLinksAndActions(): void
{
$this->client->request('GET', '/api/v1');
$response = $this->client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($payload);
self::assertIsArray($payload['links'] ?? null);
self::assertSame('/api/v1', $payload['links']['self'] ?? null);
self::assertSame('/api/v1/albums', $payload['links']['albums'] ?? null);
self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null);
self::assertSame('/api/docs', $payload['links']['docs'] ?? null);
self::assertIsArray($payload['actions'] ?? null);
self::assertSame('/api/v1/auth/login', $payload['actions']['login']['href'] ?? null);
self::assertSame('POST', $payload['actions']['login']['method'] ?? null);
}
public function testAlbumShowIncludesHypermediaLinksAndActions(): void
{
$album = $this->createAlbum('album_hypermedia');
$this->client->request('GET', '/api/v1/albums/' . $album->getExternalId());
$response = $this->client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($payload);
self::assertIsArray($payload['data'] ?? null);
self::assertSame('/api/v1/albums/album_hypermedia', $payload['data']['links']['self'] ?? null);
self::assertSame('/api/v1/albums/album_hypermedia/reviews', $payload['data']['links']['reviews'] ?? null);
self::assertSame('https://example.test/albums/album_hypermedia', $payload['data']['links']['open_source'] ?? null);
self::assertSame('/api/v1/reviews', $payload['data']['actions']['create_review']['href'] ?? null);
self::assertSame('/api/v1/albums/ingest', $payload['data']['actions']['ingest']['href'] ?? null);
}
public function testAlbumReviewsEndpointIncludesHypermedia(): void
{
$author = $this->createUser('album-reviews-hypermedia@example.test');
$album = $this->createAlbum('album_reviews_hypermedia');
$review = $this->createReview($album, $author);
$this->client->request('GET', '/api/v1/albums/' . $album->getExternalId() . '/reviews');
$response = $this->client->getResponse();
self::assertSame(Response::HTTP_OK, $response->getStatusCode(), $response->getContent());
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($payload);
self::assertSame('/api/v1/albums/album_reviews_hypermedia/reviews', $payload['links']['self'] ?? null);
self::assertSame('/api/v1/reviews', $payload['links']['reviews'] ?? null);
self::assertSame('/api/v1/reviews', $payload['actions']['create_review']['href'] ?? null);
self::assertSame('POST', $payload['actions']['create_review']['method'] ?? null);
self::assertIsArray($payload['data'] ?? null);
self::assertNotEmpty($payload['data']);
$first = $payload['data'][0] ?? null;
self::assertIsArray($first);
self::assertSame($review->getId(), $first['id'] ?? null);
self::assertSame('/api/v1/reviews/' . $review->getId(), $first['links']['self'] ?? null);
self::assertSame('/api/v1/albums/album_reviews_hypermedia', $first['links']['album'] ?? null);
}
private function resetDatabase(): void
{
$allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
if ($allMetadata === []) {
return;
}
$schemaTool = new SchemaTool($this->entityManager);
$schemaTool->dropDatabase();
$schemaTool->createSchema($allMetadata);
$this->entityManager->clear();
}
private function createUser(string $email, string $plainPassword = 'password123'): User
{
/** @var UserPasswordHasherInterface $passwordHasher */
$passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setEmail($email);
$user->setDisplayName('Test User');
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
private function createAlbum(string $externalId): Album
{
$album = new Album();
$album->setSource('external');
$album->setExternalId($externalId);
$album->setName('Functional Test Album');
$album->setArtists(['Functional Test Artist']);
$album->setGenres(['Rock']);
$album->setReleaseDate('2020-01-01');
$album->setTotalTracks(1);
$album->setExternalUrl('https://example.test/albums/' . $externalId);
$track = new AlbumTrack();
$track->setDiscNumber(1);
$track->setTrackNumber(1);
$track->setName('Functional Test Track');
$track->setDurationMs(210000);
$album->addTrack($track);
$this->entityManager->persist($album);
$this->entityManager->flush();
return $album;
}
private function createReview(Album $album, User $author): Review
{
$review = new Review();
$review->setAlbum($album);
$review->setAuthor($author);
$review->setTitle('Excellent reference review');
$review->setContent('This review content is comfortably longer than twenty characters.');
$review->setRating(8);
$this->entityManager->persist($review);
$this->entityManager->flush();
return $review;
}
private function issueToken(User $user): string
{
/** @var JWTTokenManagerInterface $jwtTokenManager */
$jwtTokenManager = static::getContainer()->get(JWTTokenManagerInterface::class);
return $jwtTokenManager->create($user);
}
/**
* @return array<string,mixed>
*/
private function assertErrorEnvelope(int $expectedStatus, string|array $expectedCode): array
{
$response = $this->client->getResponse();
self::assertSame($expectedStatus, $response->getStatusCode(), $response->getContent());
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($payload);
self::assertIsArray($payload['error'] ?? null);
if (is_array($expectedCode)) {
self::assertContains($payload['error']['code'] ?? null, $expectedCode);
} else {
self::assertSame($expectedCode, $payload['error']['code'] ?? null);
}
self::assertSame($expectedStatus, $payload['error']['status'] ?? null);
self::assertIsString($payload['error']['message'] ?? null);
return $payload;
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Web;
use App\Entity\Album;
use App\Entity\AlbumTrack;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
final class AlbumWorkflowRegressionTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
private string $projectDir;
protected function setUp(): void
{
self::ensureKernelShutdown();
$this->client = static::createClient();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->projectDir = (string) static::getContainer()->getParameter('kernel.project_dir');
$this->resetDatabase();
$this->ensureUploadDirectories();
}
public function testAlbumEditAndDeleteRoutesWorkForUserAlbum(): void
{
$user = $this->createUser('album-owner@example.test');
$album = $this->createUserAlbum($user, 'u_album_route_fix');
$this->client->loginUser($user);
$crawler = $this->client->request('GET', '/albums/' . $album->getExternalId());
self::assertResponseStatusCodeSame(200);
$this->client->request('GET', '/albums/' . $album->getExternalId() . '/edit');
self::assertResponseStatusCodeSame(200);
$token = $crawler->filter('form[action$="/albums/' . $album->getExternalId() . '/delete"] input[name="_token"]')->attr('value');
self::assertIsString($token);
$this->client->request('POST', '/albums/' . $album->getExternalId() . '/delete', ['_token' => $token]);
self::assertResponseStatusCodeSame(302);
$deleted = $this->entityManager->getRepository(Album::class)->findOneBy(['externalId' => $album->getExternalId()]);
self::assertNull($deleted);
}
public function testCreateAlbumWithCoverUploadDoesNotErrorAndPersistsCoverPath(): void
{
$user = $this->createUser('cover-upload@example.test');
$this->client->loginUser($user);
$crawler = $this->client->request('GET', '/albums/new');
self::assertResponseStatusCodeSame(200);
$formToken = $crawler->filter('input[name="album[_token]"]')->attr('value');
self::assertIsString($formToken);
$tmpFile = tempnam(sys_get_temp_dir(), 'cover_');
self::assertIsString($tmpFile);
file_put_contents($tmpFile, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAoMBgQwM0R8AAAAASUVORK5CYII=', true));
$cover = new UploadedFile($tmpFile, 'cover.png', 'image/png', null, true);
$this->client->request(
'POST',
'/albums/new',
[
'album' => [
'_token' => $formToken,
'name' => 'Cover Upload Regression',
'artistsCsv' => 'Cover Artist',
'genresCsv' => 'Rock',
'releaseDate' => '2024-01-01',
'totalTracks' => '1',
'externalUrl' => 'https://example.test/cover-source',
],
'track_title' => ['Track One'],
'track_duration' => ['3:21'],
'track_disc' => ['1'],
'track_number' => ['1'],
],
[
'album' => [
'coverUpload' => $cover,
],
]
);
self::assertResponseStatusCodeSame(302);
$album = $this->entityManager->getRepository(Album::class)->findOneBy(['name' => 'Cover Upload Regression']);
self::assertInstanceOf(Album::class, $album);
self::assertNotNull($album->getCoverImagePath());
self::assertStringStartsWith('/uploads/album_covers/', (string) $album->getCoverImagePath());
self::assertFileExists($this->projectDir . '/public' . $album->getCoverImagePath());
}
public function testDashboardPagesLinkToUserAlbumByExternalId(): void
{
$owner = $this->createUser('dashboard-owner@example.test');
$moderator = $this->createUser('dashboard-moderator@example.test', ['ROLE_MODERATOR']);
$album = $this->createUserAlbum($owner, 'u_dashboard_link');
$this->client->loginUser($owner);
$this->client->request('GET', '/dashboard');
self::assertResponseStatusCodeSame(200);
self::assertStringContainsString('/albums/' . $album->getExternalId(), (string) $this->client->getResponse()->getContent());
$this->client->loginUser($moderator);
$this->client->request('GET', '/admin/dashboard');
self::assertResponseStatusCodeSame(200);
self::assertStringContainsString('/albums/' . $album->getExternalId(), (string) $this->client->getResponse()->getContent());
}
private function ensureUploadDirectories(): void
{
$base = $this->projectDir . '/public/uploads';
@mkdir($base . '/album_covers', 0777, true);
@mkdir($base . '/avatars', 0777, true);
}
private function resetDatabase(): void
{
$allMetadata = $this->entityManager->getMetadataFactory()->getAllMetadata();
if ($allMetadata === []) {
return;
}
$schemaTool = new SchemaTool($this->entityManager);
$schemaTool->dropDatabase();
$schemaTool->createSchema($allMetadata);
$this->entityManager->clear();
}
/**
* @param list<string> $roles
*/
private function createUser(string $email, array $roles = []): User
{
/** @var UserPasswordHasherInterface $passwordHasher */
$passwordHasher = static::getContainer()->get(UserPasswordHasherInterface::class);
$user = new User();
$user->setEmail($email);
$user->setDisplayName('Regression User');
$user->setRoles($roles);
$user->setPassword($passwordHasher->hashPassword($user, 'password123'));
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
private function createUserAlbum(User $owner, string $externalId): Album
{
$album = new Album();
$album->setSource('user');
$album->setExternalId($externalId);
$album->setName('User Album ' . $externalId);
$album->setArtists(['Regression Artist']);
$album->setGenres(['Rock']);
$album->setReleaseDate('2024-01-01');
$album->setTotalTracks(1);
$album->setOwner($owner);
$track = new AlbumTrack();
$track->setDiscNumber(1);
$track->setTrackNumber(1);
$track->setName('Regression Track');
$track->setDurationMs(180000);
$album->addTrack($track);
$this->entityManager->persist($album);
$this->entityManager->flush();
return $album;
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\ExternalAlbumClient;
use App\Service\ExternalProviderManager;
use App\Service\LastFmClient;
use App\Service\MusicBrainzClient;
use App\Service\TidalClient;
use PHPUnit\Framework\TestCase;
final class ExternalAlbumClientTest extends TestCase
{
public function testUnknownProviderFallsBackToLastFm(): void
{
$lastFm = $this->createMock(LastFmClient::class);
$musicBrainz = $this->createMock(MusicBrainzClient::class);
$tidal = $this->createMock(TidalClient::class);
$providerManager = $this->createMock(ExternalProviderManager::class);
$providerManager->expects(self::atLeastOnce())
->method('getEffectiveProvider')
->willReturn('unknown-provider');
$lastFm->expects(self::once())
->method('searchAlbums')
->with('radiohead', 5, 1)
->willReturn(['albums' => ['items' => []]]);
$lastFm->expects(self::once())
->method('getProviderName')
->willReturn('lastfm');
$musicBrainz->expects(self::never())->method('searchAlbums');
$tidal->expects(self::never())->method('searchAlbums');
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
$result = $client->searchAlbums('radiohead', 5, 1);
self::assertSame(['albums' => ['items' => []]], $result);
self::assertSame('lastfm', $client->getProviderName());
}
public function testMusicBrainzProviderRoutesCallsToMusicBrainzClient(): void
{
$lastFm = $this->createMock(LastFmClient::class);
$musicBrainz = $this->createMock(MusicBrainzClient::class);
$tidal = $this->createMock(TidalClient::class);
$providerManager = $this->createMock(ExternalProviderManager::class);
$providerManager->expects(self::atLeastOnce())
->method('getEffectiveProvider')
->willReturn('musicbrainz');
$musicBrainz->expects(self::once())
->method('searchAlbums')
->with('tool', 3, 2)
->willReturn([
'albums' => [
'items' => [
['id' => 'mbid:abc', 'name' => 'Lateralus'],
],
],
]);
$musicBrainz->expects(self::once())
->method('getProviderName')
->willReturn('musicbrainz');
$lastFm->expects(self::never())->method('searchAlbums');
$tidal->expects(self::never())->method('searchAlbums');
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
$result = $client->searchAlbums('tool', 3, 2);
self::assertSame('mbid:abc', $result['albums']['items'][0]['id'] ?? null);
self::assertSame('musicbrainz', $client->getProviderName());
}
public function testTidalProviderRoutesCallsToTidalClient(): void
{
$lastFm = $this->createMock(LastFmClient::class);
$musicBrainz = $this->createMock(MusicBrainzClient::class);
$tidal = $this->createMock(TidalClient::class);
$providerManager = $this->createMock(ExternalProviderManager::class);
$providerManager->expects(self::atLeastOnce())
->method('getEffectiveProvider')
->willReturn('tidal');
$tidal->expects(self::once())
->method('searchAlbums')
->with('kanye', 4, 1)
->willReturn([
'albums' => [
'items' => [
['id' => 'tidal:123', 'name' => 'The College Dropout'],
],
],
]);
$tidal->expects(self::once())
->method('getProviderName')
->willReturn('tidal');
$lastFm->expects(self::never())->method('searchAlbums');
$musicBrainz->expects(self::never())->method('searchAlbums');
$client = new ExternalAlbumClient($lastFm, $musicBrainz, $tidal, $providerManager);
$result = $client->searchAlbums('kanye', 4, 1);
self::assertSame('tidal:123', $result['albums']['items'][0]['id'] ?? null);
self::assertSame('tidal', $client->getProviderName());
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Repository\SettingRepository;
use App\Service\MusicBrainzClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class MusicBrainzClientTest extends TestCase
{
public function testDetailedAlbumUsesCanonicalMusicBrainzUrlForOpenSourceLink(): void
{
$releaseId = '78eb2077-b86d-4382-9ce7-38f9260316f0';
$httpClient = $this->createMock(ClientInterface::class);
$httpClient->expects(self::once())
->method('request')
->with(
'GET',
'https://musicbrainz.org/ws/2/release/' . $releaseId,
self::callback(static function (array $options): bool {
$query = $options['query'] ?? [];
return ($query['fmt'] ?? null) === 'json';
})
)
->willReturn(new Response(200, ['Content-Type' => 'application/json'], json_encode([
'id' => $releaseId,
'title' => 'Reference Album',
'artist-credit' => [
['name' => 'Reference Artist'],
],
'media' => [[
'position' => 1,
'tracks' => [[
'id' => 'track-1',
'number' => '1',
'title' => 'Reference Track',
'length' => 180000,
]],
]],
// External storefront URL should not be used as the "Open Source" link.
'relations' => [[
'url' => ['resource' => 'https://store.example.test/reference-album'],
]],
], JSON_THROW_ON_ERROR)));
$settings = $this->createMock(SettingRepository::class);
$cache = new ArrayAdapter();
$client = new MusicBrainzClient($httpClient, $cache, $settings);
$album = $client->getAlbumWithTracks('mbid:' . $releaseId);
self::assertIsArray($album);
self::assertSame(
'https://musicbrainz.org/release/' . $releaseId,
$album['external_urls']['external'] ?? null
);
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Repository\SettingRepository;
use App\Service\TidalClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class TidalClientTest extends TestCase
{
public function testIsConfiguredRequiresClientIdAndSecret(): void
{
$http = $this->createMock(ClientInterface::class);
$settings = $this->createMock(SettingRepository::class);
$cache = new ArrayAdapter();
$client = new TidalClient($http, $cache, $settings, 'client-id-only', null, 'US');
self::assertFalse($client->isConfigured());
}
public function testSearchAlbumsUsesClientCredentialsAndNormalizesPayload(): void
{
$http = $this->createMock(ClientInterface::class);
$settings = $this->createMock(SettingRepository::class);
$cache = new ArrayAdapter();
$call = 0;
$http->expects(self::exactly(2))
->method('request')
->willReturnCallback(function (string $method, string $uri, array $options) use (&$call) {
$call++;
if ($call === 1) {
self::assertSame('POST', $method);
self::assertSame('https://auth.tidal.com/v1/oauth2/token', $uri);
self::assertSame('client_credentials', $options['form_params']['grant_type'] ?? null);
self::assertSame('tidal-client-id', $options['form_params']['client_id'] ?? null);
self::assertSame('tidal-client-secret', $options['form_params']['client_secret'] ?? null);
return new Response(200, ['Content-Type' => 'application/json'], json_encode([
'access_token' => 'token-abc',
'expires_in' => 3600,
'token_type' => 'Bearer',
], JSON_THROW_ON_ERROR));
}
self::assertSame('GET', $method);
self::assertStringContainsString('/searchResults/kanye%20west/relationships/albums', $uri);
self::assertSame('US', $options['query']['countryCode'] ?? null);
self::assertSame('albums', $options['query']['include'] ?? null);
self::assertSame('Bearer token-abc', $options['headers']['Authorization'] ?? null);
return new Response(200, ['Content-Type' => 'application/vnd.api+json'], json_encode([
'data' => [
['type' => 'albums', 'id' => '251380836'],
],
'included' => [
[
'type' => 'albums',
'id' => '251380836',
'attributes' => [
'title' => '4:44',
'releaseDate' => '2017-06-30',
'numberOfItems' => 10,
'externalLinks' => [
[
'href' => 'https://tidal.com/browse/album/251380836',
'meta' => ['type' => 'TIDAL_SHARING'],
],
],
],
'relationships' => [
'artists' => [
'data' => [
['type' => 'artists', 'id' => 'artist-1'],
],
],
'genres' => [
'data' => [
['type' => 'genres', 'id' => 'genre-1'],
],
],
'coverArt' => [
'data' => [
['type' => 'artworks', 'id' => 'art-1'],
],
],
],
],
[
'type' => 'artists',
'id' => 'artist-1',
'attributes' => ['name' => 'JAY-Z'],
],
[
'type' => 'genres',
'id' => 'genre-1',
'attributes' => ['genreName' => 'hip hop'],
],
[
'type' => 'artworks',
'id' => 'art-1',
'attributes' => [
'files' => [
['href' => 'https://cdn.example.test/160.jpg', 'meta' => ['width' => 160, 'height' => 160]],
['href' => 'https://cdn.example.test/320.jpg', 'meta' => ['width' => 320, 'height' => 320]],
],
],
],
],
'links' => ['self' => '/searchResults/kanye west/relationships/albums'],
], JSON_THROW_ON_ERROR));
});
$client = new TidalClient($http, $cache, $settings, 'tidal-client-id', 'tidal-client-secret', 'US');
$result = $client->searchAlbums('kanye west', 5, 1);
$items = $result['albums']['items'] ?? [];
self::assertCount(1, $items);
self::assertSame('tidal:251380836', $items[0]['id'] ?? null);
self::assertSame('4:44', $items[0]['name'] ?? null);
self::assertSame('JAY-Z', $items[0]['artists'][0]['name'] ?? null);
self::assertSame('Hip Hop', $items[0]['genres'][0] ?? null);
self::assertSame('2017-06-30', $items[0]['release_date'] ?? null);
self::assertSame(10, $items[0]['total_tracks'] ?? null);
self::assertSame('https://cdn.example.test/320.jpg', $items[0]['images'][0]['url'] ?? null);
self::assertSame('https://tidal.com/browse/album/251380836', $items[0]['external_urls']['external'] ?? null);
}
public function testGetAlbumWithTracksNormalizesTracksAndCoverArt(): void
{
$http = $this->createMock(ClientInterface::class);
$settings = $this->createMock(SettingRepository::class);
$cache = new ArrayAdapter();
$call = 0;
$http->expects(self::exactly(2))
->method('request')
->willReturnCallback(function (string $method, string $uri, array $options) use (&$call) {
$call++;
if ($call === 1) {
return new Response(200, ['Content-Type' => 'application/json'], json_encode([
'access_token' => 'token-xyz',
'expires_in' => 3600,
'token_type' => 'Bearer',
], JSON_THROW_ON_ERROR));
}
self::assertSame('GET', $method);
self::assertStringContainsString('/albums/251380836', $uri);
self::assertSame('artists,coverArt,genres,items', $options['query']['include'] ?? null);
self::assertSame('US', $options['query']['countryCode'] ?? null);
return new Response(200, ['Content-Type' => 'application/vnd.api+json'], json_encode([
'data' => [
'type' => 'albums',
'id' => '251380836',
'attributes' => [
'title' => '4:44',
'releaseDate' => '2017-06-30',
'numberOfItems' => 2,
'externalLinks' => [
[
'href' => 'https://tidal.com/browse/album/251380836',
'meta' => ['type' => 'TIDAL_SHARING'],
],
],
],
'relationships' => [
'artists' => [
'data' => [
['type' => 'artists', 'id' => 'artist-1'],
],
],
'genres' => [
'data' => [
['type' => 'genres', 'id' => 'genre-1'],
],
],
'coverArt' => [
'data' => [
['type' => 'artworks', 'id' => 'art-1'],
],
],
'items' => [
'data' => [
['type' => 'tracks', 'id' => 'track-1', 'meta' => ['volumeNumber' => 1, 'trackNumber' => 1]],
['type' => 'tracks', 'id' => 'track-2', 'meta' => ['volumeNumber' => 1, 'trackNumber' => 2]],
],
],
],
],
'included' => [
['type' => 'artists', 'id' => 'artist-1', 'attributes' => ['name' => 'JAY-Z']],
['type' => 'genres', 'id' => 'genre-1', 'attributes' => ['genreName' => 'hip hop']],
[
'type' => 'artworks',
'id' => 'art-1',
'attributes' => [
'files' => [
['href' => 'https://cdn.example.test/640.jpg', 'meta' => ['width' => 640, 'height' => 640]],
],
],
],
['type' => 'tracks', 'id' => 'track-1', 'attributes' => ['title' => 'Kill Jay Z', 'duration' => 'PT3M25S']],
['type' => 'tracks', 'id' => 'track-2', 'attributes' => ['title' => 'The Story of O.J.', 'duration' => 'PT3M52S']],
],
'links' => ['self' => '/albums/251380836'],
], JSON_THROW_ON_ERROR));
});
$client = new TidalClient($http, $cache, $settings, 'tidal-client-id', 'tidal-client-secret', 'US');
$album = $client->getAlbumWithTracks('tidal:251380836');
self::assertIsArray($album);
self::assertSame('tidal:251380836', $album['id'] ?? null);
self::assertSame('4:44', $album['name'] ?? null);
self::assertSame('JAY-Z', $album['artists'][0]['name'] ?? null);
self::assertSame('Hip Hop', $album['genres'][0] ?? null);
self::assertSame('2017-06-30', $album['release_date'] ?? null);
self::assertSame('https://cdn.example.test/640.jpg', $album['images'][0]['url'] ?? null);
self::assertSame(2, $album['total_tracks'] ?? null);
self::assertSame(2, $album['tracks']['total'] ?? null);
self::assertSame('Kill Jay Z', $album['tracks']['items'][0]['name'] ?? null);
self::assertSame(205000, $album['tracks']['items'][0]['duration_ms'] ?? null);
self::assertSame(1, $album['tracks']['items'][0]['track_number'] ?? null);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__) . '/vendor/autoload.php';
if (class_exists(Dotenv::class)) {
(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');
}
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'test';
$_ENV['APP_DEBUG'] = $_SERVER['APP_DEBUG'] = '1';
$_ENV['APP_SECRET'] = $_SERVER['APP_SECRET'] = 'test-secret';
$_ENV['KERNEL_CLASS'] = $_SERVER['KERNEL_CLASS'] = 'App\\Kernel';
$_ENV['DATABASE_DRIVER'] = $_SERVER['DATABASE_DRIVER'] = 'sqlite';
$_ENV['DATABASE_SQLITE_PATH'] = $_SERVER['DATABASE_SQLITE_PATH'] = '/tmp/tonehaus_test.sqlite';
$_ENV['JWT_PASSPHRASE'] = $_SERVER['JWT_PASSPHRASE'] = 'test';
putenv('APP_ENV=test');
putenv('APP_DEBUG=1');
putenv('APP_SECRET=test-secret');
putenv('KERNEL_CLASS=App\\Kernel');
putenv('DATABASE_DRIVER=sqlite');
putenv('DATABASE_SQLITE_PATH=/tmp/tonehaus_test.sqlite');
putenv('JWT_PASSPHRASE=test');