interview-feedback-enhancements and convexsol turn server #23
10
.env.example
10
.env.example
@ -70,10 +70,20 @@ MICROSOFT_CLIENT_SECRET=
|
|||||||
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
|
||||||
MICROSOFT_TENANT_ID=common
|
MICROSOFT_TENANT_ID=common
|
||||||
|
|
||||||
|
# ICE Candidate / TURN Server Configurations
|
||||||
|
ICE_CANDIDATE_PROVIDER=metered
|
||||||
|
ICE_FALLBACK_PROVIDER=convexsol
|
||||||
|
|
||||||
# Metered TURN Server Credentials
|
# Metered TURN Server Credentials
|
||||||
METERED_URL=
|
METERED_URL=
|
||||||
METERED_KEY=
|
METERED_KEY=
|
||||||
|
|
||||||
|
# ConvexSol TURN / STUN Server Credentials
|
||||||
|
CONVEXSOL_USERNAME=
|
||||||
|
CONVEXSOL_PASSWORD=
|
||||||
|
CONVEXSOL_TURN_URLS=
|
||||||
|
CONVEXSOL_STUN_URLS=
|
||||||
|
|
||||||
# Interview Configurations
|
# Interview Configurations
|
||||||
MAX_INTERVIEWER=2
|
MAX_INTERVIEWER=2
|
||||||
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings
|
INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings
|
||||||
|
|||||||
13
app/Contracts/IceCandidateProviderInterface.php
Normal file
13
app/Contracts/IceCandidateProviderInterface.php
Normal 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;
|
||||||
|
}
|
||||||
46
app/DTOs/IceCandidateDto.php
Normal file
46
app/DTOs/IceCandidateDto.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/DTOs/IceCandidatesDto.php
Normal file
69
app/DTOs/IceCandidatesDto.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,65 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Contracts\IceCandidateProviderInterface;
|
||||||
|
use App\Http\Resources\Interview\IceCandidatesResource;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class IceServerController extends Controller
|
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')) {
|
if (! Auth::check() && ! session()->has('candidate_interview_id')) {
|
||||||
return response()->json(['error' => 'Unauthenticated access.'], 401);
|
return response()->json(['error' => 'Unauthenticated access.'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$baseUrl = config(
|
$candidates = $this->iceCandidateProvider->getIceCandidates();
|
||||||
"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);
|
|
||||||
|
|
||||||
|
return new IceCandidatesResource($candidates);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error(
|
Log::error('ICE servers fetch exception: '.$e->getMessage());
|
||||||
"ICE servers fetch exception: " . $e->getMessage(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(
|
return response()->json([
|
||||||
[
|
'error' => 'An error occurred while fetching ICE servers.',
|
||||||
"error" => "An error occurred while fetching ICE servers.",
|
'message' => $e->getMessage(),
|
||||||
"message" => $e->getMessage(),
|
], 500);
|
||||||
],
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
app/Http/Resources/Interview/IceCandidateResource.php
Normal file
40
app/Http/Resources/Interview/IceCandidateResource.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Resources/Interview/IceCandidatesResource.php
Normal file
31
app/Http/Resources/Interview/IceCandidatesResource.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@
|
|||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||||
use SocialiteProviders\Microsoft\MicrosoftExtendSocialite;
|
use SocialiteProviders\Microsoft\MicrosoftExtendSocialite;
|
||||||
|
|
||||||
@ -23,6 +24,14 @@ public function register(): void
|
|||||||
*/
|
*/
|
||||||
public function boot(): 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(
|
Event::listen(
|
||||||
SocialiteWasCalled::class,
|
SocialiteWasCalled::class,
|
||||||
MicrosoftExtendSocialite::class
|
MicrosoftExtendSocialite::class
|
||||||
|
|||||||
38
app/Providers/IceCandidateServiceProvider.php
Normal file
38
app/Providers/IceCandidateServiceProvider.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Services/IceCandidate/IceCandidateManager.php
Normal file
85
app/Services/IceCandidate/IceCandidateManager.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\IceCandidateServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
|
IceCandidateServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -38,7 +38,7 @@
|
|||||||
'microsoft' => [
|
'microsoft' => [
|
||||||
'client_id' => env('MICROSOFT_CLIENT_ID', env('MICROSOFT_GRAPH_CLIENT_ID')),
|
'client_id' => env('MICROSOFT_CLIENT_ID', env('MICROSOFT_GRAPH_CLIENT_ID')),
|
||||||
'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('MICROSOFT_GRAPH_CLIENT_SECRET')),
|
'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('MICROSOFT_GRAPH_CLIENT_SECRET')),
|
||||||
'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000') . '/auth/microsoft/callback'),
|
'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000').'/auth/microsoft/callback'),
|
||||||
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
|
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
|
||||||
],
|
],
|
||||||
|
|
||||||
@ -47,4 +47,32 @@
|
|||||||
'key' => env('METERED_KEY'),
|
'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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -39,8 +39,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-label>Start Time (Timeline)</x-label>
|
<x-label for="scheduled_at">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-input type="datetime-local" id="scheduled_at" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="bg-slate-900" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
117
tests/Feature/IceServerControllerTest.php
Normal file
117
tests/Feature/IceServerControllerTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/Unit/ConvexSolIceCandidateProviderTest.php
Normal file
52
tests/Unit/ConvexSolIceCandidateProviderTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/Unit/IceCandidateDtoTest.php
Normal file
70
tests/Unit/IceCandidateDtoTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/Unit/IceCandidateManagerTest.php
Normal file
134
tests/Unit/IceCandidateManagerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Unit/IceCandidatesResourceTest.php
Normal file
54
tests/Unit/IceCandidatesResourceTest.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Unit/IceResponseValidatorTest.php
Normal file
85
tests/Unit/IceResponseValidatorTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
79
tests/Unit/MeteredIceCandidateProviderTest.php
Normal file
79
tests/Unit/MeteredIceCandidateProviderTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user