diff --git a/app/Helpers/StreamingServicePlaylistDto.php b/app/Helpers/StreamingServicePlaylistDto.php new file mode 100644 index 0000000..3213bbe --- /dev/null +++ b/app/Helpers/StreamingServicePlaylistDto.php @@ -0,0 +1,25 @@ +id = Arr::get($params, 'id'); + $this->name = Arr::get($params, 'name'); + $this->tracks = Arr::get($params, 'tracks'); + $this->owner = Arr::get($params, 'owner'); + $this->number_of_tracks = Arr::get($params, 'number_of_tracks'); + $this->image_uri = Arr::get($params, 'image_uri'); + } +} diff --git a/app/Http/Controllers/TriggerPlaylistTransferController.php b/app/Http/Controllers/TriggerPlaylistTransferController.php index f2fdb03..9672fe9 100644 --- a/app/Http/Controllers/TriggerPlaylistTransferController.php +++ b/app/Http/Controllers/TriggerPlaylistTransferController.php @@ -27,10 +27,21 @@ public function __invoke(Request $request): JsonResponse $playlistTransfer = $user->playlistTransfers()->create([ 'source' => $source, 'destination' => $destination, - 'playlists' => $playlists, 'status' => PlaylistTransfer::STATUS_PENDING, ]); + collect($playlists) + ->each(function ($playlist) use ($playlistTransfer, $source, $user) { + $playlist = $user->playlists()->updateOrCreate([ + 'remote_id' => $playlist['id'], + 'service' => $source, + ], [ + 'name' => $playlist['name'], + ]); + + $playlistTransfer->playlists()->save($playlist); + }); + PlaylistTransferJob::dispatch($playlistTransfer); return response()->json([ diff --git a/app/Jobs/CreateAndSearchForTracksJob.php b/app/Jobs/CreateAndSearchForTracksJob.php index 0111657..d1f63b4 100644 --- a/app/Jobs/CreateAndSearchForTracksJob.php +++ b/app/Jobs/CreateAndSearchForTracksJob.php @@ -99,10 +99,13 @@ public function updateOrCreateTrack(TrackDto $track, array $remoteIds, array|nul $trackModel->remote_ids, $remoteIds, ); - $trackModel->isrc_ids = array_merge( - $trackModel->isrc_ids, - $isrc + $trackModel->isrc_ids = array_unique( + array_merge( + $trackModel->isrc_ids, + $isrc + ) ); + $trackModel->save(); return $trackModel; diff --git a/app/Jobs/CreatePlaylistAndDispatchTracksJob.php b/app/Jobs/CreatePlaylistAndDispatchTracksJob.php index f1f67b4..7bd6504 100644 --- a/app/Jobs/CreatePlaylistAndDispatchTracksJob.php +++ b/app/Jobs/CreatePlaylistAndDispatchTracksJob.php @@ -20,7 +20,7 @@ class CreatePlaylistAndDispatchTracksJob implements ShouldQueue public function __construct( private readonly PlaylistTransfer $playlistTransfer, - private readonly array $playlist + private readonly Playlist $playlist ) { } @@ -31,14 +31,6 @@ public function handle(): void $source = $this->playlistTransfer->sourceApi(); $destination = $this->playlistTransfer->destinationApi(); - $playlistModel = Playlist::query()->firstOrCreate([ - 'service' => $source::PROVIDER, - 'remote_id' => $this->playlist['id'], - ], [ - 'user_id' => $this->playlistTransfer->user_id, - 'name' => $this->playlist['name'], - ]); - $tracks = $source->getPlaylistTracks($this->playlist['id']); $destinationPlaylistId = $destination->createPlaylist($this->playlist['name']); @@ -58,14 +50,14 @@ public function handle(): void ...collect($tracks) ->map(fn($t) => new CreateAndSearchForTracksJob( $this->playlistTransfer, - $playlistModel, + $this->playlist, $t, )) ->toArray(), new PopulatePlaylistWithTracksJob( $this->playlistTransfer, $destinationPlaylistId, - $playlistModel + $this->playlist ), new IncrementPlaylistsProcessedJob($this->playlistTransfer), ] diff --git a/app/Jobs/PlaylistTransferJob.php b/app/Jobs/PlaylistTransferJob.php index 238e02e..6fc032f 100644 --- a/app/Jobs/PlaylistTransferJob.php +++ b/app/Jobs/PlaylistTransferJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Models\Playlist; use App\Models\PlaylistTransfer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -30,8 +31,10 @@ public function handle(): void Bus::chain( collect($this->playlistTransfer->playlists) - ->map(fn($pt) => new CreatePlaylistAndDispatchTracksJob($this->playlistTransfer, $pt)) - ->toArray(), + ->map(fn(Playlist $p) => new CreatePlaylistAndDispatchTracksJob( + $this->playlistTransfer, + $p + ))->toArray(), )->catch(function (Throwable $throwable) { Log::error( "A failure occurred with a playlist transfer ", diff --git a/app/Models/PlaylistTransfer.php b/app/Models/PlaylistTransfer.php index 547a632..25b2011 100644 --- a/app/Models/PlaylistTransfer.php +++ b/app/Models/PlaylistTransfer.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class PlaylistTransfer extends Model { @@ -15,7 +16,7 @@ class PlaylistTransfer extends Model protected $guarded = []; - protected $casts = ['playlists' => 'json']; + protected $with = ['playlists']; public const string STATUS_PENDING = 'pending'; public const string STATUS_IN_PROGRESS = 'in_progress'; @@ -48,4 +49,14 @@ public function destinationApi(): StreamingService ->firstOrFail() ); } + + public function playlists(): BelongsToMany + { + return $this->belongsToMany( + Playlist::class, + 'playlist_transfer_playlists', + 'playlist_id', + 'playlist_transfer_id' + ); + } } diff --git a/app/Observers/PlaylistTransferObserver.php b/app/Observers/PlaylistTransferObserver.php index 5acddb1..15943d4 100644 --- a/app/Observers/PlaylistTransferObserver.php +++ b/app/Observers/PlaylistTransferObserver.php @@ -15,7 +15,7 @@ public function updated(PlaylistTransfer $playlistTransfer): void ) { if ( - $playlistTransfer->playlists_processed === count($playlistTransfer->playlists) + $playlistTransfer->playlists_processed === $playlistTransfer->playlists()->count() ) { // oh god no $playlistTransfer->status = PlaylistTransfer::STATUS_COMPLETED; diff --git a/database/factories/PlaylistTransferFactory.php b/database/factories/PlaylistTransferFactory.php index 9d06681..40bc971 100644 --- a/database/factories/PlaylistTransferFactory.php +++ b/database/factories/PlaylistTransferFactory.php @@ -11,7 +11,6 @@ public function definition(): array return [ 'source' => $this->faker->word, 'destination' => $this->faker->word, - 'playlists' => [['id' => $this->faker->uuid, 'name' => $this->faker->sentence]], 'status' => $this->faker->randomElement(['pending', 'completed', 'failed']), 'playlists_processed' => 0 ]; diff --git a/database/migrations/2025_06_20_175333_create_playlist_transfers_table.php b/database/migrations/2025_06_20_175333_create_playlist_transfers_table.php index 438a4ed..435b7b5 100644 --- a/database/migrations/2025_06_20_175333_create_playlist_transfers_table.php +++ b/database/migrations/2025_06_20_175333_create_playlist_transfers_table.php @@ -11,7 +11,6 @@ public function up(): void $table->uuid('id')->primary(); $table->string('source'); $table->string('destination'); - $table->json('playlists'); $table->integer('playlists_processed')->default(0); $table->enum('status', ['pending', 'in_progress', 'completed', 'failed']) ->default('pending'); diff --git a/database/migrations/2025_12_12_151235_create_playlist_transfer_playlists_table.php b/database/migrations/2025_12_12_151235_create_playlist_transfer_playlists_table.php new file mode 100644 index 0000000..94f5385 --- /dev/null +++ b/database/migrations/2025_12_12_151235_create_playlist_transfer_playlists_table.php @@ -0,0 +1,22 @@ +id(); + $table->uuid('playlist_id'); + $table->uuid('playlist_transfer_id'); + $table->unique(['playlist_id', 'playlist_transfer_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('playlist_transfer_playlists'); + } +}; diff --git a/tests/Feature/Controllers/TriggerPlaylistTransferControllerTest.php b/tests/Feature/Controllers/TriggerPlaylistTransferControllerTest.php index 74e0a30..3c62b98 100644 --- a/tests/Feature/Controllers/TriggerPlaylistTransferControllerTest.php +++ b/tests/Feature/Controllers/TriggerPlaylistTransferControllerTest.php @@ -3,10 +3,12 @@ namespace Tests\Feature\Controllers; use App\Jobs\PlaylistTransferJob; -use App\Models\OauthCredential; +use App\Models\Playlist; +use App\Models\PlaylistTransfer; use App\Services\SpotifyService; use App\Services\TidalService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Support\Facades\Bus; use PHPUnit\Framework\Attributes\DataProvider; use Tests\TestCase; @@ -14,33 +16,39 @@ class TriggerPlaylistTransferControllerTest extends TestCase { use RefreshDatabase; + use WithFaker; + + protected function setUp(): void + { + parent::setUp(); + + $this->playlistPayload = [ + 'id' => $this->faker->uuid(), + 'name' => $this->faker->word(), + 'tracks' => 'asdf', + 'owner' => [ + 'id' => $this->user()->getKey(), + 'name' => 'ronald mcdonanld', + ], + 'number_of_tracks' => 5, + 'image_uri' => $this->faker->url(), + ]; + } public function test_it_dispatches_playlist_transfer_job() { Bus::fake(); - OauthCredential::query()->create([ - 'user_id' => $this->user()->getKey(), - 'provider' => TidalService::PROVIDER, - 'provider_id' => TidalService::PROVIDER, - ]); - OauthCredential::query()->create([ - 'user_id' => $this->user()->getKey(), - 'provider' => SpotifyService::PROVIDER, - 'provider_id' => SpotifyService::PROVIDER, - ]); - $this->actingAs($this->user())->post('api/playlist-transfers/trigger', [ 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, - 'playlists' => ['playlist1', 'playlist2'], + 'playlists' => [$this->playlistPayload], ])->assertCreated()->assertJsonStructure([ 'message', 'data' => [ 'id', 'source', 'destination', - 'playlists', ], ]); @@ -55,6 +63,82 @@ public function test_it_rejects_with_incomplete_data($payload) ->assertUnprocessable(); } + public function test_it_creates_playlists_and_associates_them_with_playlist_transfer_model() + { + Bus::fake(); + + $this->assertDatabaseCount('playlists', 0); + $this->assertDatabaseCount('playlist_transfers', 0); + + $this->actingAs($this->user()) + ->post('api/playlist-transfers/trigger', [ + 'source' => SpotifyService::PROVIDER, + 'destination' => TidalService::PROVIDER, + 'playlists' => [$this->playlistPayload], + ])->assertCreated()->assertJsonStructure([ + 'message', + 'data' => [ + 'id', + 'source', + 'destination', + ], + ]); + + $this->assertDatabaseCount('playlists', 1); + $this->assertDatabaseCount('playlist_transfers', 1); + + $pt = PlaylistTransfer::query()->first(); + $this->assertEquals( + $pt->playlists()->first()->getKey(), + Playlist::query()->first()->getKey() + ); + } + + public function test_it_updates_existing_playlist_if_already_exists() + { + Bus::fake(); + + $this->assertDatabaseCount('playlists', 0); + $this->assertDatabaseCount('playlist_transfers', 0); + + $this->actingAs($this->user()) + ->post('api/playlist-transfers/trigger', [ + 'source' => SpotifyService::PROVIDER, + 'destination' => TidalService::PROVIDER, + 'playlists' => [ + $this->playlistPayload + ], + ])->assertCreated()->assertJsonStructure([ + 'message', + 'data' => [ + 'id', + 'source', + 'destination', + ], + ]); + + $this->assertDatabaseCount('playlists', 1); + $this->assertDatabaseCount('playlist_transfers', 1); + + $this->actingAs($this->user()) + ->post('api/playlist-transfers/trigger', [ + 'source' => SpotifyService::PROVIDER, + 'destination' => TidalService::PROVIDER, + 'playlists' => [ + $this->playlistPayload + ], + ])->assertCreated()->assertJsonStructure([ + 'message', + 'data' => [ + 'id', + 'source', + 'destination', + ], + ]); + + $this->assertDatabaseCount('playlists', 1); + } + public static function provideIncompletePayloads(): array { return [ diff --git a/tests/Feature/Models/PlaylistTransferTest.php b/tests/Feature/Models/PlaylistTransferTest.php new file mode 100644 index 0000000..7bcd7b9 --- /dev/null +++ b/tests/Feature/Models/PlaylistTransferTest.php @@ -0,0 +1,26 @@ +create(['user_id' => $this->user()->getKey()]); + + $playlist = Playlist::factory()->create(['user_id' => $this->user()->getKey()]); + $playlistTransfer->playlists()->save($playlist); + + $playlistTransfer = PlaylistTransfer::query()->first(); + $this->assertCount(1, $playlistTransfer->playlists); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f50cc22..b4a61e5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,6 +3,7 @@ namespace Tests; use App\Models\OauthCredential; +use App\Models\Playlist; use App\Models\User; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -29,4 +30,12 @@ public function user(): User return $this->user; } + + public function newPlaylist(array $props = []): Playlist + { + return Playlist::factory()->create([ + 'user_id' => $this->user()->getKey(), + ...$props, + ]); + } } diff --git a/tests/Unit/Jobs/CreatePlaylistAndDispatchTracksJobTest.php b/tests/Unit/Jobs/CreatePlaylistAndDispatchTracksJobTest.php index f687cc9..1c2b4b4 100644 --- a/tests/Unit/Jobs/CreatePlaylistAndDispatchTracksJobTest.php +++ b/tests/Unit/Jobs/CreatePlaylistAndDispatchTracksJobTest.php @@ -63,7 +63,7 @@ public function test_it_marks_playlist_as_processed_on_search_track_failure() new CreatePlaylistAndDispatchTracksJob( $playlistTransfer, - ['name' => 'snickers', 'id' => '123'] + $this->newPlaylist(), )->handle(); $playlistTransfer->refresh(); @@ -114,7 +114,7 @@ public function test_it_marks_playlist_as_processed_on_populate_playlist_failure new CreatePlaylistAndDispatchTracksJob( $playlistTransfer, - ['name' => 'snickers', 'id' => '123'] + $this->newPlaylist(), )->handle(); $playlistTransfer->refresh(); diff --git a/tests/Unit/Jobs/PlaylistTransferJobTest.php b/tests/Unit/Jobs/PlaylistTransferJobTest.php index ba7ba43..2d671f6 100644 --- a/tests/Unit/Jobs/PlaylistTransferJobTest.php +++ b/tests/Unit/Jobs/PlaylistTransferJobTest.php @@ -88,6 +88,7 @@ public function test_it_updates_status_to_complete_on_completion() 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), ]); + $pt->playlists()->save($this->newPlaylist()); (new PlaylistTransferJob($pt))->handle(); $pt->refresh(); $this->assertEquals(PlaylistTransfer::STATUS_COMPLETED, $pt->status); @@ -101,10 +102,10 @@ public function test_it_updates_processed_playlists() 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 'asdf', 'name' => 'snickers'] - ] ]); + $job->playlists()->save( + $this->newPlaylist() + ); (new PlaylistTransferJob($job))->handle(); $job->refresh(); $this->assertEquals(1, $job->playlists_processed); @@ -118,10 +119,10 @@ public function test_it_creates_track_models() 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 'asdf', 'name' => 'snickers'] - ] ]); + + $job->playlists()->save($this->newPlaylist()); + (new PlaylistTransferJob($job))->handle(); $this->assertDatabaseHas('tracks', [ 'isrc_ids' => json_encode(['USUM72005901']), @@ -156,10 +157,8 @@ public function test_it_creates_track_with_one_remote_id_if_no_final_candidate() 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 'asdf', 'name' => 'snickers'] - ] ]); + $pt->playlists()->save($this->newPlaylist()); (new PlaylistTransferJob($pt))->handle(); $this->assertDatabaseHas('tracks', [ @@ -204,15 +203,13 @@ public function test_it_creates_track_with_two_remote_ids_if_final_canddidate_fo $this->destinationMock->shouldReceive('addTracksToPlaylist'); - $job = PlaylistTransfer::factory()->create([ + $pt = PlaylistTransfer::factory()->create([ 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 'asdf', 'name' => 'snickers'] - ] ]); - (new PlaylistTransferJob($job))->handle(); + $pt->playlists()->save($this->newPlaylist()); + (new PlaylistTransferJob($pt))->handle(); $this->assertDatabaseHas('tracks', [ 'isrc_ids' => json_encode(['USUM72005901']), @@ -223,28 +220,6 @@ public function test_it_creates_track_with_two_remote_ids_if_final_canddidate_fo ]); } - public function test_it_creates_playlist_models() - { - $this->happyPathApiMocks(); - - $job = PlaylistTransfer::factory()->create([ - 'source' => SpotifyService::PROVIDER, - 'destination' => TidalService::PROVIDER, - 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 1, 'name' => 'snickers1'], - ], - ]); - (new PlaylistTransferJob($job))->handle(); - - $this->assertDatabaseHas('playlists', [ - 'name' => 'snickers1', - 'service' => SpotifyService::PROVIDER, - 'remote_id' => "1", - 'user_id' => $this->user()->getKey(), - ]); - } - public function test_it_associates_tracks_with_playlist() { $trackOne = new TrackDto([ @@ -273,52 +248,23 @@ public function test_it_associates_tracks_with_playlist() $this->destinationMock->shouldReceive('addTracksToPlaylist') ->once(); - $job = PlaylistTransfer::factory()->create([ + $pt = PlaylistTransfer::factory()->create([ 'source' => SpotifyService::PROVIDER, 'destination' => TidalService::PROVIDER, 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 1, 'name' => 'snickers1'], - ], ]); - (new PlaylistTransferJob($job))->handle(); + $pt->playlists()->save($this->newPlaylist([ + 'name' => 'snickers', + ])); + (new PlaylistTransferJob($pt))->handle(); $playlist = Playlist::query() - ->where(['name' => 'snickers1']) + ->where(['name' => 'snickers']) ->firstOrFail(); $this->assertNotEmpty($playlist->tracks()->get()); } - public function test_it_creates_one_playlist_if_transferred_twice() - { - $this->happyPathApiMocks(); - $job = PlaylistTransfer::factory()->create([ - 'source' => SpotifyService::PROVIDER, - 'destination' => TidalService::PROVIDER, - 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 1, 'name' => 'snickers1'], - ], - ]); - (new PlaylistTransferJob($job))->handle(); - - $this->assertDatabaseCount('playlists', 1); - - $this->happyPathApiMocks(); - $job = PlaylistTransfer::factory()->create([ - 'source' => SpotifyService::PROVIDER, - 'destination' => TidalService::PROVIDER, - 'user_id' => $this->user()->getKey(), - 'playlists' => [ - ['id' => 1, 'name' => 'snickers1'], - ], - ]); - (new PlaylistTransferJob($job))->handle(); - - $this->assertDatabaseCount('playlists', 1); - } - public function happyPathApiMocks(): void { $this->sourceMock->shouldReceive('getPlaylistTracks')