diff --git a/cron/flexiapi.debian b/cron/flexiapi.debian
index 73a4447..a44db02 100644
--- a/cron/flexiapi.debian
+++ b/cron/flexiapi.debian
@@ -1,8 +1,9 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
-sudo -su www-data && php artisan digest:clear-nonces 60
-sudo -su www-data && php artisan accounts:clear-api-keys 60
sudo -su www-data && php artisan accounts:clear-accounts-tombstones 7 --apply
+sudo -su www-data && php artisan accounts:clear-api-keys 60
+sudo -su www-data && php artisan accounts:clear-files 30 --apply
sudo -su www-data && php artisan accounts:clear-unconfirmed 30 --apply
+sudo -su www-data && php artisan digest:clear-nonces 60
sudo -su www-data && php artisan spaces:expiration-emails
diff --git a/cron/flexiapi.redhat b/cron/flexiapi.redhat
index af3c529..1954c82 100644
--- a/cron/flexiapi.redhat
+++ b/cron/flexiapi.redhat
@@ -1,8 +1,9 @@
#!/bin/sh
cd /opt/belledonne-communications/share/flexisip-account-manager/flexiapi/
-php artisan digest:clear-nonces 60
-php artisan accounts:clear-api-keys 60
php artisan accounts:clear-accounts-tombstones 7 --apply
+php artisan accounts:clear-api-keys 60
+php artisan accounts:clear-files 30 --apply
php artisan accounts:clear-unconfirmed 30 --apply
+php artisan digest:clear-nonces 60
php artisan spaces:expiration-emails
\ No newline at end of file
diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php
index e193c2b..df15c77 100644
--- a/flexiapi/app/Account.php
+++ b/flexiapi/app/Account.php
@@ -135,6 +135,18 @@ class Account extends Authenticatable
return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id');
}
+ public function files()
+ {
+ return $this->hasMany(AccountFile::class)->latest();
+ }
+
+ public function voicemails()
+ {
+ return $this->hasMany(AccountFile::class)
+ ->whereIn('content_type', AccountFile::VOICEMAIL_CONTENTTYPES)
+ ->latest();
+ }
+
public function vcardsStorage()
{
return $this->hasMany(VcardStorage::class);
diff --git a/flexiapi/app/AccountFile.php b/flexiapi/app/AccountFile.php
new file mode 100644
index 0000000..404f28f
--- /dev/null
+++ b/flexiapi/app/AccountFile.php
@@ -0,0 +1,52 @@
+getPathAttribute());
+ });
+ }
+
+ public function account()
+ {
+ return $this->belongsTo(Account::class);
+ }
+
+ public function getMaxUploadSizeAttribute(): ?int
+ {
+ return maxUploadSize();
+ }
+
+ public function getUploadUrlAttribute(): ?string
+ {
+ return route('file.upload', $this->attributes['id']);
+ }
+
+ public function getPathAttribute(): string
+ {
+ return self::FILES_PATH . '/' . $this->attributes['name'];
+ }
+
+ public function getDownloadUrlAttribute(): ?string
+ {
+ return !empty($this->attributes['name'])
+ && !empty($this->attributes['id'])
+ ? route('file.show', [$this->attributes['id'], $this->attributes['name']])
+ : null;
+ }
+}
diff --git a/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php b/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php
index 4493e08..1fed49d 100644
--- a/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php
+++ b/flexiapi/app/Console/Commands/Accounts/ClearAccountsTombstones.php
@@ -29,11 +29,6 @@ class ClearAccountsTombstones extends Command
protected $signature = 'accounts:clear-accounts-tombstones {days} {--apply}';
protected $description = 'Clear deleted accounts tombstones after n days';
- public function __construct()
- {
- parent::__construct();
- }
-
public function handle()
{
$tombstones = AccountTombstone::where(
diff --git a/flexiapi/app/Console/Commands/Accounts/ClearFiles.php b/flexiapi/app/Console/Commands/Accounts/ClearFiles.php
new file mode 100644
index 0000000..e6a7ac5
--- /dev/null
+++ b/flexiapi/app/Console/Commands/Accounts/ClearFiles.php
@@ -0,0 +1,35 @@
+subDays($this->argument('days'))->toDateTimeString()
+ );
+
+ $count = $files->count();
+
+ if ($this->option('apply')) {
+ $this->info($count . ' files in deletion…');
+ $files->delete();
+ $this->info($count . ' files deleted');
+
+ return Command::SUCCESS;
+ }
+
+ $this->info($count . ' files to delete');
+ return Command::SUCCESS;
+ }
+}
diff --git a/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php b/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php
index 50d333e..f1d2a1e 100644
--- a/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php
+++ b/flexiapi/app/Console/Commands/Accounts/ClearUnconfirmed.php
@@ -29,11 +29,6 @@ class ClearUnconfirmed extends Command
protected $signature = 'accounts:clear-unconfirmed {days} {--apply} {--and-confirmed}';
protected $description = 'Clear unconfirmed accounts after n days';
- public function __construct()
- {
- parent::__construct();
- }
-
public function handle()
{
$accounts = Account::where(
diff --git a/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php b/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php
index 9e1c674..5f405c8 100644
--- a/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php
+++ b/flexiapi/app/Console/Commands/Accounts/CreateAdminTest.php
@@ -30,11 +30,6 @@ class CreateAdminTest extends Command
protected $signature = 'accounts:create-admin-test';
protected $description = 'Create a test admin account, only for tests purpose';
- public function __construct()
- {
- parent::__construct();
- }
-
public function handle()
{
$username = 'admin_test';
diff --git a/flexiapi/app/Console/Commands/Accounts/Seed.php b/flexiapi/app/Console/Commands/Accounts/Seed.php
index f54cbf6..889ebf4 100644
--- a/flexiapi/app/Console/Commands/Accounts/Seed.php
+++ b/flexiapi/app/Console/Commands/Accounts/Seed.php
@@ -28,11 +28,6 @@ class Seed extends Command
protected $signature = 'accounts:seed {json-file-path}';
protected $description = 'Seed some accounts from a JSON file';
- public function __construct()
- {
- parent::__construct();
- }
-
public function handle()
{
$file = $this->argument('json-file-path');
diff --git a/flexiapi/app/Console/Commands/Accounts/SetAdmin.php b/flexiapi/app/Console/Commands/Accounts/SetAdmin.php
index 157cf46..d6d0540 100644
--- a/flexiapi/app/Console/Commands/Accounts/SetAdmin.php
+++ b/flexiapi/app/Console/Commands/Accounts/SetAdmin.php
@@ -28,11 +28,6 @@ class SetAdmin extends Command
protected $signature = 'accounts:set-admin {id}';
protected $description = 'Give the admin role to an account';
- public function __construct()
- {
- parent::__construct();
- }
-
public function handle()
{
$account = Account::withoutGlobalScopes()->where('id', $this->argument('id'))->first();
diff --git a/flexiapi/app/Helpers/Utils.php b/flexiapi/app/Helpers/Utils.php
index f61d0a8..7ee62c2 100644
--- a/flexiapi/app/Helpers/Utils.php
+++ b/flexiapi/app/Helpers/Utils.php
@@ -17,6 +17,7 @@
along with this program. If not, see .
*/
+use Illuminate\Support\Facades\App;
use Illuminate\Support\Str;
use App\Account;
@@ -30,13 +31,13 @@ use Illuminate\Support\Facades\DB;
function space(): ?Space
{
- return is_object(request()->space) ? request()->space :null;
+ return is_object(request()->space) ? request()->space : null;
}
function passwordAlgorithms(): array
{
return [
- 'MD5' => 'md5',
+ 'MD5' => 'md5',
'SHA-256' => 'sha256',
];
}
@@ -100,7 +101,7 @@ function markdownDocumentationView(string $view): string
$converter->getEnvironment()->addExtension(new TableOfContentsExtension());
return (string) $converter->convert(
- (string)view($view, [
+ (string) view($view, [
'app_name' => space()->name
])->render()
);
@@ -161,6 +162,19 @@ function resolveDomain(Request $request): string
: $request->space->domain;
}
+function maxUploadSize(): ?int
+{
+ $uploadMaxSizeInBytes = ini_parse_quantity(ini_get('upload_max_filesize'));
+ if ($uploadMaxSizeInBytes > 0) {
+ return $uploadMaxSizeInBytes / 1024;
+ }
+
+ $postMaxSizeInBytes = ini_parse_quantity(ini_get('post_max_size'));
+ if ($postMaxSizeInBytes > 0) {
+ return $postMaxSizeInBytes / 1024;
+ }
+}
+
function captchaConfigured(): bool
{
return env('HCAPTCHA_SECRET', false) != false || env('HCAPTCHA_SITEKEY', false) != false;
@@ -205,7 +219,7 @@ function validateIsoDate($attribute, $value, $parameters, $validator): bool
// Regex from https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/
: '/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/';
- return (bool)preg_match($regex, $value);
+ return (bool) preg_match($regex, $value);
}
/**
diff --git a/flexiapi/app/Http/Controllers/Api/Account/FileController.php b/flexiapi/app/Http/Controllers/Api/Account/FileController.php
new file mode 100644
index 0000000..6c61b9f
--- /dev/null
+++ b/flexiapi/app/Http/Controllers/Api/Account/FileController.php
@@ -0,0 +1,51 @@
+name != $name) {
+ abort(404);
+ }
+
+ return Storage::download($file->path);
+ }
+
+ public function upload(Request $request, string $uuid)
+ {
+ $file = AccountFile::findOrFail($uuid);
+
+ if (!empty($file->name)) {
+ abort(404);
+ }
+
+ $request->validate([
+ 'file' => 'required|file|mimetypes:' . $file->content_type
+ ]);
+
+ $uploadedFile = $request->file('file');
+ $name = Str::random(8) . '_' . $uploadedFile->getClientOriginalName();
+
+ if ($uploadedFile->storeAs('files', $name)) {
+ $file->name = $name;
+ $file->size = $uploadedFile->getSize();
+ $file->uploaded_at = Carbon::now();
+ $file->save();
+
+ return $file;
+ }
+
+ abort(503);
+ }
+}
diff --git a/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php b/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php
new file mode 100644
index 0000000..5039121
--- /dev/null
+++ b/flexiapi/app/Http/Controllers/Api/Account/VoicemailController.php
@@ -0,0 +1,30 @@
+index($request, $request->user()->id);
+ }
+
+ public function store(Request $request)
+ {
+ return (new AdminVoicemailController)->store($request, $request->user()->id);
+ }
+
+ public function show(Request $request, string $uuid)
+ {
+ return (new AdminVoicemailController)->show($request, $request->user()->id, $uuid);
+ }
+
+ public function destroy(Request $request, string $uuid)
+ {
+ return (new AdminVoicemailController)->destroy($request, $request->user()->id, $uuid);
+ }
+}
diff --git a/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php b/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php
new file mode 100644
index 0000000..e2db717
--- /dev/null
+++ b/flexiapi/app/Http/Controllers/Api/Admin/Account/VoicemailController.php
@@ -0,0 +1,50 @@
+voicemails;
+ }
+
+ public function store(Request $request, int $accountId)
+ {
+ $account = Account::findOrFail($accountId);
+
+ $request->validate([
+ 'sip_from' => 'nullable|starts_with:sip',
+ 'content_type' => [
+ 'required',
+ Rule::in(AccountFile::VOICEMAIL_CONTENTTYPES),
+ ]
+ ]);
+
+ $voicemail = new AccountFile;
+ $voicemail->account_id = $account->id;
+ $voicemail->sip_from = $request->get('sip_from');
+ $voicemail->content_type = $request->get('content_type');
+ $voicemail->save();
+
+ $voicemail->append(['upload_url', 'max_upload_size']);
+
+ return $voicemail;
+ }
+
+ public function show(Request $request, int $accountId, string $uuid)
+ {
+ return Account::findOrFail($accountId)->voicemails()->where('id', $uuid)->firstOrFail();
+ }
+
+ public function destroy(Request $request, int $accountId, string $uuid)
+ {
+ return Account::findOrFail($accountId)->voicemails()->where('id', $uuid)->delete();
+ }
+}
diff --git a/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php b/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php
new file mode 100644
index 0000000..6be05d2
--- /dev/null
+++ b/flexiapi/database/migrations/2025_10_20_093414_create_account_files_table.php
@@ -0,0 +1,29 @@
+uuid('id')->primary();
+ $table->integer('account_id')->unsigned()->nullable();
+ $table->foreign('account_id')->references('id')
+ ->on('accounts')->onDelete('cascade');
+ $table->string('name')->nullable();
+ $table->integer('size')->nullable();
+ $table->string('content_type')->index();
+ $table->text('sip_from')->nullable();
+ $table->dateTime('uploaded_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('account_files');
+ }
+};
diff --git a/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php b/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php
new file mode 100644
index 0000000..7e4f505
--- /dev/null
+++ b/flexiapi/resources/views/api/documentation/accounts/voicemail.blade.php
@@ -0,0 +1,57 @@
+## Voicemails
+
+### `GET /accounts/{id/me}/voicemails`
+Admin
+User
+
+Return the currently stored voicemails
+
+### `GET /accounts/{id/me}/voicemails/{uuid}`
+Admin
+User
+
+```
+{
+ id: '{uuid}',
+ sip_from: '{sip_address}',
+ get_url: 'https://{the file_url}',
+ file_size: 2451400, // the file size, in bytes
+ content_type: 'audio/{format}',
+ created_at: '2025-10-09T12:59:32Z',
+ uploaded_at: '2025-10-09T12:59:40Z'
+}
+```
+
+Return a stored voicemail
+
+### `POST /accounts/{id/me}/voicemails`
+Admin
+User
+
+Create a new voicemail slot
+
+JSON parameters:
+
+* `sip_from`, mandatory, a valid SIP address
+* `content_type`, mandatory, the content type of the audio file to upload, must be `audio/opus` or `audio/wav`
+
+This endpoint will return the following JSON:
+
+```
+{
+ id: '{uuid}',
+ sip_from: '{sip_address}',
+ upload_url: 'https://{upload_service_unique_url}', // unique URL generated to upload the audio file
+ download_url: 'https://{download_service_unique_url}', // unique URL generated to download the audio file, null before upload
+ max_upload_size: 3000000, // here 3MB file size limit, in bytes
+ content_type: 'audio/{format}',
+ created_at: '2025-10-09T12:59:32Z', // time of the slot creation
+ uploaded_at: null // time when the slot was filled with the audio file
+}
+```
+
+### `DELETE /accounts/{id/me}/voicemails/{uuid}`
+Admin
+User
+
+Delete a stored voicemail, if the file is managed by the platform it will be deleted as well
\ No newline at end of file
diff --git a/flexiapi/resources/views/api/documentation_markdown.blade.php b/flexiapi/resources/views/api/documentation_markdown.blade.php
index 5b5f5a0..4d390df 100644
--- a/flexiapi/resources/views/api/documentation_markdown.blade.php
+++ b/flexiapi/resources/views/api/documentation_markdown.blade.php
@@ -39,6 +39,23 @@ Returns `pong`
@include('api.documentation.accounts.vcards_storage')
+@include('api.documentation.accounts.voicemail')
+
+## File Upload
+
+### `POST /files/{uuid}`
+User
+
+Upload a file to a previously created slot. This endpoint will directly be returned when creating the upload slot in the `upload_url` parameter.
+
+Related endpoints:
+
+* [Voicemails](#voicemails)
+
+HTTP [Form-Data](https://developer.mozilla.org/fr/docs/Web/API/FormData) parameters:
+
+* `file` **required**, the file to upload, must have the same `content_type` as requested in the slot
+
## Messages
### `POST /messages`
diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php
index 5a988ee..f1728da 100644
--- a/flexiapi/routes/api.php
+++ b/flexiapi/routes/api.php
@@ -18,11 +18,13 @@
*/
use App\Http\Controllers\Api\Account\VcardsStorageController;
+use App\Http\Controllers\Api\Account\VoicemailController;
use App\Http\Controllers\Api\Admin\Account\ActionController;
use App\Http\Controllers\Api\Admin\Account\CardDavCredentialsController;
use App\Http\Controllers\Api\Admin\Account\ContactController;
use App\Http\Controllers\Api\Admin\Account\DictionaryController;
use App\Http\Controllers\Api\Admin\Account\TypeController;
+use App\Http\Controllers\Api\Admin\Account\VoicemailController as AdminVoicemailController;
use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Api\Admin\ContactsListController;
use App\Http\Controllers\Api\Admin\ExternalAccountController;
@@ -55,10 +57,12 @@ Route::post('accounts/auth_token', 'Api\Account\AuthTokenController@store');
Route::get('accounts/me/api_key/{auth_token}', 'Api\Account\ApiKeyController@generateFromToken')->middleware('cookie', 'cookie.encrypt');
Route::get('phone_countries', 'Api\PhoneCountryController@index');
+Route::get('files/{uuid}/{name}', 'Api\Account\FileController@show')->name('file.show');
Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blocked']], function () {
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\Account\AuthTokenController@attach');
Route::post('account_creation_tokens/consume', 'Api\Account\CreationTokenController@consume');
+ Route::post('files/{uuid}', 'Api\Account\FileController@upload')->name('file.upload');
Route::post('push_notification', 'Api\Account\PushNotificationController@push');
@@ -86,6 +90,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::get('contacts', 'Api\Account\ContactController@index');
Route::apiResource('vcards-storage', VcardsStorageController::class);
+ Route::apiResource('voicemails', VoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
});
Route::group(['middleware' => ['auth.admin']], function () {
@@ -152,6 +157,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::apiResource('accounts/{id}/actions', ActionController::class);
Route::apiResource('account_types', TypeController::class);
Route::apiResource('accounts/{account_id}/vcards-storage', AdminVcardsStorageController::class);
+ Route::apiResource('accounts/{id}/voicemails', AdminVoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]);
Route::apiResource('contacts_lists', ContactsListController::class);
Route::prefix('contacts_lists')->controller(ContactsListController::class)->group(function () {
diff --git a/flexiapi/tests/Feature/ApiSpaceTest.php b/flexiapi/tests/Feature/ApiSpaceTest.php
index 4300988..4b840be 100644
--- a/flexiapi/tests/Feature/ApiSpaceTest.php
+++ b/flexiapi/tests/Feature/ApiSpaceTest.php
@@ -21,7 +21,6 @@ namespace Tests\Feature;
use App\Account;
use App\Space;
-use Carbon\Carbon;
use Tests\TestCase;
class ApiSpaceTest extends TestCase
diff --git a/flexiapi/tests/Feature/ApiVoicemailTest.php b/flexiapi/tests/Feature/ApiVoicemailTest.php
new file mode 100644
index 0000000..829426d
--- /dev/null
+++ b/flexiapi/tests/Feature/ApiVoicemailTest.php
@@ -0,0 +1,157 @@
+.
+*/
+
+namespace Tests\Feature;
+
+use App\Account;
+use Illuminate\Http\UploadedFile;
+use Tests\TestCase;
+
+class ApiVoicemailTest extends TestCase
+{
+ protected $route = '/api/accounts/me/voicemails';
+ protected $uploadRoute = '/api/files/';
+
+ public function testAccount()
+ {
+ $account = Account::factory()->create();
+ $account->generateUserApiKey();
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $this->route, [])
+ ->assertJsonValidationErrors(['content_type']);
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $this->route, [
+ 'content_type' => 'image/jpg'
+ ])
+ ->assertJsonValidationErrors(['content_type']);
+
+ $accountFile = $this->keyAuthenticated($account)
+ ->json('POST', $this->route, [
+ 'content_type' => 'audio/opus'
+ ])->assertCreated();
+
+ $uuid = $accountFile->json()['id'];
+
+ $this->keyAuthenticated($account)
+ ->get($this->route)
+ ->assertJsonFragment(['id' => $uuid]);
+
+ $this->keyAuthenticated($account)
+ ->get($this->route . '/' . $uuid)
+ ->assertJsonFragment([
+ 'id' => $uuid,
+ 'name' => null,
+ 'size' => null,
+ 'sip_from' => null,
+ 'uploaded_at' => null,
+ ]);
+
+ $this->keyAuthenticated($account)
+ ->delete($this->route . '/' . $uuid)
+ ->assertOk();
+
+ $this->keyAuthenticated($account)
+ ->get($this->route . '/' . $uuid)
+ ->assertNotFound();
+ }
+
+ public function testAdmin()
+ {
+ $admin = Account::factory()->admin()->create();
+ $admin->generateUserApiKey();
+
+ $account = Account::factory()->create();
+ $account->generateUserApiKey();
+
+ $adminRoute = '/api/accounts/' . $account->id . '/voicemails';
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $adminRoute, [])
+ ->assertForbidden();
+
+ $accountFile = $this->keyAuthenticated($admin)
+ ->json('POST', $adminRoute, [
+ 'content_type' => 'audio/opus'
+ ])->assertCreated();
+
+ $uuid = $accountFile->json()['id'];
+
+ $this->keyAuthenticated($admin)
+ ->get($adminRoute . '/' . $uuid)
+ ->assertJsonFragment(['id' => $uuid]);
+
+ $this->keyAuthenticated($admin)
+ ->get($adminRoute . '/' . $uuid)
+ ->assertJsonFragment([
+ 'id' => $uuid
+ ]);
+
+ $this->keyAuthenticated($admin)
+ ->delete($adminRoute . '/' . $uuid)
+ ->assertOk();
+
+ $this->keyAuthenticated($admin)
+ ->get($adminRoute . '/' . $uuid)
+ ->assertNotFound();
+ }
+
+ public function testUpload()
+ {
+ $account = Account::factory()->create();
+ $account->generateUserApiKey();
+
+ $accountFile = $this->keyAuthenticated($account)
+ ->json('POST', $this->route, [
+ 'content_type' => 'audio/opus'
+ ])->assertCreated();
+
+ $uuid = $accountFile->json()['id'];
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $this->uploadRoute . $uuid, [
+ 'file' => UploadedFile::fake()->image('photo.jpg')
+ ])->assertJsonValidationErrors(['file']);
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $this->uploadRoute . $uuid, [
+ 'file' => UploadedFile::fake()->create('audio.wav', 500, 'audio/wav')
+ ])->assertJsonValidationErrors(['file']);
+
+ $file = $this->keyAuthenticated($account)
+ ->json('POST', $this->uploadRoute . $uuid, data: [
+ 'file' => UploadedFile::fake()->create('audio.opus', 500, 'audio/opus')
+ ])->assertOk();
+
+ $this->keyAuthenticated($account)
+ ->json('POST', $this->uploadRoute . $uuid, data: [
+ 'file' => UploadedFile::fake()->create('audio.opus', 500, 'audio/opus')
+ ])->assertNotFound();
+
+ $this->head($file->json()['download_url'])->assertOk();
+
+ // Delete the file
+ $this->keyAuthenticated($account)
+ ->delete($this->route . '/' . $file->json()['id'])
+ ->assertOk();
+
+ $this->head($file->json()['download_url'])->assertNotFound();
+ }
+}