feat(plus): add song policy tests

This commit is contained in:
Phan An 2024-01-09 19:34:40 +01:00
parent 9a89828384
commit cc12618a95
61 changed files with 460 additions and 95 deletions

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\LicenseService;
use App\Services\License\LicenseServiceInterface;
use Illuminate\Console\Command;
use Throwable;
@ -11,7 +11,7 @@ class ActivateLicenseCommand extends Command
protected $signature = 'koel:license:activate {key : The license key to activate.}';
protected $description = 'Activate a Koel Plus license';
public function __construct(private LicenseService $licenseService)
public function __construct(private LicenseServiceInterface $licenseService)
{
parent::__construct();
}

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\LicenseService;
use App\Services\License\LicenseServiceInterface;
use App\Values\LicenseStatus;
use Illuminate\Console\Command;
use Throwable;
@ -12,7 +12,7 @@ class CheckLicenseStatusCommand extends Command
protected $signature = 'koel:license:status';
protected $description = 'Check the current Koel Plus license status.';
public function __construct(private LicenseService $licenseService)
public function __construct(private LicenseServiceInterface $licenseService)
{
parent::__construct();
}

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\LicenseService;
use App\Services\License\LicenseServiceInterface;
use Illuminate\Console\Command;
use Throwable;
@ -11,7 +11,7 @@ class DeactivateLicenseCommand extends Command
protected $signature = 'koel:license:deactivate';
protected $description = 'Deactivate the currently active Koel Plus license';
public function __construct(private LicenseService $plusService)
public function __construct(private LicenseServiceInterface $plusService)
{
parent::__construct();
}
@ -35,7 +35,7 @@ class DeactivateLicenseCommand extends Command
$this->components->info('Deactivating your license…');
try {
$this->plusService->deactivateLicense($status->license);
$this->plusService->deactivate($status->license);
$this->components->info('Koel Plus has been deactivated. Plus features are now disabled.');
return self::SUCCESS;

View file

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use Exception;
class MethodNotImplementedException extends Exception
{
public static function method(string $method): self
{
return new self("Method $method is not implemented.");
}
}

View file

@ -2,12 +2,13 @@
namespace App\Facades;
use App\Services\License\FakePlusLicenseService;
use Illuminate\Support\Facades\Facade;
/**
* @method static bool isPlus()
* @method static bool isCommunity()
* @see \App\Services\LicenseService
* @see \App\Services\License\LicenseService
*/
class License extends Facade
{
@ -15,4 +16,9 @@ class License extends Facade
{
return 'License';
}
public static function fakePlusLicense(): void
{
self::swap(app(FakePlusLicenseService::class));
}
}

View file

@ -13,7 +13,7 @@ use App\Repositories\SongRepository;
use App\Services\ApplicationInformationService;
use App\Services\ITunesService;
use App\Services\LastfmService;
use App\Services\LicenseService;
use App\Services\License\LicenseServiceInterface;
use App\Services\QueueService;
use App\Services\SpotifyService;
use App\Services\YouTubeService;
@ -28,7 +28,7 @@ class FetchInitialDataController extends Controller
SongRepository $songRepository,
ApplicationInformationService $applicationInformationService,
QueueService $queueService,
LicenseService $licenseService,
LicenseServiceInterface $licenseService,
?Authenticatable $user
) {
$licenseStatus = $licenseService->getStatus();

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ChangeSongsVisibilityRequest;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\SongService;
@ -18,7 +19,7 @@ class MakeSongsPublicController extends Controller
SongService $songService,
Authenticatable $user
) {
$songs = $repository->getMany(ids: $request->songs, scopedUser: $user);
$songs = Song::find($request->songs);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->makeSongsPublic($songs);

View file

@ -45,13 +45,15 @@ class SongController extends Controller
public function show(Song $song)
{
$this->authorize('access', $song);
return SongResource::make($this->songRepository->getOne($song->id));
}
public function update(SongUpdateRequest $request)
{
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user)
->each(fn (Song $song) => $this->authorize('edit', $song));
// 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));
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
$albums = $this->albumRepository->getMany($updatedSongs->pluck('album_id')->toArray());
@ -73,8 +75,8 @@ class SongController extends Controller
public function destroy(DeleteSongsRequest $request)
{
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user)
->each(fn (Song $song) => $this->authorize('delete', $song));
// 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));
$this->songService->deleteSongs($request->songs);

View file

@ -2,6 +2,8 @@
namespace App\Http\Requests\API;
use App\Facades\License;
/**
* @property-read array<string> $songs
*/
@ -14,4 +16,9 @@ class ChangeSongsVisibilityRequest extends Request
'songs' => 'required|exists:songs,id',
];
}
public function authorize(): bool
{
return License::isPlus();
}
}

View file

@ -5,7 +5,8 @@ namespace App\Providers;
use App\Services\ApiClients\ApiClient;
use App\Services\ApiClients\LemonSqueezyApiClient;
use App\Services\LastfmService;
use App\Services\LicenseService;
use App\Services\License\LicenseService;
use App\Services\License\LicenseServiceInterface;
use App\Services\MusicEncyclopedia;
use App\Services\NullMusicEncyclopedia;
use App\Services\SpotifyService;
@ -48,6 +49,8 @@ class AppServiceProvider extends ServiceProvider
return $this->app->get(LastfmService::enabled() ? LastfmService::class : NullMusicEncyclopedia::class);
});
$this->app->bind(LicenseServiceInterface::class, LicenseService::class);
$this->app->when(LicenseService::class)
->needs(ApiClient::class)
->give(fn () => $this->app->get(LemonSqueezyApiClient::class));

View file

@ -2,13 +2,13 @@
namespace App\Providers;
use App\Services\LicenseService;
use App\Services\License\LicenseServiceInterface;
use Illuminate\Support\ServiceProvider;
class LicenseServiceProvider extends ServiceProvider
{
public function register(): void
{
app()->singleton('License', static fn (): LicenseService => app(LicenseService::class));
app()->singleton('License', static fn (): LicenseServiceInterface => app(LicenseServiceInterface::class));
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace App\Services;
use App\Services\ApiClients\LemonSqueezyApiClient;
class CommunityLicenseService extends LicenseService
{
public function __construct(LemonSqueezyApiClient $client)
{
parent::__construct($client, config('app.key'));
}
public function isPlus(): bool
{
return false;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Services\License;
use App\Exceptions\MethodNotImplementedException;
use App\Models\License;
use App\Values\LicenseStatus;
class CommunityLicenseService implements LicenseServiceInterface
{
public function activate(string $key): License
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function deactivate(License $license): void
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function getStatus(): LicenseStatus
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function isPlus(): bool
{
return false;
}
public function isCommunity(): bool
{
return true;
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Services\License;
use App\Exceptions\MethodNotImplementedException;
use App\Models\License;
use App\Values\LicenseStatus;
class FakePlusLicenseService implements LicenseServiceInterface
{
public function activate(string $key): License
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function deactivate(License $license): void
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function getStatus(): LicenseStatus
{
throw MethodNotImplementedException::method(__METHOD__);
}
public function isPlus(): bool
{
return true;
}
public function isCommunity(): bool
{
return false;
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\License;
use App\Exceptions\FailedToActivateLicenseException;
use App\Models\License;
@ -14,7 +14,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
class LicenseService
class LicenseService implements LicenseServiceInterface
{
public function __construct(private ApiClient $client, private string $hashSalt)
{
@ -41,7 +41,7 @@ class LicenseService
}
}
public function deactivateLicense(License $license): void
public function deactivate(License $license): void
{
try {
$response = $this->client->post('licenses/deactivate', [

View file

@ -0,0 +1,19 @@
<?php
namespace App\Services\License;
use App\Models\License;
use App\Values\LicenseStatus;
interface LicenseServiceInterface
{
public function isPlus(): bool;
public function isCommunity(): bool;
public function activate(string $key): License;
public function deactivate(License $license): void;
public function getStatus(): LicenseStatus;
}

View file

@ -17,7 +17,7 @@ class InteractionFactory extends Factory
return [
'song_id' => Song::factory(),
'user_id' => User::factory(),
'liked' => $this->faker->boolean,
'liked' => $this->faker->boolean(),
'play_count' => $this->faker->randomNumber(),
];
}

View file

@ -4,6 +4,7 @@ namespace Database\Factories;
use App\Models\Album;
use App\Models\Song;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class SongFactory extends Factory
@ -27,7 +28,19 @@ class SongFactory extends Factory
'path' => '/tmp/' . uniqid() . '.mp3',
'genre' => $this->faker->randomElement(['Rock', 'Pop', 'Jazz', 'Classical', 'Metal', 'Hip Hop', 'Rap']),
'year' => $this->faker->year(),
'is_public' => $this->faker->boolean(),
'owner_id' => User::factory(),
'mtime' => time(),
];
}
public function public(): self
{
return $this->state(fn () => ['is_public' => true]); // @phpcs:ignore
}
public function private(): self
{
return $this->state(fn () => ['is_public' => false]); // @phpcs:ignore
}
}

View file

@ -1,6 +1,5 @@
<?php
use App\Facades\License;
use App\Facades\YouTube;
use App\Http\Controllers\API\AlbumController;
use App\Http\Controllers\API\AlbumSongController;
@ -159,10 +158,8 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::post('invitations', [UserInvitationController::class, 'invite']);
Route::delete('invitations', [UserInvitationController::class, 'revoke']);
if (License::isPlus()) {
Route::put('songs/make-public', MakeSongsPublicController::class);
Route::put('songs/make-private', MakeSongsPrivateController::class);
}
Route::put('songs/make-public', MakeSongsPublicController::class);
Route::put('songs/make-private', MakeSongsPrivateController::class);
});
// Object-storage (S3) routes

View file

@ -8,6 +8,7 @@ use App\Models\User;
use App\Services\MediaMetadataService;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class AlbumCoverTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Album;
use App\Services\MediaInformationService;
use App\Values\AlbumInformation;
use Mockery;
use Tests\TestCase;
class AlbumInformationTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Album;
use App\Models\Song;
use Tests\TestCase;
class AlbumSongTest extends TestCase
{

View file

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\Album;
use Tests\TestCase;
class AlbumTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Album;
use App\Services\MediaMetadataService;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class AlbumThumbnailTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Album;
use App\Models\Artist;
use Tests\TestCase;
class ArtistAlbumTest extends TestCase
{

View file

@ -8,6 +8,7 @@ use App\Models\User;
use App\Services\MediaMetadataService;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class ArtistImageTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Artist;
use App\Services\MediaInformationService;
use App\Values\ArtistInformation;
use Mockery;
use Tests\TestCase;
class ArtistInformationTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Artist;
use App\Models\Song;
use Tests\TestCase;
class ArtistSongTest extends TestCase
{

View file

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\Artist;
use Tests\TestCase;
class ArtistTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class AuthTest extends TestCase
{

View file

@ -2,6 +2,8 @@
namespace Tests\Feature;
use Tests\TestCase;
class DataTest extends TestCase
{
public function testIndex(): void

View file

@ -4,14 +4,15 @@ namespace Tests\Feature;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Interaction;
use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Services\DownloadService;
use Illuminate\Support\Collection;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class DownloadTest extends TestCase
{
@ -90,14 +91,18 @@ class DownloadTest extends TestCase
/** @var Album $album */
$album = Album::query()->first();
$songs = Song::factory(3)->create(['album_id' => $album->id]);
/** @var User $user */
$user = User::factory()->create();
$this->downloadService
->shouldReceive('from')
->once()
->with(Mockery::on(static function (Album $retrievedAlbum) use ($album): bool {
return $retrievedAlbum->id === $album->id;
->with(Mockery::on(static function (Collection $retrievedSongs) use ($songs): bool {
self::assertEqualsCanonicalizing($retrievedSongs->pluck('id')->all(), $songs->pluck('id')->all());
return true;
}))
->andReturn($this->mediaPath . '/blank.mp3');
@ -110,14 +115,18 @@ class DownloadTest extends TestCase
/** @var Artist $artist */
$artist = Artist::query()->first();
$songs = Song::factory(3)->create(['artist_id' => $artist->id]);
/** @var User $user */
$user = User::factory()->create();
$this->downloadService
->shouldReceive('from')
->once()
->with(Mockery::on(static function (Artist $retrievedArtist) use ($artist): bool {
return $retrievedArtist->id === $artist->id;
->with(Mockery::on(static function (Collection $retrievedSongs) use ($songs): bool {
self::assertEqualsCanonicalizing($retrievedSongs->pluck('id')->all(), $songs->pluck('id')->all());
return true;
}))
->andReturn($this->mediaPath . '/blank.mp3');
@ -130,13 +139,19 @@ class DownloadTest extends TestCase
/** @var User $user */
$user = User::factory()->create();
$songs = Song::factory(3)->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create();
$playlist->songs()->attach($songs);
$this->downloadService
->shouldReceive('from')
->with(Mockery::on(static function (Playlist $retrievedPlaylist) use ($playlist): bool {
return $retrievedPlaylist->id === $playlist->id;
->with(Mockery::on(static function (Collection $retrievedSongs) use ($songs): bool {
self::assertEqualsCanonicalizing($retrievedSongs->pluck('id')->all(), $songs->pluck('id')->all());
return true;
}))
->once()
->andReturn($this->mediaPath . '/blank.mp3');
@ -161,17 +176,16 @@ class DownloadTest extends TestCase
{
/** @var User $user */
$user = User::factory()->create();
$favorites = Collection::make();
self::mock(InteractionRepository::class)
->shouldReceive('getUserFavorites')
->once()
->with(Mockery::on(static fn (User $retrievedUser) => $retrievedUser->is($user)))
->andReturn($favorites);
$favorites = Interaction::factory(3)->for($user)->create(['liked' => true]);
$this->downloadService
->shouldReceive('from')
->with($favorites)
->with(Mockery::on(static function (Collection $songs) use ($favorites): bool {
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $favorites->pluck('song_id')->all());
return true;
}))
->once()
->andReturn($this->mediaPath . '/blank.mp3');

View file

@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use Tests\TestCase;
class ExcerptSearchTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Interaction;
use App\Models\User;
use Tests\TestCase;
class FavoriteSongTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Song;
use App\Values\Genre;
use Tests\TestCase;
class GenreTest extends TestCase
{

View file

@ -7,6 +7,7 @@ use App\Events\SongsBatchLiked;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
use Tests\TestCase;
class InteractionTest extends TestCase
{

View file

@ -0,0 +1,110 @@
<?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 SongTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
License::fakePlusLicense();
}
public function testShowSongPolicy(): void
{
/** @var User $user */
$user = User::factory()->create();
/** @var Song $publicSong */
$publicSong = Song::factory()->public()->create();
// We can access public songs.
$this->getAs('api/songs/' . $publicSong->id, $user)->assertSuccessful();
/** @var Song $ownPrivateSong */
$ownPrivateSong = Song::factory()->for($user, 'owner')->private()->create();
// We can access our own private songs.
$this->getAs('api/songs/' . $ownPrivateSong->id, $user)->assertSuccessful();
$externalPrivateSong = Song::factory()->private()->create();
// But we can't access private songs that are not ours.
$this->getAs('api/songs/' . $externalPrivateSong->id, $user)->assertForbidden();
}
public function testEditSongsPolicy(): void
{
/** @var User $currentUser */
$currentUser = User::factory()->create();
/** @var User $anotherUser */
$anotherUser = User::factory()->create();
/** @var Collection<Song> $externalSongs */
$externalSongs = Song::factory(3)->for($anotherUser, 'owner')->private()->create();
// We can't edit songs that are not ours.
$this->putAs('api/songs', [
'songs' => $externalSongs->pluck('id')->toArray(),
'data' => [
'title' => 'New Title',
],
], $currentUser)->assertForbidden();
// Even if some of the songs are owned by us, we still can't edit them.
$mixedSongs = $externalSongs->merge(Song::factory(2)->for($currentUser, 'owner')->create());
$this->putAs('api/songs', [
'songs' => $mixedSongs->pluck('id')->toArray(),
'data' => [
'title' => 'New Title',
],
], $currentUser)->assertForbidden();
// But we can edit our own songs.
$ownSongs = Song::factory(3)->for($currentUser, 'owner')->create();
$this->putAs('api/songs', [
'songs' => $ownSongs->pluck('id')->toArray(),
'data' => [
'title' => 'New Title',
],
], $currentUser)->assertSuccessful();
}
public function testDeleteSongsPolicy(): void
{
/** @var User $currentUser */
$currentUser = User::factory()->create();
/** @var User $anotherUser */
$anotherUser = User::factory()->create();
/** @var Collection<Song> $externalSongs */
$externalSongs = Song::factory(3)->for($anotherUser, 'owner')->private()->create();
// We can't delete songs that are not ours.
$this->deleteAs('api/songs', ['songs' => $externalSongs->pluck('id')->toArray()], $currentUser)
->assertForbidden();
// Even if some of the songs are owned by us, we still can't delete them.
$mixedSongs = $externalSongs->merge(Song::factory(2)->for($currentUser, 'owner')->create());
$this->deleteAs('api/songs', ['songs' => $mixedSongs->pluck('id')->toArray()], $currentUser)
->assertForbidden();
// But we can delete our own songs.
$ownSongs = Song::factory(3)->for($currentUser, 'owner')->create();
$this->deleteAs('api/songs', ['songs' => $ownSongs->pluck('id')->toArray()], $currentUser)
->assertSuccessful();
}
}

View file

@ -0,0 +1,67 @@
<?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 SongVisibilityTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
License::fakePlusLicense();
}
public function testMakingSongPublic(): void
{
/** @var User $currentUser */
$currentUser = User::factory()->create();
/** @var User $anotherUser */
$anotherUser = User::factory()->create();
/** @var Collection<Song> $externalSongs */
$externalSongs = Song::factory(3)->for($anotherUser, 'owner')->private()->create();
// We can't make public songs that are not ours.
$this->putAs('api/songs/make-public', ['songs' => $externalSongs->pluck('id')->toArray()], $currentUser)
->assertForbidden();
// But we can our own songs.
$ownSongs = Song::factory(3)->for($currentUser, 'owner')->create();
$this->putAs('api/songs/make-public', ['songs' => $ownSongs->pluck('id')->toArray()], $currentUser)
->assertSuccessful();
$ownSongs->each(static fn (Song $song) => self::assertTrue($song->refresh()->is_public));
}
public function testMakingSongPrivate(): void
{
/** @var User $currentUser */
$currentUser = User::factory()->create();
/** @var User $anotherUser */
$anotherUser = User::factory()->create();
/** @var Collection<Song> $externalSongs */
$externalSongs = Song::factory(3)->for($anotherUser, 'owner')->public()->create();
// We can't make private songs that are not ours.
$this->putAs('api/songs/make-private', ['songs' => $externalSongs->pluck('id')->toArray()], $currentUser)
->assertForbidden();
// But we can our own songs.
$ownSongs = Song::factory(3)->for($currentUser, 'owner')->create();
$this->putAs('api/songs/make-private', ['songs' => $ownSongs->pluck('id')->toArray()], $currentUser)
->assertSuccessful();
$ownSongs->each(static fn (Song $song) => self::assertFalse($song->refresh()->is_public));
}
}

View file

@ -9,6 +9,7 @@ use Laravel\Sanctum\NewAccessToken;
use Laravel\Sanctum\PersonalAccessToken;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class LastfmTest extends TestCase
{

View file

@ -5,7 +5,7 @@ namespace Tests\Feature\ObjectStorage;
use App\Events\LibraryChanged;
use App\Models\Song;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Tests\Feature\TestCase;
use Tests\TestCase;
class S3Test extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Interaction;
use App\Models\User;
use Tests\TestCase;
class OverviewTest extends TestCase
{

View file

@ -7,6 +7,7 @@ use App\Models\Interaction;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class PlayCountTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\PlaylistFolder;
use App\Models\User;
use Tests\TestCase;
class PlaylistFolderTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
use Tests\TestCase;
class PlaylistSongTest extends TestCase
{

View file

@ -7,6 +7,7 @@ use App\Models\Song;
use App\Models\User;
use App\Values\SmartPlaylistRule;
use Illuminate\Support\Collection;
use Tests\TestCase;
class PlaylistTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ProfileTest extends TestCase
{

View file

@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\QueueState;
use App\Models\Song;
use App\Models\User;
use Tests\TestCase;
class QueueTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\Interaction;
use App\Models\User;
use Tests\TestCase;
class RecentlyPlayedSongTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Song;
use App\Models\User;
use App\Services\LastfmService;
use Mockery;
use Tests\TestCase;
class ScrobbleTest extends TestCase
{

View file

@ -7,16 +7,17 @@ use App\Models\User;
use App\Services\MediaScanner;
use App\Values\ScanResultCollection;
use Mockery\MockInterface;
use Tests\TestCase;
class SettingTest extends TestCase
{
private MediaScanner|MockInterface $mediaSyncService;
private MediaScanner|MockInterface $mediaScanner;
public function setUp(): void
{
parent::setUp();
$this->mediaSyncService = self::mock(MediaScanner::class);
$this->mediaScanner = self::mock(MediaScanner::class);
}
public function testSaveSettings(): void
@ -24,7 +25,7 @@ class SettingTest extends TestCase
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->mediaSyncService->shouldReceive('scan')->once()
$this->mediaScanner->shouldReceive('scan')->once()
->andReturn(ScanResultCollection::create());
$this->putAs('/api/settings', ['media_path' => __DIR__], $admin)

View file

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Models\Song;
use Tests\TestCase;
class SongSearchTest extends TestCase
{

View file

@ -7,6 +7,7 @@ use App\Models\Artist;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
use Tests\TestCase;
class SongTest extends TestCase
{

View file

@ -0,0 +1,24 @@
<?php
namespace Tests\Feature;
use App\Models\Song;
use App\Models\User;
use Tests\TestCase;
class SongVisibilityTest extends TestCase
{
public function testChangingVisibilityIsForbiddenInCommunityEdition(): void
{
/** @var User $user */
$owner = User::factory()->admin()->create();
Song::factory(3)->create();
$this->putAs('api/songs/make-public', ['songs' => Song::query()->pluck('id')->all()], $owner)
->assertForbidden();
$this->putAs('api/songs/make-private', ['songs' => Song::query()->pluck('id')->all()], $owner)
->assertForbidden();
}
}

View file

@ -10,6 +10,7 @@ use App\Models\User;
use Illuminate\Http\Response;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class UploadTest extends TestCase
{

View file

@ -4,8 +4,9 @@ namespace Tests\Feature;
use App\Mail\UserInvite;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Mail;
use Tests\TestCase;
class UserInvitationTest extends TestCase
{

View file

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class UserTest extends TestCase
{

View file

@ -6,6 +6,7 @@ use App\Models\Song;
use App\Services\YouTubeService;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class YouTubeTest extends TestCase
{

View file

@ -16,7 +16,7 @@ use App\Values\ScanConfiguration;
use getID3;
use Illuminate\Support\Arr;
use Mockery;
use Tests\Feature\TestCase;
use Tests\TestCase;
class MediaScannerTest extends TestCase
{

View file

@ -6,20 +6,21 @@ use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
use App\Services\CommunityLicenseService;
use App\Services\License\CommunityLicenseService;
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\File;
use ReflectionClass;
use Tests\Traits\CreatesApplication;
use Tests\Traits\SandboxesTests;
use Tests\Traits\MakesHttpRequests;
abstract class TestCase extends BaseTestCase
{
use ArraySubsetAsserts;
use CreatesApplication;
use DatabaseTransactions;
use SandboxesTests;
use MakesHttpRequests;
public function setUp(): void
{
@ -58,4 +59,19 @@ abstract class TestCase extends BaseTestCase
return $property->getValue($object);
}
private static function createSandbox(): void
{
config(['koel.album_cover_dir' => 'sandbox/img/covers/']);
config(['koel.artist_image_dir' => 'sandbox/img/artists/']);
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
File::ensureDirectoryExists(public_path('sandbox/media/'));
}
private static function destroySandbox(): void
{
File::deleteDirectory(public_path('sandbox'));
}
}

View file

@ -1,20 +1,30 @@
<?php
namespace Tests\Feature;
namespace Tests\Traits;
use App\Models\User;
use Illuminate\Testing\TestResponse;
use Tests\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
/**
* @extends TestCase
*/
trait MakesHttpRequests
{
/**
* @param string $method
* @param string $uri
* @return TestResponse
*/
abstract public function json($method, $uri, array $data = [], array $headers = []); // @phpcs:ignore
private function jsonAs(?User $user, string $method, $uri, array $data = [], array $headers = []): TestResponse
{
/** @var User $user */
$user = $user ?: User::factory()->create();
$this->withToken($user->createToken('koel')->plainTextToken);
return parent::json($method, $uri, $data, $headers);
return $this->json($method, $uri, $data, $headers);
}
protected function getAs(string $url, ?User $user = null): TestResponse

View file

@ -1,23 +0,0 @@
<?php
namespace Tests\Traits;
use Illuminate\Support\Facades\File;
trait SandboxesTests
{
private static function createSandbox(): void
{
config(['koel.album_cover_dir' => 'sandbox/img/covers/']);
config(['koel.artist_image_dir' => 'sandbox/img/artists/']);
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
File::ensureDirectoryExists(public_path('sandbox/media/'));
}
private static function destroySandbox(): void
{
File::deleteDirectory(public_path('sandbox'));
}
}