feat(webrtc): implement IceCandidateProviderInterface with Metered and ConvexSol drivers

- Add Metered and ConvexSol (CoTURN) provider drivers with response validation and caching
- Add IceCandidateManager with automatic failover and structured logging
- Add IceCandidateProviderInterface and DTOs conforming to RTCIceServer format
This commit is contained in:
kushal.saha 2026-08-26 12:44:41 +00:00
parent 0aec99f575
commit 3aca6d90f3
23 changed files with 1167 additions and 48 deletions

View File

@ -70,10 +70,20 @@ MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
MICROSOFT_TENANT_ID=common
# ICE Candidate / TURN Server Configurations
ICE_CANDIDATE_PROVIDER=metered
ICE_FALLBACK_PROVIDER=convexsol
# Metered TURN Server Credentials
METERED_URL=
METERED_KEY=
# ConvexSol TURN / STUN Server Credentials
CONVEXSOL_USERNAME=
CONVEXSOL_PASSWORD=
CONVEXSOL_TURN_URLS=
CONVEXSOL_STUN_URLS=
# Interview Configurations
MAX_INTERVIEWER=2
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings

View File

@ -0,0 +1,13 @@
<?php
namespace App\Contracts;
use App\DTOs\IceCandidatesDto;
interface IceCandidateProviderInterface
{
/**
* Retrieve the ICE candidate / TURN / STUN server configurations.
*/
public function getIceCandidates(): IceCandidatesDto;
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\DTOs;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class IceCandidateDto implements Arrayable, JsonSerializable
{
/**
* @param string|array<string> $urls
*/
public function __construct(
public readonly string|array $urls,
public readonly ?string $username = null,
public readonly ?string $credential = null,
public readonly ?string $credentialType = null,
) {}
public static function fromArray(array $data): self
{
$rawUrls = $data['urls'] ?? $data['url'] ?? '';
return new self(
urls: is_array($rawUrls) ? array_values($rawUrls) : (string) $rawUrls,
username: isset($data['username']) ? (string) $data['username'] : null,
credential: isset($data['credential']) ? (string) $data['credential'] : (isset($data['password']) ? (string) $data['password'] : null),
credentialType: isset($data['credentialType']) ? (string) $data['credentialType'] : null,
);
}
public function toArray(): array
{
return array_filter([
'urls' => $this->urls,
'username' => $this->username,
'credential' => $this->credential,
'credentialType' => $this->credentialType,
], fn ($value) => $value !== null);
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\DTOs;
use ArrayIterator;
use Countable;
use Illuminate\Contracts\Support\Arrayable;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
class IceCandidatesDto implements Arrayable, Countable, IteratorAggregate, JsonSerializable
{
/**
* @param array<IceCandidateDto> $servers
*/
public function __construct(
public readonly array $servers = []
) {}
public static function fromArray(array $items): self
{
$servers = [];
foreach ($items as $item) {
if ($item instanceof IceCandidateDto) {
$servers[] = $item;
} elseif (is_array($item)) {
$servers[] = IceCandidateDto::fromArray($item);
}
}
return new self($servers);
}
public static function defaultStun(): self
{
return new self([
new IceCandidateDto(urls: [
'stun:stun.l.google.com:19302',
'stun:stun.cloudflare.com:3478',
]),
]);
}
public function toArray(): array
{
return array_map(fn (IceCandidateDto $server) => $server->toArray(), $this->servers);
}
public function jsonSerialize(): array
{
return $this->toArray();
}
public function count(): int
{
return count($this->servers);
}
public function isEmpty(): bool
{
return empty($this->servers);
}
public function getIterator(): Traversable
{
return new ArrayIterator($this->servers);
}
}

View File

@ -1,65 +1,36 @@
<?php
namespace App\Http\Controllers;
use App\Contracts\IceCandidateProviderInterface;
use App\Http\Resources\Interview\IceCandidatesResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class IceServerController extends Controller
{
public function __invoke(): JsonResponse
public function __construct(
protected IceCandidateProviderInterface $iceCandidateProvider
) {}
public function __invoke(): JsonResponse|IceCandidatesResource
{
if (! Auth::check() && ! session()->has('candidate_interview_id')) {
return response()->json(['error' => 'Unauthenticated access.'], 401);
}
try {
$baseUrl = config(
"services.metered.url",
);
$apiKey = config(
"services.metered.key",
);
$url = $baseUrl;
if ($apiKey && !str_contains($url, "apiKey=")) {
$separator = str_contains($url, "?") ? "&" : "?";
$url .= $separator . "apiKey=" . urlencode($apiKey);
}
$data = Cache::remember('metered_credentials_cache', now()->addMinute(10), function() use($url) {
$response = Http::timeout(5)->get($url);
if(!$response->successful())
{
Log::error(
"Metered API error response",
[
"status" => $response->status(),
"body" => $response->body(),
],
);
throw new \RuntimeException('Metered API Error');
}
return $response->json();
});
return response()->json($data);
$candidates = $this->iceCandidateProvider->getIceCandidates();
return new IceCandidatesResource($candidates);
} catch (\Throwable $e) {
Log::error(
"ICE servers fetch exception: " . $e->getMessage(),
);
Log::error('ICE servers fetch exception: '.$e->getMessage());
return response()->json(
[
"error" => "An error occurred while fetching ICE servers.",
"message" => $e->getMessage(),
],
500,
);
return response()->json([
'error' => 'An error occurred while fetching ICE servers.',
'message' => $e->getMessage(),
], 500);
}
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Resources\Interview;
use App\DTOs\IceCandidateDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IceCandidateResource extends JsonResource
{
/**
* Disable the default "data" wrapper.
*
* @var string|null
*/
public static $wrap = null;
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
if ($this->resource instanceof IceCandidateDto) {
return $this->resource->toArray();
}
if (is_array($this->resource)) {
return array_filter([
'urls' => $this->resource['urls'] ?? $this->resource['url'] ?? null,
'username' => $this->resource['username'] ?? null,
'credential' => $this->resource['credential'] ?? $this->resource['password'] ?? null,
'credentialType' => $this->resource['credentialType'] ?? null,
], fn ($value) => $value !== null);
}
return (array) $this->resource;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Interview;
use App\DTOs\IceCandidatesDto;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IceCandidatesResource extends JsonResource
{
/**
* Disable the default "data" wrapper.
*
* @var string|null
*/
public static $wrap = null;
/**
* Transform the resource into an array.
*
* @return array<int, array<string, mixed>>
*/
public function toArray(Request $request): array
{
if ($this->resource instanceof IceCandidatesDto) {
return $this->resource->toArray();
}
return (array) $this->resource;
}
}

View File

@ -5,6 +5,7 @@
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Microsoft\MicrosoftExtendSocialite;
@ -23,6 +24,14 @@ public function register(): void
*/
public function boot(): void
{
if (request()->server('HTTP_X_FORWARDED_PROTO') === 'https' || request()->isSecure()) {
URL::forceScheme('https');
if ($forwardedHost = request()->server('HTTP_X_FORWARDED_HOST')) {
URL::forceRootUrl("https://{$forwardedHost}");
}
}
Event::listen(
SocialiteWasCalled::class,
MicrosoftExtendSocialite::class

View File

@ -0,0 +1,38 @@
<?php
namespace App\Providers;
use App\Contracts\IceCandidateProviderInterface;
use App\Services\IceCandidate\IceCandidateManager;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class IceCandidateServiceProvider extends ServiceProvider implements DeferrableProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(IceCandidateManager::class, function ($app) {
return new IceCandidateManager($app);
});
$this->app->bind(IceCandidateProviderInterface::class, function ($app) {
return $app->make(IceCandidateManager::class);
});
}
/**
* Get the services provided by the provider.
*
* @return array<int, string>
*/
public function provides(): array
{
return [
IceCandidateManager::class,
IceCandidateProviderInterface::class,
];
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Services\IceCandidate;
use App\Contracts\IceCandidateProviderInterface;
use App\DTOs\IceCandidatesDto;
use App\Services\IceCandidate\Providers\ConvexSolIceCandidateProvider;
use App\Services\IceCandidate\Providers\MeteredIceCandidateProvider;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Manager;
use Throwable;
class IceCandidateManager extends Manager implements IceCandidateProviderInterface
{
public function getDefaultDriver(): string
{
return $this->config->get('services.ice.default', 'metered');
}
public function getFallbackDriver(): ?string
{
return $this->config->get('services.ice.fallback', 'convexsol');
}
/**
* Retrieve ICE candidates with automatic failover and structured logging.
*/
public function getIceCandidates(): IceCandidatesDto
{
$primaryDriver = $this->getDefaultDriver();
try {
return $this->driver($primaryDriver)->getIceCandidates();
} catch (Throwable $primaryException) {
$fallbackDriver = $this->getFallbackDriver();
if ($fallbackDriver && $fallbackDriver !== $primaryDriver) {
Log::warning("Primary ICE candidate provider [{$primaryDriver}] failed: {$primaryException->getMessage()}. Falling back to [{$fallbackDriver}].", [
'primary_driver' => $primaryDriver,
'fallback_driver' => $fallbackDriver,
'error' => $primaryException->getMessage(),
]);
try {
return $this->driver($fallbackDriver)->getIceCandidates();
} catch (Throwable $fallbackException) {
Log::error("Fallback ICE candidate provider [{$fallbackDriver}] also failed: {$fallbackException->getMessage()}. Using default STUN servers.", [
'primary_error' => $primaryException->getMessage(),
'fallback_error' => $fallbackException->getMessage(),
]);
return IceCandidatesDto::defaultStun();
}
}
Log::error("ICE candidate provider [{$primaryDriver}] failed without fallback: {$primaryException->getMessage()}. Using default STUN servers.", [
'error' => $primaryException->getMessage(),
]);
return IceCandidatesDto::defaultStun();
}
}
protected function createMeteredDriver(): IceCandidateProviderInterface
{
$config = $this->config->get('services.ice.providers.metered', [
'url' => $this->config->get('services.metered.url'),
'key' => $this->config->get('services.metered.key'),
]);
return new MeteredIceCandidateProvider($config);
}
protected function createConvexsolDriver(): IceCandidateProviderInterface
{
$config = $this->config->get('services.ice.providers.convexsol', [
'username' => $this->config->get('services.convexsol.username'),
'password' => $this->config->get('services.convexsol.password'),
'turn_urls' => $this->config->get('services.convexsol.turn_urls'),
'stun_urls' => $this->config->get('services.convexsol.stun_urls'),
]);
return new ConvexSolIceCandidateProvider($config);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Services\IceCandidate\Providers;
use App\Contracts\IceCandidateProviderInterface;
use App\DTOs\IceCandidatesDto;
use Illuminate\Support\Facades\Cache;
class ConvexSolIceCandidateProvider implements IceCandidateProviderInterface
{
public function __construct(
protected array $config = []
) {}
public function getIceCandidates(): IceCandidatesDto
{
$cacheKey = 'convexsol_coturn_credentials_cache_'.md5(serialize($this->config));
$cacheTtl = (int) ($this->config['cache_ttl'] ?? 600);
$servers = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () {
$list = [];
// 1. STUN servers
$stunUrls = $this->config['stun_urls'] ?? ['stun:stun.convexsol.com:3478', 'stun:stun.l.google.com:19302'];
if (is_string($stunUrls)) {
$stunUrls = array_filter(array_map('trim', explode(',', $stunUrls)));
}
if (! empty($stunUrls)) {
$list[] = [
'urls' => array_values($stunUrls),
];
}
// 2. TURN servers with username and password
$turnUrls = $this->config['turn_urls'] ?? ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'];
if (is_string($turnUrls)) {
$turnUrls = array_filter(array_map('trim', explode(',', $turnUrls)));
}
$username = $this->config['username'] ?? null;
$password = $this->config['password'] ?? null;
if (! empty($turnUrls)) {
$turnEntry = [
'urls' => array_values($turnUrls),
];
if ($username !== null) {
$turnEntry['username'] = $username;
}
if ($password !== null) {
$turnEntry['credential'] = $password;
}
$list[] = $turnEntry;
}
return $list;
});
return IceCandidatesDto::fromArray($servers);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Services\IceCandidate\Providers;
use App\Contracts\IceCandidateProviderInterface;
use App\DTOs\IceCandidatesDto;
use App\Services\IceCandidate\Validators\IceResponseValidator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class MeteredIceCandidateProvider implements IceCandidateProviderInterface
{
public function __construct(
protected array $config = []
) {}
public function getIceCandidates(): IceCandidatesDto
{
$baseUrl = $this->config['url'] ?? config('services.metered.url');
$apiKey = $this->config['key'] ?? config('services.metered.key');
$timeout = (int) ($this->config['timeout'] ?? 5);
$cacheTtl = (int) ($this->config['cache_ttl'] ?? 600);
if (empty($baseUrl)) {
throw new RuntimeException('Metered ICE provider URL is not configured.');
}
$url = $baseUrl;
if ($apiKey && ! str_contains($url, 'apiKey=')) {
$separator = str_contains($url, '?') ? '&' : '?';
$url .= $separator.'apiKey='.urlencode($apiKey);
}
$cacheKey = 'metered_credentials_cache_'.md5($url);
$data = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () use ($url, $timeout) {
$response = Http::timeout($timeout)->get($url);
if (! $response->successful()) {
Log::error('Metered API error response', [
'status' => $response->status(),
'body' => $response->body(),
]);
throw new RuntimeException('Metered API Error: '.$response->status());
}
$json = $response->json();
return IceResponseValidator::validate($json, 'Metered');
});
return IceCandidatesDto::fromArray($data);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services\IceCandidate\Validators;
use RuntimeException;
class IceResponseValidator
{
/**
* Validate raw API response payload and normalize into an array of server definitions.
*
* @return array<array>
*
* @throws RuntimeException
*/
public static function validate(mixed $data, string $providerName = 'ICE Provider'): array
{
if (! is_array($data)) {
throw new RuntimeException("{$providerName} API returned an invalid non-array response.");
}
// Support wrapped responses like ['iceServers' => [...]] or ['v' => [...]]
$servers = isset($data['iceServers']) && is_array($data['iceServers'])
? $data['iceServers']
: (isset($data['v']) && is_array($data['v']) ? $data['v'] : $data);
if (empty($servers) || ! is_array($servers)) {
throw new RuntimeException("{$providerName} API returned an empty or invalid list of ICE servers.");
}
$validServers = [];
$validProtocols = ['stun:', 'stuns:', 'turn:', 'turns:'];
foreach ($servers as $index => $server) {
if (! is_array($server)) {
throw new RuntimeException("{$providerName} server entry at index [{$index}] must be an object/array.");
}
$rawUrls = $server['urls'] ?? $server['url'] ?? null;
if (empty($rawUrls)) {
throw new RuntimeException("{$providerName} server entry at index [{$index}] is missing 'urls'.");
}
$urlList = is_array($rawUrls) ? $rawUrls : [$rawUrls];
foreach ($urlList as $url) {
if (! is_string($url) || trim($url) === '') {
throw new RuntimeException("{$providerName} URL at index [{$index}] must be a non-empty string.");
}
$hasValidProtocol = false;
foreach ($validProtocols as $protocol) {
if (str_starts_with(strtolower(trim($url)), $protocol)) {
$hasValidProtocol = true;
break;
}
}
if (! $hasValidProtocol) {
throw new RuntimeException("{$providerName} invalid ICE URL scheme [{$url}] at index [{$index}].");
}
}
$validServers[] = $server;
}
return $validServers;
}
}

View File

@ -1,7 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\IceCandidateServiceProvider;
return [
AppServiceProvider::class,
IceCandidateServiceProvider::class,
];

View File

@ -47,4 +47,32 @@
'key' => env('METERED_KEY'),
],
'convexsol' => [
'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')),
'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')),
'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'),
'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'),
],
'ice' => [
'default' => env('ICE_CANDIDATE_PROVIDER', env('ICE_PROVIDER', 'metered')),
'fallback' => env('ICE_FALLBACK_PROVIDER', 'convexsol'),
'providers' => [
'metered' => [
'url' => env('METERED_URL'),
'key' => env('METERED_KEY'),
'timeout' => (int) env('METERED_TIMEOUT', 5),
'cache_ttl' => (int) env('METERED_CACHE_TTL', 600),
],
'convexsol' => [
'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')),
'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')),
'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'),
'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'),
'timeout' => (int) env('CONVEXSOL_TIMEOUT', 5),
'cache_ttl' => (int) env('CONVEXSOL_CACHE_TTL', 600),
],
],
],
];

View File

@ -39,8 +39,8 @@
</div>
<div>
<x-label>Start Time (Timeline)</x-label>
<x-input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
<x-label for="scheduled_at">Start Time (Timeline)</x-label>
<x-input type="datetime-local" id="scheduled_at" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
</div>
<div>

View File

@ -0,0 +1,117 @@
<?php
namespace Tests\Feature;
use App\Models\Interview;
use App\Models\User;
use App\Services\IceCandidate\IceCandidateManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class IceServerControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->app->forgetInstance(IceCandidateManager::class);
}
public function test_unauthenticated_request_is_denied(): void
{
$response = $this->getJson(route('ice-servers'));
$response->assertStatus(401)
->assertJson(['error' => 'Unauthenticated access.']);
}
public function test_authenticated_user_can_fetch_ice_servers(): void
{
$user = User::create([
'name' => 'John Interviewer',
'email' => 'john@company.com',
'role' => 'user',
]);
Http::fake([
'*metered*' => Http::response([
['urls' => 'stun:stun.relay.metered.ca:80'],
[
'urls' => 'turn:standard.relay.metered.ca:80',
'username' => 'metered_usr',
'credential' => 'metered_pwd',
],
], 200),
]);
$response = $this->actingAs($user)->getJson(route('ice-servers'));
$response->assertStatus(200);
$data = $response->json();
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']);
$this->assertEquals('metered_usr', $data[1]['username']);
}
public function test_authenticated_candidate_session_can_fetch_ice_servers(): void
{
$interview = Interview::create([
'candidate_name' => 'Alice Candidate',
'candidate_email' => 'alice@gmail.com',
'candidate_phone' => '9876543210',
'temp_password' => 'Pass-123456',
'expires_at' => now()->addHours(2),
'language' => 'python',
'status' => 'scheduled',
'submission_unique_id' => 'alice-candidate_9876543210_1752000099',
]);
Http::fake([
'*metered*' => Http::response([
['urls' => 'stun:stun.relay.metered.ca:80'],
], 200),
]);
$response = $this->withSession(['candidate_interview_id' => $interview->id])
->getJson(route('ice-servers'));
$response->assertStatus(200);
$data = $response->json();
$this->assertCount(1, $data);
$this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']);
}
public function test_switches_to_convexsol_provider_via_configuration(): void
{
$user = User::create([
'name' => 'Admin User',
'email' => 'admin@company.com',
'role' => 'admin',
]);
config([
'services.ice.default' => 'convexsol',
'services.ice.providers.convexsol' => [
'username' => 'convex_corp_user',
'password' => 'convex_corp_pass',
'turn_urls' => ['turn:turn.convexsol.com:3478'],
'stun_urls' => ['stun:turn.convexsol.com:3478'],
],
]);
$response = $this->actingAs($user)->getJson(route('ice-servers'));
$response->assertStatus(200);
$data = $response->json();
$this->assertCount(2, $data);
$this->assertEquals(['stun:turn.convexsol.com:3478'], $data[0]['urls']);
$this->assertEquals('convex_corp_user', $data[1]['username']);
$this->assertEquals('convex_corp_pass', $data[1]['credential']);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Tests\Unit;
use App\Services\IceCandidate\Providers\ConvexSolIceCandidateProvider;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class ConvexSolIceCandidateProviderTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_builds_ice_servers_from_coturn_credentials(): void
{
$provider = new ConvexSolIceCandidateProvider([
'username' => 'convex_admin',
'password' => 'super_secret_password',
'turn_urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'],
'stun_urls' => ['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'],
]);
$candidates = $provider->getIceCandidates();
$this->assertCount(2, $candidates);
$array = $candidates->toArray();
$this->assertEquals(['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'], $array[0]['urls']);
$this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array[1]['urls']);
$this->assertEquals('convex_admin', $array[1]['username']);
$this->assertEquals('super_secret_password', $array[1]['credential']);
}
public function test_caches_coturn_credentials(): void
{
$provider = new ConvexSolIceCandidateProvider([
'username' => 'cached_user',
'password' => 'cached_pass',
'turn_urls' => 'turn:turn.convexsol.com:3478',
'stun_urls' => 'stun:turn.convexsol.com:3478',
]);
$candidates1 = $provider->getIceCandidates();
$candidates2 = $provider->getIceCandidates();
$this->assertCount(2, $candidates1);
$this->assertEquals($candidates1->toArray(), $candidates2->toArray());
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Tests\Unit;
use App\DTOs\IceCandidateDto;
use App\DTOs\IceCandidatesDto;
use PHPUnit\Framework\TestCase;
class IceCandidateDtoTest extends TestCase
{
public function test_ice_candidate_dto_instantiation_and_serialization(): void
{
$dto = new IceCandidateDto(
urls: 'stun:stun.l.google.com:19302',
username: null,
credential: null
);
$array = $dto->toArray();
$this->assertEquals(['urls' => 'stun:stun.l.google.com:19302'], $array);
$this->assertArrayNotHasKey('username', $array);
$this->assertArrayNotHasKey('credential', $array);
$this->assertEquals($array, $dto->jsonSerialize());
}
public function test_ice_candidate_dto_with_turn_credentials(): void
{
$dto = IceCandidateDto::fromArray([
'urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'],
'username' => 'convex_user',
'password' => 'secret_pass',
]);
$array = $dto->toArray();
$this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array['urls']);
$this->assertEquals('convex_user', $array['username']);
$this->assertEquals('secret_pass', $array['credential']);
}
public function test_ice_candidates_dto_collection_handling(): void
{
$candidates = IceCandidatesDto::fromArray([
['urls' => 'stun:stun.l.google.com:19302'],
['urls' => 'turn:turn.convexsol.com:3478', 'username' => 'user1', 'credential' => 'pass1'],
]);
$this->assertCount(2, $candidates);
$this->assertFalse($candidates->isEmpty());
$array = $candidates->toArray();
$this->assertCount(2, $array);
$this->assertEquals('stun:stun.l.google.com:19302', $array[0]['urls']);
$this->assertEquals('user1', $array[1]['username']);
$this->assertEquals('pass1', $array[1]['credential']);
$json = json_encode($candidates);
$this->assertStringContainsString('stun:stun.l.google.com:19302', $json);
$this->assertStringContainsString('turn:turn.convexsol.com:3478', $json);
}
public function test_default_stun_factory(): void
{
$default = IceCandidatesDto::defaultStun();
$this->assertCount(1, $default);
$array = $default->toArray();
$this->assertContains('stun:stun.l.google.com:19302', $array[0]['urls']);
$this->assertContains('stun:stun.cloudflare.com:3478', $array[0]['urls']);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Tests\Unit;
use App\Contracts\IceCandidateProviderInterface;
use App\DTOs\IceCandidatesDto;
use App\Services\IceCandidate\IceCandidateManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Tests\TestCase;
class IceCandidateManagerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
$this->app->forgetInstance(IceCandidateManager::class);
}
public function test_manager_resolves_metered_as_default_driver(): void
{
Http::fake([
'*metered*' => Http::response([
['urls' => 'stun:stun.relay.metered.ca:80'],
], 200),
]);
$manager = app(IceCandidateManager::class);
$candidates = $manager->getIceCandidates();
$this->assertCount(1, $candidates);
$this->assertEquals('stun:stun.relay.metered.ca:80', $candidates->toArray()[0]['urls']);
}
public function test_manager_resolves_convexsol_when_configured(): void
{
config([
'services.ice.default' => 'convexsol',
'services.ice.providers.convexsol' => [
'username' => 'convex_usr',
'password' => 'convex_pwd',
'turn_urls' => ['turn:turn.convexsol.com:3478'],
'stun_urls' => ['stun:turn.convexsol.com:3478'],
],
]);
$manager = app(IceCandidateManager::class);
$candidates = $manager->getIceCandidates();
$this->assertCount(2, $candidates);
$array = $candidates->toArray();
$this->assertEquals('convex_usr', $array[1]['username']);
$this->assertEquals('convex_pwd', $array[1]['credential']);
}
public function test_manager_falls_back_and_logs_warning_when_primary_fails(): void
{
Log::shouldReceive('warning')
->once()
->withArgs(function ($message, $context) {
return str_contains($message, 'Primary ICE candidate provider [metered] failed') &&
$context['primary_driver'] === 'metered' &&
$context['fallback_driver'] === 'convexsol';
});
// Fail primary Metered HTTP request
Http::fake([
'*metered*' => Http::response('Gateway Error', 502),
]);
config([
'services.ice.default' => 'metered',
'services.ice.fallback' => 'convexsol',
'services.ice.providers.convexsol' => [
'username' => 'fallback_user',
'password' => 'fallback_password',
'turn_urls' => ['turn:turn.convexsol.com:3478'],
'stun_urls' => ['stun:turn.convexsol.com:3478'],
],
]);
$manager = app(IceCandidateManager::class);
$candidates = $manager->getIceCandidates();
$this->assertCount(2, $candidates);
$array = $candidates->toArray();
$this->assertEquals('fallback_user', $array[1]['username']);
}
public function test_manager_falls_back_to_default_stun_and_logs_error_when_both_fail(): void
{
Log::shouldReceive('warning')->once();
Log::shouldReceive('error')
->once()
->withArgs(function ($message) {
return str_contains($message, 'Fallback ICE candidate provider [convexsol] also failed');
});
// Fail primary Metered HTTP request
Http::fake([
'*metered*' => Http::response('Gateway Error', 502),
]);
$manager = app(IceCandidateManager::class);
$manager->extend('convexsol', function () {
return new class implements IceCandidateProviderInterface
{
public function getIceCandidates(): IceCandidatesDto
{
throw new RuntimeException('ConvexSol Server Error');
}
};
});
config([
'services.ice.default' => 'metered',
'services.ice.fallback' => 'convexsol',
]);
$candidates = $manager->getIceCandidates();
$this->assertCount(1, $candidates);
$this->assertContains('stun:stun.l.google.com:19302', $candidates->toArray()[0]['urls']);
}
public function test_container_resolves_interface_to_manager(): void
{
$resolved = app(IceCandidateProviderInterface::class);
$this->assertInstanceOf(IceCandidateManager::class, $resolved);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Tests\Unit;
use App\DTOs\IceCandidateDto;
use App\DTOs\IceCandidatesDto;
use App\Http\Resources\Interview\IceCandidateResource;
use App\Http\Resources\Interview\IceCandidatesResource;
use Illuminate\Http\Request;
use Tests\TestCase;
class IceCandidatesResourceTest extends TestCase
{
public function test_ice_candidate_resource_transforms_individual_dto(): void
{
$dto = new IceCandidateDto(
urls: ['turn:turn.convexsol.com:3478'],
username: 'convex_user',
credential: 'password123'
);
$resource = new IceCandidateResource($dto);
$array = $resource->toArray(Request::create('/ice-servers', 'GET'));
$this->assertEquals(['turn:turn.convexsol.com:3478'], $array['urls']);
$this->assertEquals('convex_user', $array['username']);
$this->assertEquals('password123', $array['credential']);
}
public function test_ice_candidates_resource_formats_json_array_without_data_wrap(): void
{
$dto = new IceCandidatesDto([
new IceCandidateDto(urls: 'stun:stun.l.google.com:19302'),
new IceCandidateDto(
urls: ['turn:turn.convexsol.com:3478'],
username: 'convex_user',
credential: 'password123'
),
]);
$resource = new IceCandidatesResource($dto);
$request = Request::create('/ice-servers', 'GET');
$response = $resource->toResponse($request);
$this->assertEquals(200, $response->getStatusCode());
$data = $response->getData(true);
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals('stun:stun.l.google.com:19302', $data[0]['urls']);
$this->assertEquals('convex_user', $data[1]['username']);
$this->assertEquals('password123', $data[1]['credential']);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Tests\Unit;
use App\Services\IceCandidate\Validators\IceResponseValidator;
use PHPUnit\Framework\TestCase;
use RuntimeException;
class IceResponseValidatorTest extends TestCase
{
public function test_validates_standard_ice_server_array(): void
{
$payload = [
[
'urls' => 'stun:stun.relay.metered.ca:80',
],
[
'urls' => [
'turn:standard.relay.metered.ca:80',
'turns:standard.relay.metered.ca:443',
],
'username' => 'testuser',
'credential' => 'testpass',
],
];
$validated = IceResponseValidator::validate($payload, 'Metered');
$this->assertCount(2, $validated);
$this->assertEquals('stun:stun.relay.metered.ca:80', $validated[0]['urls']);
$this->assertEquals('testuser', $validated[1]['username']);
}
public function test_validates_wrapped_ice_servers_payload(): void
{
$payload = [
'iceServers' => [
[
'urls' => 'stun:stun.convexsol.com:3478',
],
],
];
$validated = IceResponseValidator::validate($payload, 'ConvexSol');
$this->assertCount(1, $validated);
$this->assertEquals('stun:stun.convexsol.com:3478', $validated[0]['urls']);
}
public function test_throws_exception_for_non_array_payload(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Metered API returned an invalid non-array response.');
IceResponseValidator::validate('invalid-string', 'Metered');
}
public function test_throws_exception_for_empty_array_payload(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Metered API returned an empty or invalid list of ICE servers.');
IceResponseValidator::validate([], 'Metered');
}
public function test_throws_exception_for_missing_urls_field(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Metered server entry at index [0] is missing 'urls'.");
IceResponseValidator::validate([
['username' => 'someuser'],
], 'Metered');
}
public function test_throws_exception_for_invalid_url_protocol(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Metered invalid ICE URL scheme [http://invalid.url] at index [0].');
IceResponseValidator::validate([
['urls' => 'http://invalid.url'],
], 'Metered');
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace Tests\Unit;
use App\Services\IceCandidate\Providers\MeteredIceCandidateProvider;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;
class MeteredIceCandidateProviderTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
public function test_fetches_and_parses_metered_ice_servers_successfully(): void
{
$mockUrl = 'https://mock.metered.live/api/v1/turn/credentials';
$apiKey = 'test-metered-key';
Http::fake([
$mockUrl.'*' => Http::response([
[
'urls' => 'stun:stun.relay.metered.ca:80',
],
[
'urls' => 'turn:standard.relay.metered.ca:80',
'username' => 'metered_usr',
'credential' => 'metered_pwd',
],
], 200),
]);
$provider = new MeteredIceCandidateProvider([
'url' => $mockUrl,
'key' => $apiKey,
'timeout' => 3,
'cache_ttl' => 60,
]);
$candidates = $provider->getIceCandidates();
$this->assertCount(2, $candidates);
$array = $candidates->toArray();
$this->assertEquals('stun:stun.relay.metered.ca:80', $array[0]['urls']);
$this->assertEquals('metered_usr', $array[1]['username']);
$this->assertEquals('metered_pwd', $array[1]['credential']);
// Verify result is cached (subsequent call does not hit HTTP)
Http::fake([
$mockUrl.'*' => Http::response([], 500),
]);
$cachedCandidates = $provider->getIceCandidates();
$this->assertCount(2, $cachedCandidates);
}
public function test_throws_exception_on_metered_http_error(): void
{
$mockUrl = 'https://mock.metered.live/api/v1/turn/credentials';
Http::fake([
$mockUrl.'*' => Http::response('Server Error', 500),
]);
$provider = new MeteredIceCandidateProvider([
'url' => $mockUrl,
'key' => 'key',
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Metered API Error: 500');
$provider->getIceCandidates();
}
}