mirror of
https://github.com/koel/koel
synced 2024-11-10 14:44:13 +00:00
feat(plus): add tests for License service
This commit is contained in:
parent
10eed05543
commit
6b23f85b90
16 changed files with 407 additions and 26 deletions
|
@ -18,7 +18,7 @@ class DeactivateLicenseCommand extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$status = $this->plusService->getStatus(checkCache: false);
|
||||
$status = $this->plusService->getStatus();
|
||||
|
||||
if ($status->hasNoLicense()) {
|
||||
$this->components->warn('No active Plus license found.');
|
||||
|
|
|
@ -3,12 +3,20 @@
|
|||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Throwable;
|
||||
|
||||
class FailedToActivateLicenseException extends Exception
|
||||
{
|
||||
public static function fromException(Throwable $e): self
|
||||
public static function fromThrowable(Throwable $e): self
|
||||
{
|
||||
return new static($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
public static function fromClientException(ClientException $e): self
|
||||
{
|
||||
$response = $e->getResponse();
|
||||
|
||||
return new static(json_decode($response->getBody())->error, $response->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Facade;
|
|||
/**
|
||||
* @method static bool isPlus()
|
||||
* @method static bool isCommunity()
|
||||
* @see \App\Services\License\LicenseService
|
||||
* @see \App\Services\LicenseService
|
||||
*/
|
||||
class License extends Facade
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ use App\Values\LicenseInstance;
|
|||
use App\Values\LicenseMeta;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
@ -22,6 +23,8 @@ use Illuminate\Support\Str;
|
|||
*/
|
||||
class License extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
@ -5,8 +5,8 @@ namespace App\Providers;
|
|||
use App\Services\ApiClients\ApiClient;
|
||||
use App\Services\ApiClients\LemonSqueezyApiClient;
|
||||
use App\Services\LastfmService;
|
||||
use App\Services\License\LicenseService;
|
||||
use App\Services\License\LicenseServiceInterface;
|
||||
use App\Services\LicenseService;
|
||||
use App\Services\MusicEncyclopedia;
|
||||
use App\Services\NullMusicEncyclopedia;
|
||||
use App\Services\SpotifyService;
|
||||
|
@ -50,10 +50,7 @@ class AppServiceProvider extends ServiceProvider
|
|||
});
|
||||
|
||||
$this->app->bind(LicenseServiceInterface::class, LicenseService::class);
|
||||
|
||||
$this->app->when(LicenseService::class)
|
||||
->needs(ApiClient::class)
|
||||
->give(fn () => $this->app->get(LemonSqueezyApiClient::class));
|
||||
$this->app->bind(ApiClient::class, LemonSqueezyApiClient::class);
|
||||
|
||||
$this->app->when(LicenseService::class)
|
||||
->needs('$hashSalt')
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\License;
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\FailedToActivateLicenseException;
|
||||
use App\Models\License;
|
||||
use App\Services\ApiClients\ApiClient;
|
||||
use App\Services\License\LicenseServiceInterface;
|
||||
use App\Values\LicenseInstance;
|
||||
use App\Values\LicenseMeta;
|
||||
use App\Values\LicenseStatus;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
@ -32,12 +34,15 @@ class LicenseService implements LicenseServiceInterface
|
|||
throw new FailedToActivateLicenseException('This license key is not from Koel’s official store.');
|
||||
}
|
||||
|
||||
return $this->updateOrCreateLicenseFromApiResponse($response);
|
||||
$license = $this->updateOrCreateLicenseFromApiResponse($response);
|
||||
$this->cacheStatus(LicenseStatus::valid($license));
|
||||
|
||||
return $license;
|
||||
} catch (ClientException $e) {
|
||||
throw new FailedToActivateLicenseException(json_decode($e->getResponse()->getBody())->error, $e->getCode());
|
||||
throw FailedToActivateLicenseException::fromClientException($e);
|
||||
} catch (Throwable $e) {
|
||||
Log::error($e);
|
||||
throw FailedToActivateLicenseException::fromException($e);
|
||||
throw FailedToActivateLicenseException::fromThrowable($e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,9 +55,17 @@ class LicenseService implements LicenseServiceInterface
|
|||
]);
|
||||
|
||||
if ($response->deactivated) {
|
||||
$license->delete();
|
||||
Cache::delete('license_status');
|
||||
self::deleteLicense($license);
|
||||
}
|
||||
} catch (ClientException $e) {
|
||||
if ($e->getResponse()->getStatusCode() === Response::HTTP_NOT_FOUND) {
|
||||
// The instance ID was not found. The license record must be a leftover from an erroneous attempt.
|
||||
self::deleteLicense($license);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw FailedToActivateLicenseException::fromClientException($e);
|
||||
} catch (Throwable $e) {
|
||||
Log::error($e);
|
||||
throw $e;
|
||||
|
@ -83,8 +96,9 @@ class LicenseService implements LicenseServiceInterface
|
|||
return self::cacheStatus(LicenseStatus::valid($updatedLicense));
|
||||
} catch (ClientException $e) {
|
||||
Log::error($e);
|
||||
$statusCode = $e->getResponse()->getStatusCode();
|
||||
|
||||
if ($e->getCode() === 400 || $e->getCode() === 404) {
|
||||
if ($statusCode === Response::HTTP_BAD_REQUEST || $statusCode === Response::HTTP_NOT_FOUND) {
|
||||
return self::cacheStatus(LicenseStatus::invalid($license));
|
||||
}
|
||||
|
||||
|
@ -109,10 +123,16 @@ class LicenseService implements LicenseServiceInterface
|
|||
'instance' => LicenseInstance::fromJsonObject($response->instance),
|
||||
'meta' => LicenseMeta::fromJsonObject($response->meta),
|
||||
'created_at' => $response->license_key->created_at,
|
||||
'expires_at' => $response->instance->created_at,
|
||||
'expires_at' => $response->license_key->expires_at,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function deleteLicense(License $license): void
|
||||
{
|
||||
$license->delete();
|
||||
Cache::delete('license_status');
|
||||
}
|
||||
|
||||
private static function cacheStatus(LicenseStatus $status): LicenseStatus
|
||||
{
|
||||
Cache::put('license_status', $status, now()->addWeek());
|
31
database/factories/LicenseFactory.php
Normal file
31
database/factories/LicenseFactory.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Values\LicenseInstance;
|
||||
use App\Values\LicenseMeta;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LicenseFactory extends Factory
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'key' => Str::uuid()->toString(),
|
||||
'hash' => Str::random(32),
|
||||
'instance' => LicenseInstance::make(
|
||||
id: Str::uuid()->toString(),
|
||||
name: 'Koel Plus',
|
||||
createdAt: now(),
|
||||
),
|
||||
'meta' => LicenseMeta::make(
|
||||
customerId: $this->faker->numberBetween(1, 1000),
|
||||
customerName: $this->faker->name(),
|
||||
customerEmail: $this->faker->email()
|
||||
),
|
||||
'expires_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -44,9 +44,7 @@ class AlbumCoverTest extends TestCase
|
|||
/** @var Album $album */
|
||||
$album = Album::factory()->create();
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('writeAlbumCover')
|
||||
->never();
|
||||
$this->mediaMetadataService->shouldNotReceive('writeAlbumCover');
|
||||
|
||||
$this->putAs('api/album/' . $album->id . '/cover', ['cover' => 'data:image/jpeg;base64,Rm9v'], create_user())
|
||||
->assertForbidden();
|
||||
|
|
|
@ -41,9 +41,7 @@ class ArtistImageTest extends TestCase
|
|||
{
|
||||
Artist::factory()->create(['id' => 9999]);
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('writeArtistImage')
|
||||
->never();
|
||||
$this->mediaMetadataService->shouldNotReceive('writeArtistImage');
|
||||
|
||||
$this->putAs('api/artist/9999/image', ['image' => 'data:image/jpeg;base64,Rm9v'])
|
||||
->assertForbidden();
|
||||
|
|
|
@ -32,9 +32,7 @@ class DownloadTest extends TestCase
|
|||
/** @var Song $song */
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$this->downloadService
|
||||
->shouldReceive('getDownloadablePath')
|
||||
->never();
|
||||
$this->downloadService->shouldNotReceive('getDownloadablePath');
|
||||
|
||||
$this->get("download/songs?songs[]=$song->id")
|
||||
->assertUnauthorized();
|
||||
|
|
239
tests/Integration/Services/LicenseServiceTest.php
Normal file
239
tests/Integration/Services/LicenseServiceTest.php
Normal file
|
@ -0,0 +1,239 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Integration\Services;
|
||||
|
||||
use App\Exceptions\FailedToActivateLicenseException;
|
||||
use App\Models\License;
|
||||
use App\Services\ApiClients\ApiClient;
|
||||
use App\Services\LicenseService;
|
||||
use App\Values\LicenseStatus;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Mockery;
|
||||
use Mockery\LegacyMockInterface;
|
||||
use Mockery\MockInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\test_path;
|
||||
|
||||
class LicenseServiceTest extends TestCase
|
||||
{
|
||||
private LicenseService $service;
|
||||
private ApiClient|MockInterface|LegacyMockInterface $apiClient;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->apiClient = $this->mock(ApiClient::class);
|
||||
$this->service = app(LicenseService::class);
|
||||
}
|
||||
|
||||
public function testActivateLicense(): void
|
||||
{
|
||||
config(['lemonsqueezy.store_id' => 42]);
|
||||
$key = '38b1460a-5104-4067-a91d-77b872934d51';
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/activate', [
|
||||
'license_key' => $key,
|
||||
'instance_name' => 'Koel Plus',
|
||||
])
|
||||
->once()
|
||||
->andReturn(json_decode(File::get(test_path('blobs/lemonsqueezy/license-activated-successful.json'))));
|
||||
|
||||
$license = $this->service->activate($key);
|
||||
|
||||
self::assertSame($key, $license->key);
|
||||
self::assertNotNull($license->instance);
|
||||
self::assertSame('Luke Skywalker', $license->meta->customerName);
|
||||
self::assertSame('luke@skywalker.com', $license->meta->customerEmail);
|
||||
|
||||
/** @var LicenseStatus $cachedLicenseStatus */
|
||||
$cachedLicenseStatus = Cache::get('license_status');
|
||||
|
||||
self::assertSame($license->key, $cachedLicenseStatus->license->key);
|
||||
self::assertTrue($cachedLicenseStatus->isValid());
|
||||
}
|
||||
|
||||
public function testActivateLicenseFailsBecauseOfIncorrectStoreId(): void
|
||||
{
|
||||
self::expectException(FailedToActivateLicenseException::class);
|
||||
self::expectExceptionMessage('This license key is not from Koel’s official store.');
|
||||
|
||||
config(['lemonsqueezy.store_id' => 43]);
|
||||
$key = '38b1460a-5104-4067-a91d-77b872934d51';
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/activate', [
|
||||
'license_key' => $key,
|
||||
'instance_name' => 'Koel Plus',
|
||||
])
|
||||
->once()
|
||||
->andReturn(json_decode(File::get(test_path('blobs/lemonsqueezy/license-activated-successful.json'))));
|
||||
|
||||
$this->service->activate($key);
|
||||
}
|
||||
|
||||
public function testActivateLicenseFailsForInvalidLicenseKey(): void
|
||||
{
|
||||
self::expectException(FailedToActivateLicenseException::class);
|
||||
self::expectExceptionMessage('license_key not found');
|
||||
|
||||
$exception = Mockery::mock(ClientException::class, [
|
||||
'getResponse' => Mockery::mock(ResponseInterface::class, [
|
||||
'getBody' => File::get(test_path('blobs/lemonsqueezy/license-invalid.json')),
|
||||
'getStatusCode' => Response::HTTP_NOT_FOUND,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/activate', [
|
||||
'license_key' => 'invalid-key',
|
||||
'instance_name' => 'Koel Plus',
|
||||
])
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
$this->service->activate('invalid-key');
|
||||
}
|
||||
|
||||
public function testDeactivateLicense(): void
|
||||
{
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/deactivate', [
|
||||
'license_key' => $license->key,
|
||||
'instance_id' => $license->instance->id,
|
||||
])
|
||||
->once()
|
||||
->andReturn(json_decode(File::get(test_path('blobs/lemonsqueezy/license-deactivated-successful.json'))));
|
||||
|
||||
$this->service->deactivate($license);
|
||||
|
||||
self::assertModelMissing($license);
|
||||
self::assertFalse(Cache::has('license_status'));
|
||||
}
|
||||
|
||||
public function testDeactivateLicenseHandlesLeftoverRecords(): void
|
||||
{
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
$exception = Mockery::mock(ClientException::class, [
|
||||
'getResponse' => Mockery::mock(ResponseInterface::class, [
|
||||
'getStatusCode' => Response::HTTP_NOT_FOUND,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/deactivate', [
|
||||
'license_key' => $license->key,
|
||||
'instance_id' => $license->instance->id,
|
||||
])
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
$this->service->deactivate($license);
|
||||
|
||||
self::assertModelMissing($license);
|
||||
}
|
||||
|
||||
public function testDeactivateLicenseFails(): void
|
||||
{
|
||||
self::expectExceptionMessage('Something went horribly wrong');
|
||||
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/deactivate', [
|
||||
'license_key' => $license->key,
|
||||
'instance_id' => $license->instance->id,
|
||||
])
|
||||
->once()
|
||||
->andThrow(new Exception('Something went horribly wrong'));
|
||||
|
||||
$this->service->deactivate($license);
|
||||
}
|
||||
|
||||
public function testGetLicenseStatusFromCache(): void
|
||||
{
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
Cache::put('license_status', LicenseStatus::valid($license));
|
||||
|
||||
$this->apiClient->shouldNotReceive('post');
|
||||
|
||||
self::assertTrue($this->service->getStatus()->license->is($license));
|
||||
self::assertTrue($this->service->getStatus()->isValid());
|
||||
}
|
||||
|
||||
public function testGetLicenseStatusWithNoLicenses(): void
|
||||
{
|
||||
License::query()->delete();
|
||||
|
||||
$this->apiClient->shouldNotReceive('post');
|
||||
|
||||
self::assertTrue($this->service->getStatus()->hasNoLicense());
|
||||
}
|
||||
|
||||
public function testGetLicenseStatusValidatesWithApi(): void
|
||||
{
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
self::assertFalse(Cache::has('license_status'));
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/validate', [
|
||||
'license_key' => $license->key,
|
||||
'instance_id' => $license->instance->id,
|
||||
])
|
||||
->once()
|
||||
->andReturn(json_decode(File::get(test_path('blobs/lemonsqueezy/license-validated-successful.json'))));
|
||||
|
||||
self::assertTrue($this->service->getStatus()->isValid());
|
||||
self::assertTrue(Cache::has('license_status'));
|
||||
}
|
||||
|
||||
public function testGetLicenseStatusValidatesWithApiWithInvalidLicense(): void
|
||||
{
|
||||
/** @var License $license */
|
||||
$license = License::factory()->create();
|
||||
|
||||
self::assertFalse(Cache::has('license_status'));
|
||||
|
||||
$exception = Mockery::mock(ClientException::class, [
|
||||
'getResponse' => Mockery::mock(ResponseInterface::class, [
|
||||
'getStatusCode' => Response::HTTP_NOT_FOUND,
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->apiClient
|
||||
->shouldReceive('post')
|
||||
->with('licenses/validate', [
|
||||
'license_key' => $license->key,
|
||||
'instance_id' => $license->instance->id,
|
||||
])
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
self::assertFalse($this->service->getStatus()->isValid());
|
||||
self::assertTrue(Cache::has('license_status'));
|
||||
}
|
||||
}
|
|
@ -54,7 +54,7 @@ class ForceHttpsTest extends TestCase
|
|||
$this->url->shouldReceive('forceScheme')->with('https')->never();
|
||||
|
||||
$request = Mockery::mock(Request::class);
|
||||
$request->shouldReceive('setTrustedProxies')->never();
|
||||
$request->shouldNotReceive('setTrustedProxies');
|
||||
|
||||
$response = Mockery::mock(Response::class);
|
||||
$next = static fn () => $response;
|
||||
|
|
30
tests/blobs/lemonsqueezy/license-activated-successful.json
Normal file
30
tests/blobs/lemonsqueezy/license-activated-successful.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"activated": true,
|
||||
"error": null,
|
||||
"license_key": {
|
||||
"id": 1,
|
||||
"status": "active",
|
||||
"key": "38b1460a-5104-4067-a91d-77b872934d51",
|
||||
"activation_limit": 1,
|
||||
"activation_usage": null,
|
||||
"created_at": "2021-03-25 11:10:18",
|
||||
"expires_at": null
|
||||
},
|
||||
"instance": {
|
||||
"id": "47596ad9-a811-4ebf-ac8a-03fc7b6d2a17",
|
||||
"name": "Test",
|
||||
"created_at": "2021-04-06 14:08:46"
|
||||
},
|
||||
"meta": {
|
||||
"store_id": 42,
|
||||
"order_id": 2,
|
||||
"order_item_id": 3,
|
||||
"product_id": 4,
|
||||
"product_name": "Koel Plus",
|
||||
"variant_id": 5,
|
||||
"variant_name": "Default",
|
||||
"customer_id": 6,
|
||||
"customer_name": "Luke Skywalker",
|
||||
"customer_email": "luke@skywalker.com"
|
||||
}
|
||||
}
|
25
tests/blobs/lemonsqueezy/license-deactivated-successful.json
Normal file
25
tests/blobs/lemonsqueezy/license-deactivated-successful.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"deactivated": true,
|
||||
"error": null,
|
||||
"license_key": {
|
||||
"id": 1,
|
||||
"status": "active",
|
||||
"key": "38b1460a-5104-4067-a91d-77b872934d51",
|
||||
"activation_limit": 5,
|
||||
"activation_usage": 0,
|
||||
"created_at": "2021-03-25 11:10:18",
|
||||
"expires_at": null
|
||||
},
|
||||
"meta": {
|
||||
"store_id": 42,
|
||||
"order_id": 2,
|
||||
"order_item_id": 3,
|
||||
"product_id": 4,
|
||||
"product_name": "Koel Plus",
|
||||
"variant_id": 5,
|
||||
"variant_name": "Default",
|
||||
"customer_id": 6,
|
||||
"customer_name": "Luke Skywalker",
|
||||
"customer_email": "luke@skywalker.com"
|
||||
}
|
||||
}
|
4
tests/blobs/lemonsqueezy/license-invalid.json
Normal file
4
tests/blobs/lemonsqueezy/license-invalid.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"activated": false,
|
||||
"error": "license_key not found."
|
||||
}
|
30
tests/blobs/lemonsqueezy/license-validated-successful.json
Normal file
30
tests/blobs/lemonsqueezy/license-validated-successful.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"valid": true,
|
||||
"error": null,
|
||||
"license_key": {
|
||||
"id": 1,
|
||||
"status": "active",
|
||||
"key": "38b1460a-5104-4067-a91d-77b872934d51",
|
||||
"activation_limit": 1,
|
||||
"activation_usage": 5,
|
||||
"created_at": "2021-03-25 11:10:18",
|
||||
"expires_at": "2022-03-25 11:10:18"
|
||||
},
|
||||
"instance": {
|
||||
"id": "47596ad9-a811-4ebf-ac8a-03fc7b6d2a17",
|
||||
"name": "Test",
|
||||
"created_at": "2021-04-06 14:08:46"
|
||||
},
|
||||
"meta": {
|
||||
"store_id": 1,
|
||||
"order_id": 2,
|
||||
"order_item_id": 3,
|
||||
"product_id": 4,
|
||||
"product_name": "Koel Plus",
|
||||
"variant_id": 5,
|
||||
"variant_name": "Default",
|
||||
"customer_id": 6,
|
||||
"customer_name": "Luke Skywalker",
|
||||
"customer_email": "luke@skywalker.com"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue