what the fuck
This commit is contained in:
1 parent
c1b1c2f372
commit
72a99a5bcd
69 files changed
+7519
-558
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user