feat(plus): add song interaction tests

This commit is contained in:
Phan An 2024-01-10 22:37:24 +01:00
parent d31479019a
commit 0407a000e8
20 changed files with 264 additions and 116 deletions

View file

@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class SongsBatchLiked extends Event
class MultipleSongsLiked extends Event
{
use SerializesModels;

View file

@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class SongsBatchUnliked extends Event
class MultipleSongsUnliked extends Event
{
use SerializesModels;

View file

@ -1,42 +0,0 @@
<?php
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\BatchInteractionRequest;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Arr;
class BatchLikeController extends Controller
{
/** @param User $user */
public function __construct(
private SongRepository $songRepository,
private InteractionService $interactionService,
private ?Authenticatable $user
) {
}
public function store(BatchInteractionRequest $request)
{
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user)
->each(fn ($song) => $this->authorize('access', $song));
$interactions = $this->interactionService->batchLike(Arr::wrap($request->songs), $this->user);
return response()->json($interactions);
}
public function destroy(BatchInteractionRequest $request)
{
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user)
->each(fn ($song) => $this->authorize('access', $song));
$this->interactionService->batchUnlike(Arr::wrap($request->songs), $this->user);
return response()->noContent();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\InteractWithMultipleSongsRequest;
use App\Models\Song;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class LikeMultipleSongsController extends Controller
{
/** @param User $user */
public function __invoke(
InteractWithMultipleSongsRequest $request,
InteractionService $interactionService,
Authenticatable $user
) {
$songs = Song::query()->findMany($request->songs);
$songs->each(fn (Song $song) => $this->authorize('access', $song));
return response()->json($interactionService->likeMany($songs, $user));
}
}

View file

@ -19,7 +19,7 @@ class MakeSongsPublicController extends Controller
SongService $songService,
Authenticatable $user
) {
$songs = Song::find($request->songs);
$songs = Song::query()->find($request->songs);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->makeSongsPublic($songs);

View file

@ -1,24 +1,29 @@
<?php
namespace App\Http\Controllers\API\Interaction;
namespace App\Http\Controllers\API;
use App\Events\PlaybackStarted;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\IncreasePlayCountRequest;
use App\Http\Resources\InteractionResource;
use App\Models\Song;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class HandlePlaybackStartedController extends Controller
class RegisterPlayController extends Controller
{
/** @param User $user */
public function __invoke(
IncreasePlayCountRequest $request,
InteractionService $interactionService,
Authenticatable $user
?Authenticatable $user
) {
$interaction = $interactionService->increasePlayCount($request->song, $user);
/** @var Song $song */
$song = Song::query()->findOrFail($request->song);
$this->authorize('access', $song);
$interaction = $interactionService->increasePlayCount($song, $user);
event(new PlaybackStarted($interaction->song, $interaction->user));
return InteractionResource::make($interaction);

View file

@ -53,7 +53,7 @@ class SongController extends Controller
public function update(SongUpdateRequest $request)
{
// Don't use SongRepository::findMany() because it'd be already catered to the current user.
Song::find($request->songs)->each(fn (Song $song) => $this->authorize('edit', $song));
Song::query()->find($request->songs)->each(fn (Song $song) => $this->authorize('edit', $song));
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
$albums = $this->albumRepository->getMany($updatedSongs->pluck('album_id')->toArray());
@ -76,7 +76,7 @@ class SongController extends Controller
public function destroy(DeleteSongsRequest $request)
{
// Don't use SongRepository::findMany() because it'd be already catered to the current user.
Song::find($request->songs)->each(fn (Song $song) => $this->authorize('delete', $song));
Song::query()->findMany($request->songs)->each(fn (Song $song) => $this->authorize('delete', $song));
$this->songService->deleteSongs($request->songs);

View file

@ -1,10 +1,11 @@
<?php
namespace App\Http\Controllers\API\Interaction;
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ToggleLikeSongRequest;
use App\Http\Resources\InteractionResource;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\InteractionService;
@ -19,9 +20,10 @@ class ToggleLikeSongController extends Controller
InteractionService $interactionService,
?Authenticatable $user
) {
$song = $songRepository->getOne($request->song, $user);
/** @var Song $song */
$song = Song::query()->findOrFail($request->song);
$this->authorize('access', $song);
return InteractionResource::make($interactionService->toggleLike($request->song, $user));
return InteractionResource::make($interactionService->toggleLike($song, $user));
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\InteractWithMultipleSongsRequest;
use App\Models\Song;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class UnlikeMultipleSongsController extends Controller
{
/** @param User $user */
public function __invoke(
InteractWithMultipleSongsRequest $request,
InteractionService $interactionService,
Authenticatable $user
) {
$songs = Song::query()->findMany($request->songs);
$songs->each(fn (Song $song) => $this->authorize('access', $song));
$interactionService->unlikeMany($songs, $user);
return response()->noContent();
}
}

View file

@ -8,7 +8,7 @@ use Illuminate\Validation\Rule;
/**
* @property array<string> $songs
*/
class BatchInteractionRequest extends Request
class InteractWithMultipleSongsRequest extends Request
{
/** @return array<mixed> */
public function rules(): array

View file

@ -3,9 +3,10 @@
namespace App\Http\Requests\API\Interaction;
use App\Http\Requests\API\Request;
use Illuminate\Validation\Rule;
/**
* @property string $song The song's ID
* @property-read string $song The song's ID
*/
class IncreasePlayCountRequest extends Request
{
@ -13,7 +14,7 @@ class IncreasePlayCountRequest extends Request
public function rules(): array
{
return [
'song' => 'required',
'song' => ['required', Rule::exists('songs', 'id')],
];
}
}

View file

@ -5,4 +5,11 @@ namespace App\Http\Requests\API;
/** @property-read string $song */
class ToggleLikeSongRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'song' => 'required|exists:songs,id',
];
}
}

View file

@ -2,7 +2,7 @@
namespace App\Listeners;
use App\Events\SongsBatchLiked;
use App\Events\MultipleSongsLiked;
use App\Services\LastfmService;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -12,7 +12,7 @@ class LoveMultipleTracksOnLastfm implements ShouldQueue
{
}
public function handle(SongsBatchLiked $event): void
public function handle(MultipleSongsLiked $event): void
{
$this->lastfm->batchToggleLoveTracks($event->songs, $event->user, true);
}

View file

@ -2,7 +2,7 @@
namespace App\Listeners;
use App\Events\SongsBatchUnliked;
use App\Events\MultipleSongsUnliked;
use App\Services\LastfmService;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -12,7 +12,7 @@ class UnloveMultipleTracksOnLastfm implements ShouldQueue
{
}
public function handle(SongsBatchUnliked $event): void
public function handle(MultipleSongsUnliked $event): void
{
$this->lastfm->batchToggleLoveTracks($event->songs, $event->user, false);
}

View file

@ -4,10 +4,10 @@ namespace App\Providers;
use App\Events\LibraryChanged;
use App\Events\MediaScanCompleted;
use App\Events\MultipleSongsLiked;
use App\Events\MultipleSongsUnliked;
use App\Events\PlaybackStarted;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
use App\Events\SongsBatchUnliked;
use App\Listeners\DeleteNonExistingRecordsPostSync;
use App\Listeners\LoveMultipleTracksOnLastfm;
use App\Listeners\LoveTrackOnLastfm;
@ -26,11 +26,11 @@ class EventServiceProvider extends BaseServiceProvider
LoveTrackOnLastfm::class,
],
SongsBatchLiked::class => [
MultipleSongsLiked::class => [
LoveMultipleTracksOnLastfm::class,
],
SongsBatchUnliked::class => [
MultipleSongsUnliked::class => [
UnloveMultipleTracksOnLastfm::class,
],

View file

@ -2,9 +2,9 @@
namespace App\Services;
use App\Events\MultipleSongsLiked;
use App\Events\MultipleSongsUnliked;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
use App\Events\SongsBatchUnliked;
use App\Models\Interaction;
use App\Models\Song;
use App\Models\User;
@ -12,15 +12,10 @@ use Illuminate\Support\Collection;
class InteractionService
{
/**
* Increase the number of times a song is played by a user.
*
* @return Interaction The affected Interaction object
*/
public function increasePlayCount(string $songId, User $user): Interaction
public function increasePlayCount(Song $song, User $user): Interaction
{
return tap(Interaction::query()->firstOrCreate([
'song_id' => $songId,
'song_id' => $song->id,
'user_id' => $user->id,
]), static function (Interaction $interaction): void {
if (!$interaction->exists) {
@ -37,12 +32,12 @@ class InteractionService
/**
* Like or unlike a song as a user.
*
* @return Interaction the affected Interaction object
* @return Interaction The affected Interaction object
*/
public function toggleLike(string $songId, User $user): Interaction
public function toggleLike(Song $song, User $user): Interaction
{
return tap(Interaction::query()->firstOrCreate([
'song_id' => $songId,
'song_id' => $song->id,
'user_id' => $user->id,
]), static function (Interaction $interaction): void {
$interaction->liked = !$interaction->liked;
@ -55,15 +50,15 @@ class InteractionService
/**
* Like several songs at once as a user.
*
* @param array<string> $songIds
* @param array<array-key, Song>|Collection $songs
*
* @return array<Interaction>|Collection The array of Interaction objects
*/
public function batchLike(array $songIds, User $user): Collection
public function likeMany(Collection $songs, User $user): Collection
{
$interactions = collect($songIds)->map(static function ($songId) use ($user): Interaction {
$interactions = $songs->map(static function (Song $song) use ($user): Interaction {
return tap(Interaction::query()->firstOrCreate([
'song_id' => $songId,
'song_id' => $song->id,
'user_id' => $user->id,
]), static function (Interaction $interaction): void {
$interaction->play_count ??= 0;
@ -72,7 +67,7 @@ class InteractionService
});
});
event(new SongsBatchLiked($interactions->map(static fn (Interaction $item) => $item->song), $user));
event(new MultipleSongsLiked($songs, $user));
return $interactions;
}
@ -80,15 +75,15 @@ class InteractionService
/**
* Unlike several songs at once.
*
* @param array<string> $songIds
* @param array<array-key, Song>|Collection $songs
*/
public function batchUnlike(array $songIds, User $user): void
public function unlikeMany(Collection $songs, User $user): void
{
Interaction::query()
->whereIn('song_id', $songIds)
->whereIn('song_id', $songs->pluck('id')->all())
->where('user_id', $user->id)
->update(['liked' => false]);
event(new SongsBatchUnliked(Song::query()->find($songIds), $user));
event(new MultipleSongsUnliked($songs, $user));
}
}

View file

@ -21,9 +21,7 @@ use App\Http\Controllers\API\FetchRecentlyPlayedSongController;
use App\Http\Controllers\API\FetchSongsForQueueController;
use App\Http\Controllers\API\GenreController;
use App\Http\Controllers\API\GenreSongController;
use App\Http\Controllers\API\Interaction\BatchLikeController;
use App\Http\Controllers\API\Interaction\HandlePlaybackStartedController;
use App\Http\Controllers\API\Interaction\ToggleLikeSongController;
use App\Http\Controllers\API\LikeMultipleSongsController;
use App\Http\Controllers\API\MakeSongsPrivateController;
use App\Http\Controllers\API\MakeSongsPublicController;
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
@ -33,12 +31,15 @@ use App\Http\Controllers\API\PlaylistFolderPlaylistController;
use App\Http\Controllers\API\PlaylistSongController;
use App\Http\Controllers\API\ProfileController;
use App\Http\Controllers\API\QueueStateController;
use App\Http\Controllers\API\RegisterPlayController;
use App\Http\Controllers\API\ScrobbleController;
use App\Http\Controllers\API\SearchYouTubeController;
use App\Http\Controllers\API\SetLastfmSessionKeyController;
use App\Http\Controllers\API\SettingController;
use App\Http\Controllers\API\SongController;
use App\Http\Controllers\API\SongSearchController;
use App\Http\Controllers\API\ToggleLikeSongController;
use App\Http\Controllers\API\UnlikeMultipleSongsController;
use App\Http\Controllers\API\UpdatePlaybackStatusController;
use App\Http\Controllers\API\UploadAlbumCoverController;
use App\Http\Controllers\API\UploadArtistImageController;
@ -103,10 +104,10 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::post('upload', UploadController::class);
// Interaction routes
Route::post('interaction/play', HandlePlaybackStartedController::class);
Route::post('interaction/play', RegisterPlayController::class);
Route::post('interaction/like', ToggleLikeSongController::class);
Route::post('interaction/batch/like', [BatchLikeController::class, 'store']);
Route::post('interaction/batch/unlike', [BatchLikeController::class, 'destroy']);
Route::post('interaction/batch/like', LikeMultipleSongsController::class);
Route::post('interaction/batch/unlike', UnlikeMultipleSongsController::class);
Route::get('songs/recently-played', FetchRecentlyPlayedSongController::class);
Route::get('songs/favorite', FetchFavoriteSongsController::class);

View file

@ -2,8 +2,9 @@
namespace Tests\Feature;
use App\Events\MultipleSongsLiked;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
use App\Models\Interaction;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
@ -11,13 +12,6 @@ use Tests\TestCase;
class InteractionTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
static::createSampleMediaSet();
}
public function testIncreasePlayCount(): void
{
$this->withoutEvents();
@ -26,10 +20,10 @@ class InteractionTest extends TestCase
$user = User::factory()->create();
/** @var Song $song */
$song = Song::query()->orderBy('id')->first();
$song = Song::factory()->create();
$this->postAs('api/interaction/play', ['song' => $song->id], $user);
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'play_count' => 1,
@ -38,7 +32,7 @@ class InteractionTest extends TestCase
// Try again
$this->postAs('api/interaction/play', ['song' => $song->id], $user);
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'play_count' => 2,
@ -53,10 +47,10 @@ class InteractionTest extends TestCase
$user = User::factory()->create();
/** @var Song $song */
$song = Song::query()->orderBy('id')->first();
$song = Song::factory()->create();
$this->postAs('api/interaction/like', ['song' => $song->id], $user);
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'liked' => 1,
@ -65,7 +59,7 @@ class InteractionTest extends TestCase
// Try again
$this->postAs('api/interaction/like', ['song' => $song->id], $user);
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'liked' => 0,
@ -74,19 +68,19 @@ class InteractionTest extends TestCase
public function testToggleLikeBatch(): void
{
$this->expectsEvents(SongsBatchLiked::class);
$this->expectsEvents(MultipleSongsLiked::class);
/** @var User $user */
$user = User::factory()->create();
/** @var Collection|array<Song> $songs */
$songs = Song::query()->orderBy('id')->take(2)->get();
$songs = Song::factory(2)->create();
$songIds = $songs->pluck('id')->all();
$this->postAs('api/interaction/batch/like', ['songs' => $songIds], $user);
foreach ($songs as $song) {
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'liked' => 1,
@ -96,7 +90,7 @@ class InteractionTest extends TestCase
$this->postAs('api/interaction/batch/unlike', ['songs' => $songIds], $user);
foreach ($songs as $song) {
self::assertDatabaseHas('interactions', [
self::assertDatabaseHas(Interaction::class, [
'user_id' => $user->id,
'song_id' => $song->id,
'liked' => 0,

View file

@ -0,0 +1,133 @@
<?php
namespace Tests\Feature\KoelPlus;
use App\Facades\License;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
use Tests\TestCase;
class InteractionTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
License::fakePlusLicense();
}
public function testPolicyForRegisterPlay(): void
{
$this->withoutEvents();
/** @var User $owner */
$owner = User::factory()->create();
// Can't increase play count of a private song that doesn't belong to the user
/** @var Song $externalPrivateSong */
$externalPrivateSong = Song::factory()->private()->create();
$this->postAs('api/interaction/play', ['song' => $externalPrivateSong->id], $owner)
->assertForbidden();
// Can increase play count of a public song that doesn't belong to the user
/** @var Song $externalPublicSong */
$externalPublicSong = Song::factory()->public()->create();
$this->postAs('api/interaction/play', ['song' => $externalPublicSong->id], $owner)
->assertSuccessful();
// Can increase play count of a private song that belongs to the user
/** @var Song $ownPrivateSong */
$ownPrivateSong = Song::factory()->private()->for($owner, 'owner')->create();
$this->postAs('api/interaction/play', ['song' => $ownPrivateSong->id], $ownPrivateSong->owner)
->assertSuccessful();
}
public function testPolicyForToggleLike(): void
{
$this->withoutEvents();
/** @var User $user */
$owner = User::factory()->create();
// Can't like a private song that doesn't belong to the user
/** @var Song $externalPrivateSong */
$externalPrivateSong = Song::factory()->private()->create();
$this->postAs('api/interaction/like', ['song' => $externalPrivateSong->id], $owner)
->assertForbidden();
// Can like a public song that doesn't belong to the user
/** @var Song $externalPublicSong */
$externalPublicSong = Song::factory()->public()->create();
$this->postAs('api/interaction/like', ['song' => $externalPublicSong->id], $owner)
->assertSuccessful();
// Can like a private song that belongs to the user
/** @var Song $ownPrivateSong */
$ownPrivateSong = Song::factory()->private()->for($owner, 'owner')->create();
$this->postAs('api/interaction/like', ['song' => $ownPrivateSong->id], $owner)
->assertSuccessful();
}
public function testPolicyForBatchLike(): void
{
$this->withoutEvents();
/** @var User $user */
$owner = User::factory()->create();
// Can't batch like private songs that don't belong to the user
/** @var Collection $externalPrivateSongs */
$externalPrivateSongs = Song::factory()->count(3)->private()->create();
$this->postAs('api/interaction/batch/like', ['songs' => $externalPrivateSongs->pluck('id')->all()], $owner)
->assertForbidden();
// Can batch like public songs that don't belong to the user
/** @var Collection $externalPublicSongs */
$externalPublicSongs = Song::factory()->count(3)->public()->create();
$this->postAs('api/interaction/batch/like', ['songs' => $externalPublicSongs->pluck('id')->all()], $owner)
->assertSuccessful();
// Can batch like private songs that belong to the user
/** @var Collection $ownPrivateSongs */
$ownPrivateSongs = Song::factory()->count(3)->private()->for($owner, 'owner')->create();
$this->postAs('api/interaction/batch/like', ['songs' => $ownPrivateSongs->pluck('id')->all()], $owner)
->assertSuccessful();
// Can't batch like a mix of inaccessible and accessible songs
$mixedSongs = $externalPrivateSongs->merge($externalPublicSongs);
$this->postAs('api/interaction/batch/like', ['songs' => $mixedSongs->pluck('id')->all()], $owner)
->assertForbidden();
}
public function testPolicyForBatchUnlike(): void
{
$this->withoutEvents();
/** @var User $user */
$owner = User::factory()->create();
// Can't batch unlike private songs that don't belong to the user
/** @var Collection $externalPrivateSongs */
$externalPrivateSongs = Song::factory()->count(3)->private()->create();
$this->postAs('api/interaction/batch/unlike', ['songs' => $externalPrivateSongs->pluck('id')->all()], $owner)
->assertForbidden();
// Can batch unlike public songs that don't belong to the user
/** @var Collection $externalPublicSongs */
$externalPublicSongs = Song::factory()->count(3)->public()->create();
$this->postAs('api/interaction/batch/unlike', ['songs' => $externalPublicSongs->pluck('id')->all()], $owner)
->assertSuccessful();
// Can batch unlike private songs that belong to the user
/** @var Collection $ownPrivateSongs */
$ownPrivateSongs = Song::factory()->count(3)->private()->for($owner, 'owner')->create();
$this->postAs('api/interaction/batch/unlike', ['songs' => $ownPrivateSongs->pluck('id')->all()], $owner)
->assertSuccessful();
// Can't batch unlike a mix of inaccessible and accessible songs
$mixedSongs = $externalPrivateSongs->merge($externalPublicSongs);
$this->postAs('api/interaction/batch/unlike', ['songs' => $mixedSongs->pluck('id')->all()], $owner)
->assertForbidden();
}
}

View file

@ -2,9 +2,9 @@
namespace Tests\Integration\Services;
use App\Events\MultipleSongsLiked;
use App\Events\MultipleSongsUnliked;
use App\Events\SongLikeToggled;
use App\Events\SongsBatchLiked;
use App\Events\SongsBatchUnliked;
use App\Models\Interaction;
use App\Models\Song;
use App\Models\User;
@ -48,7 +48,7 @@ class InteractionServiceTest extends TestCase
public function testLikeMultipleSongs(): void
{
$this->expectsEvents(SongsBatchLiked::class);
$this->expectsEvents(MultipleSongsLiked::class);
/** @var Collection $songs */
$songs = Song::factory(2)->create();
@ -56,7 +56,7 @@ class InteractionServiceTest extends TestCase
/** @var User $user */
$user = User::factory()->create();
$this->interactionService->batchLike($songs->pluck('id')->all(), $user);
$this->interactionService->likeMany($songs, $user);
$songs->each(static function (Song $song) use ($user): void {
/** @var Interaction $interaction */
@ -71,7 +71,7 @@ class InteractionServiceTest extends TestCase
public function testUnlikeMultipleSongs(): void
{
$this->expectsEvents(SongsBatchUnliked::class);
$this->expectsEvents(MultipleSongsUnliked::class);
/** @var User $user */
$user = User::factory()->create();
@ -79,7 +79,7 @@ class InteractionServiceTest extends TestCase
/** @var Collection $interactions */
$interactions = Interaction::factory(3)->for($user)->create(['liked' => true]);
$this->interactionService->batchUnlike($interactions->pluck('song.id')->all(), $user);
$this->interactionService->unlikeMany($interactions->map(static fn (Interaction $i) => $i->song), $user);
$interactions->each(static function (Interaction $interaction): void {
self::assertFalse($interaction->refresh()->liked);