feat(plus): add tests for License service

This commit is contained in:
Phan An 2024-01-11 17:52:55 +01:00
parent 10eed05543
commit 6b23f85b90
16 changed files with 407 additions and 26 deletions

View file

@ -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.');

View file

@ -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());
}
}

View file

@ -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
{

View file

@ -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 = [

View file

@ -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')

View file

@ -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 Koels 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());

View 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,
];
}
}

View file

@ -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();

View file

@ -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();

View file

@ -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();

View 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 Koels 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'));
}
}

View file

@ -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;

View 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"
}
}

View 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"
}
}

View file

@ -0,0 +1,4 @@
{
"activated": false,
"error": "license_key not found."
}

View 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"
}
}