Browse Source

more refactoring

imwald
Silberengel 18 hours ago
parent
commit
ccf35a1c9a
  1. 11
      config/services.yaml
  2. 12
      phpstan-baseline.neon
  3. 971
      src/Service/NostrClient.php
  4. 230
      src/Service/NostrRelayFanoutTransport.php
  5. 317
      src/Service/NostrRelayListFactory.php
  6. 165
      src/Service/NostrRelayQuery.php
  7. 49
      src/Service/NostrRelayRequestFactory.php
  8. 37
      tests/Service/NostrRelayFanoutTransportTest.php
  9. 35
      tests/Service/NostrRelayListFactoryTest.php
  10. 38
      tests/Service/NostrRelayQueryTest.php
  11. 33
      tests/Service/NostrRelayRequestFactoryTest.php

11
config/services.yaml

@ -35,13 +35,20 @@ services: @@ -35,13 +35,20 @@ services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'
App\Service\NostrClient:
App\Service\NostrRelayRequestFactory:
arguments:
$relayRequestTimeoutSec: '%nostr_relay_request_timeout_sec%'
App\Service\NostrRelayFanoutTransport:
arguments:
$projectDir: '%kernel.project_dir%'
App\Service\NostrRelayListFactory:
arguments:
$defaultRelayUrl: '%default_relay%'
$articleRelayUrls: '%article_relays%'
$profileRelayUrls: '%profile_relays%'
App\Service\NostrClient:
arguments:
$projectDir: '%kernel.project_dir%'
$relayRequestTimeoutSec: '%nostr_relay_request_timeout_sec%'
App\Service\ArticleCommentThreadLoader:
arguments:
$appCachePool: '@cache.replies'

12
phpstan-baseline.neon

@ -501,7 +501,7 @@ parameters: @@ -501,7 +501,7 @@ parameters:
-
message: '#^Call to function is_array\(\) with array will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 2
count: 1
path: src/Service/NostrClient.php
-
@ -525,13 +525,7 @@ parameters: @@ -525,13 +525,7 @@ parameters:
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 11
path: src/Service/NostrClient.php
-
message: '#^Call to function method_exists\(\) with swentel\\nostr\\Request\\Request and ''setTimeout'' will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
count: 6
path: src/Service/NostrClient.php
-
@ -579,7 +573,7 @@ parameters: @@ -579,7 +573,7 @@ parameters:
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<string\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 4
count: 2
path: src/Service/NostrClient.php
-

971
src/Service/NostrClient.php

File diff suppressed because it is too large Load Diff

230
src/Service/NostrRelayFanoutTransport.php

@ -0,0 +1,230 @@ @@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Multi-relay REQ fan-out: one in-process sequential {@see Request::send()} vs. one CLI worker per wss
* ({@see bin/nostr_relay_request_worker.php}). Used for article discussion and kind-9802 highlight fetches.
*/
final readonly class NostrRelayFanoutTransport
{
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
/** Soft wall-time before stopping still-running parallel workers. */
private const DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC = 3.5;
/**
* {@see Request::send()} visits relays sequentially; cap how many wss URLs we chain in one process
* so HTTP /fragment/comments do not hit long proxy timeouts.
*/
private const MAX_SEQUENTIAL_RELAY_URLS = 3;
public function __construct(
private LoggerInterface $logger,
private NostrRelayRequestFactory $relayRequestFactory,
private string $projectDir,
) {
}
/**
* @param list<string> $relayUrls
*
* @return list<string>
*/
public function capUrlsForSequential(array $relayUrls): array
{
if (\count($relayUrls) <= self::MAX_SEQUENTIAL_RELAY_URLS) {
return $relayUrls;
}
$this->logger->notice('nostr.article_discussion.sequential_relay_cap', [
'used' => self::MAX_SEQUENTIAL_RELAY_URLS,
'had' => \count($relayUrls),
]);
return \array_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS);
}
/**
* One {@see Request} over all relays in the set (library visits each wss:// in series).
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendSequential(RelaySet $relaySet, RequestMessage $requestMessage): array
{
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage);
return $request->send();
}
/**
* One short-lived CLI worker per relay URL (parallel WebSocket I/O).
*
* @param list<string> $relayUrls
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
public function sendParallelWorkers(array $relayUrls, RequestMessage $requestMessage): array
{
$worker = $this->projectDir.'/bin/nostr_relay_request_worker.php';
$phpBinary = (new PhpExecutableFinder())->find() ?: 'php';
$timeout = $this->relayRequestFactory->getRelayRequestTimeoutSec() + (int) self::DISCUSSION_WORKER_GRACE_SEC;
$workerTimeoutEnv = ['NOSTR_RELAY_REQUEST_TIMEOUT' => (string) $this->relayRequestFactory->getRelayRequestTimeoutSec()];
$rawPayload = serialize($requestMessage);
$tmp = tempnam(sys_get_temp_dir(), 'nrq_');
if ($tmp === false) {
throw new \RuntimeException('tempnam failed for Nostr discussion payload');
}
try {
if (file_put_contents($tmp, $rawPayload) === false) {
throw new \RuntimeException('Could not write Nostr discussion temp payload');
}
/** @var array<string, Process> $procs */
$procs = [];
foreach ($relayUrls as $wss) {
if ($wss === '') {
continue;
}
$p = new Process(
[$phpBinary, $worker, $wss, $tmp],
$this->projectDir,
null,
null,
(float) $timeout
);
$p->start(null, $workerTimeoutEnv);
$procs[$wss] = $p;
}
$merged = [];
$pending = $procs;
$deadlineAt = microtime(true) + self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC;
while ($pending !== []) {
foreach ($pending as $wss => $p) {
if ($p->isRunning()) {
continue;
}
unset($pending[$wss]);
if (!$p->isSuccessful()) {
$err = $p->getErrorOutput();
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [
'relay' => $wss,
'exit_code' => $p->getExitCode(),
'stderr' => $err !== '' ? $err : null,
]);
continue;
}
$out = trim($p->getOutput());
if ($out === '') {
continue;
}
$decoded = base64_decode($out, true);
if ($decoded === false || $decoded === '') {
continue;
}
$chunk = unserialize($decoded, ['allowed_classes' => true]);
if (!\is_array($chunk)) {
continue;
}
$merged = array_replace($merged, $chunk);
}
if ($pending === []) {
break;
}
if (microtime(true) >= $deadlineAt) {
foreach ($pending as $wss => $p) {
$this->logger->warning('nostr.article_discussion.relay_worker_soft_timeout', [
'relay' => $wss,
'soft_deadline_sec' => self::DISCUSSION_PARALLEL_SOFT_DEADLINE_SEC,
]);
$p->stop(0.2);
}
break;
}
usleep(100_000);
}
return $merged;
} finally {
if (\is_file($tmp)) {
@unlink($tmp);
}
}
}
/**
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …).
*
* @param array<string, mixed> $response
*/
public function logWireResponseSummary(string $context, array $response): void
{
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->warning(sprintf(
'nostr.wire.relay_throwable [%s]: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'context' => $context,
'relay' => $relayUrl,
'message' => $relayRes->getMessage(),
'class' => \get_class($relayRes),
]);
continue;
}
if (!\is_iterable($relayRes)) {
$this->logger->warning(sprintf(
'nostr.wire.relay_not_iterable [%s]: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
\get_debug_type($relayRes)
), [
'context' => $context,
'relay' => $relayUrl,
'php_type' => \get_debug_type($relayRes),
]);
continue;
}
$counts = [
'EVENT' => 0,
'EOSE' => 0,
'NOTICE' => 0,
'ERROR' => 0,
'AUTH' => 0,
'CLOSED' => 0,
'other' => 0,
];
foreach ($relayRes as $item) {
if (!\is_object($item)) {
++$counts['other'];
continue;
}
$t = (string) ($item->type ?? 'other');
if (\array_key_exists($t, $counts)) {
++$counts[$t];
} else {
++$counts['other'];
}
}
$this->logger->info(sprintf('nostr.wire.relay_messages [%s]', NostrRelayQuery::relayLogLabel($relayUrl)), [
'context' => $context,
'relay' => $relayUrl,
'counts' => $counts,
]);
}
}
}

317
src/Service/NostrRelayListFactory.php

@ -0,0 +1,317 @@ @@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use Psr\Log\LoggerInterface;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Config-driven relay URL lists and {@link RelaySet} construction: default + article + profile URLs,
* profile fetch ordering, sequential cap for slow in-process {@see \swentel\nostr\Request\Request::send()},
* and Nostr Land → aggr.nostr.land for logged-in readers who list the former.
*/
final readonly class NostrRelayListFactory
{
/** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */
private const NOSTR_LAND = 'wss://nostr.land';
/**
* Aggregated / subscription relay (not for anonymous visitors). Only added when the session user
* has {@see self::NOSTR_LAND} in their NIP-65-style relay list.
*/
private const AGGR_NOSTR_LAND = 'wss://aggr.nostr.land';
/**
* {@see \swentel\nostr\Request\Request::send()} hits relays sequentially; profile pages (metadata, long-form list, 10133) used
* the full default+article+profile list (~8–9 wss) → 2 slow relays can exceed PHP’s 30s default max_execution_time.
*/
private const MAX_PROFILE_SEQUENTIAL_RELAY_URLS = 3;
/**
* @param list<string> $articleRelayUrls
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see getProfileMetadataQueryRelayUrlList()})
*/
public function __construct(
private string $defaultRelayUrl,
private array $articleRelayUrls,
private array $profileRelayUrls,
private TokenStorageInterface $tokenStorage,
private LoggerInterface $logger,
) {
}
public function getDefaultRelayUrl(): string
{
return $this->defaultRelayUrl;
}
/**
* default_relay + article_relays from config, in order, deduplicated. Used for the static
* default set and as the base when merging author/extra relay URLs in {@see createRelaySetMergedWithArticleList()}.
*
* @return list<string>
*/
public function getConfiguredArticleRelayUrlList(): array
{
$seen = [];
$out = [];
if ($this->defaultRelayUrl !== '') {
$seen[$this->defaultRelayUrl] = true;
$out[] = $this->defaultRelayUrl;
}
foreach ($this->articleRelayUrls as $url) {
if ($url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
if ($out === []) {
$out[] = $this->defaultRelayUrl;
}
return $out;
}
public function getDefaultArticleRelaySet(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->getConfiguredArticleRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* Configured profile relays (kind-0 / NIP-05 hints) that are not already in the article relay list.
* Used as a second pass for magazine 30040 and category long-form ingest when article relays return nothing.
* Intentionally excludes merging article URLs again — {@see createRelaySetMergedWithArticleList()} prepends article relays.
*
* @return list<string>
*/
public function getProfileRelayUrlsExcludedFromArticleRelays(): array
{
$article = array_fill_keys($this->getConfiguredArticleRelayUrlList(), true);
$out = [];
foreach ($this->getProfileRelayUrlList() as $u) {
if (!isset($article[$u])) {
$out[] = $u;
}
}
return $out;
}
/**
* Relay set built only from the given URLs (no implicit article-relay merge).
*/
public function createRelaySetFromUrlsOnly(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach ($relayUrls as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped.
* Used for comment threads, per-author fetches, etc.
*/
public function createRelaySetMergedWithArticleList(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge($this->getConfiguredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Suffix to segregate HTTP caches: aggr is only used for some logged-in readers, so results differ.
*
* @return string empty when aggr is not used, else a short token
*/
public function getNostrLandAggrReaderCacheSuffix(): string
{
return $this->loggedInUserHasNostrLandInRelayList() ? 'a1' : '';
}
public function loggedInUserHasNostrLandInRelayList(): bool
{
$token = $this->tokenStorage->getToken();
if ($token === null) {
return false;
}
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->userRelayListContainsNostrLand($user->getRelays());
}
/**
* @param list<array{0?: string, 1?: string, 2?: string}>|array<array-key, mixed>|null $relays
*/
private function userRelayListContainsNostrLand(?array $relays): bool
{
if ($relays === null || $relays === []) {
return false;
}
$target = $this->normalizeWssUrlForNostrLandMatch(self::NOSTR_LAND);
foreach ($relays as $row) {
if (!\is_array($row) || !isset($row[1]) || !\is_string($row[1])) {
continue;
}
if ($this->normalizeWssUrlForNostrLandMatch($row[1]) === $target) {
return true;
}
}
return false;
}
private function normalizeWssUrlForNostrLandMatch(string $url): string
{
return rtrim(trim($url), '/');
}
/**
* Appends wss://aggr.nostr.land when the current user listed wss://nostr.land (session).
*
* @param list<string> $urls
*
* @return list<string>
*/
public function withAggrNostrLandIfUserSubscribesNostrLand(array $urls): array
{
if (!$this->loggedInUserHasNostrLandInRelayList()) {
return $urls;
}
$seen = array_fill_keys($urls, true);
if (isset($seen[self::AGGR_NOSTR_LAND])) {
return $urls;
}
$this->logger->debug('nostr.relay.append_aggr_nostr_land', [
'user_has_nostr_land' => true,
]);
$out = $urls;
$out[] = self::AGGR_NOSTR_LAND;
return $out;
}
/**
* @param list<string> $urls
*/
public function relaySetFromDistinctUrlList(array $urls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach ($urls as $relayUrl) {
if ($relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* @param list<string> $urls
*
* @return list<string>
*/
public function capSequentialRelaysForProfileFetches(array $urls): array
{
if (\count($urls) <= self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS) {
return $urls;
}
$this->logger->notice('nostr.relay_list_capped', [
'context' => 'profile_sequential',
'max' => self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS,
'had' => \count($urls),
]);
return \array_slice($urls, 0, self::MAX_PROFILE_SEQUENTIAL_RELAY_URLS);
}
/**
* @return list<string> Deduplicated profile relay URLs from config
*/
public function getProfileRelayUrlList(): array
{
$seen = [];
$out = [];
foreach ($this->profileRelayUrls as $url) {
if ($url === '' || isset($seen[$url])) {
continue;
}
if (!str_starts_with($url, 'wss:')) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
return $out;
}
/**
* Profile (kind-0) queries: {@see getProfileRelayUrlList()} first (Damus, nos.lol, …), then default + article set.
* Order matters: {@see \swentel\nostr\Request\Request::send()} walks relays sequentially.
*
* @return list<string>
*/
public function getProfileMetadataQueryRelayUrlList(): array
{
$seen = [];
$ordered = [];
foreach (array_merge($this->getProfileRelayUrlList(), $this->getConfiguredArticleRelayUrlList()) as $u) {
if ($u === '' || isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$ordered[] = $u;
}
if ($ordered === []) {
$ordered[] = $this->defaultRelayUrl;
}
return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered);
}
/**
* Same relays for kind-0 metadata, without mutating the default article relay set from {@see NostrClient}.
*/
public function getRelaySetForProfileMetadataFetch(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->getProfileMetadataQueryRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
}

165
src/Service/NostrRelayQuery.php

@ -0,0 +1,165 @@ @@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
/**
* NIP-01 style REQ construction and per-relay response iteration (EVENT / ERROR / …).
* Extracted from {@see NostrClient} for reuse; logging stays on this service.
*/
final readonly class NostrRelayQuery
{
public function __construct(
private LoggerInterface $logger,
private NostrRelayRequestFactory $relayRequestFactory,
) {
}
/**
* Short host/URL for logs (e.g. fragment comments / prewarm) without full wss:// noise.
*/
public static function relayLogLabel(string $relayUrl): string
{
$host = parse_url($relayUrl, \PHP_URL_HOST);
if (\is_string($host) && $host !== '') {
return $host;
}
return $relayUrl;
}
/**
* @param list<int|\BackedEnum> $kinds Integers or PHP 8.1 enums backed by int (e.g. {@see \App\Enum\KindsEnum})
* @param array<string, mixed> $filters Filter builder keys (e.g. authors, ids, tag, limit, …)
*/
public function createNostrRequest(
RelaySet $defaultRelaySet,
?RelaySet $relaySet = null,
array $kinds = [],
array $filters = [],
): Request {
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$kindInts = [];
foreach ($kinds as $k) {
$kindInts[] = $k instanceof \BackedEnum ? (int) $k->value : (int) $k;
}
$filter->setKinds($kindInts);
foreach ($filters as $key => $value) {
$method = 'set' . ucfirst($key);
if (method_exists($filter, $method)) {
if ($key === 'tag') {
$filter->setTag($value[0], $value[1]);
} else {
$filter->$method($value);
}
}
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$set = $relaySet ?? $defaultRelaySet;
return $this->relayRequestFactory->createTimedRequest($set, $requestMessage);
}
/**
* @param array<string, mixed> $response Return value of {@see Request::send()}: relay URL → message list|Throwable
* @return list<mixed>
*/
public function processResponse(array $response, callable $eventHandler): array
{
$results = [];
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->error(sprintf(
'Relay error at %s: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'relay' => $relayUrl,
'error' => $relayRes->getMessage(),
]);
continue;
}
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null;
$this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'item_count' => $itemEstimate,
]);
foreach ($relayRes as $item) {
try {
if (!\is_object($item)) {
$this->logger->warning(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'item' => $item,
]);
continue;
}
switch ($item->type) {
case 'EVENT':
$this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'event_id' => $item->event->id ?? 'unknown',
]);
$result = $eventHandler($item->event);
if ($result !== null) {
$results[] = $result;
}
break;
case 'AUTH':
$this->logger->warning(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'response' => $item,
]);
break;
case 'ERROR':
case 'NOTICE':
$msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg
), [
'relay' => $relayUrl,
'type' => $item->type,
'message' => $msg,
]);
break;
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error processing event from relay %s: %s',
self::relayLogLabel($relayUrl),
$e->getMessage()
), [
'relay' => $relayUrl,
'error' => $e->getMessage(),
]);
continue;
}
}
}
return $results;
}
}

49
src/Service/NostrRelayRequestFactory.php

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Service;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
/**
* Builds swentel {@see Request} instances with per-relay I/O timeout (config: `nostr_relay_request_timeout_sec`).
* Shared by {@see NostrClient} so request wiring stays in one place.
*/
final readonly class NostrRelayRequestFactory
{
public function __construct(
private int $relayRequestTimeoutSec = 12,
) {
}
public function getRelayRequestTimeoutSec(): int
{
return $this->relayRequestTimeoutSec;
}
/**
* {@see Request::setTimeout()} drives per-relay WebSocket I/O for {@see Request::send()}.
*/
public function createTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
{
$request = new Request($relaySet, $requestMessage);
return $request->setTimeout($this->relayRequestTimeoutSec);
}
/**
* For paths that use {@see RelaySet::send()} with a custom message and bypass {@see Request}.
*/
public function applySocketTimeoutToRelaySet(RelaySet $relaySet): void
{
foreach ($relaySet->getRelays() as $relay) {
$client = $relay->getClient();
if (method_exists($client, 'setTimeout')) {
$client->setTimeout($this->relayRequestTimeoutSec);
}
}
}
}

37
tests/Service/NostrRelayFanoutTransportTest.php

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrRelayFanoutTransport;
use App\Service\NostrRelayRequestFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
final class NostrRelayFanoutTransportTest extends TestCase
{
public function testCapUrlsForSequentialLeavesShortListsUnchanged(): void
{
$t = $this->makeTransport();
$in = ['wss://a', 'wss://b'];
$this->assertSame($in, $t->capUrlsForSequential($in));
}
public function testCapUrlsForSequentialTrimsToThreeRelays(): void
{
$t = $this->makeTransport();
$in = ['wss://1', 'wss://2', 'wss://3', 'wss://4'];
$out = $t->capUrlsForSequential($in);
$this->assertSame(['wss://1', 'wss://2', 'wss://3'], $out);
}
private function makeTransport(): NostrRelayFanoutTransport
{
return new NostrRelayFanoutTransport(
new NullLogger(),
new NostrRelayRequestFactory(10),
\sys_get_temp_dir()
);
}
}

35
tests/Service/NostrRelayListFactoryTest.php

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrRelayListFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
final class NostrRelayListFactoryTest extends TestCase
{
public function testGetConfiguredArticleRelayUrlListDeduplicatesAndPreservesOrder(): void
{
$tokenStorage = $this->createMock(TokenStorageInterface::class);
$tokenStorage->method('getToken')->willReturn(null);
$f = new NostrRelayListFactory(
'wss://main',
['wss://extra', 'wss://main', 'wss://extra'],
['wss://profile'],
$tokenStorage,
new NullLogger()
);
$this->assertSame(['wss://main', 'wss://extra'], $f->getConfiguredArticleRelayUrlList());
}
public function testGetDefaultRelayUrl(): void
{
$ts = $this->createMock(TokenStorageInterface::class);
$ts->method('getToken')->willReturn(null);
$f = new NostrRelayListFactory('wss://d', [], [], $ts, new NullLogger());
$this->assertSame('wss://d', $f->getDefaultRelayUrl());
}
}

38
tests/Service/NostrRelayQueryTest.php

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\KindsEnum;
use App\Service\NostrRelayRequestFactory;
use App\Service\NostrRelayQuery;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
final class NostrRelayQueryTest extends TestCase
{
public function testRelayLogLabelUsesHost(): void
{
$this->assertSame(
'relay.example.com',
NostrRelayQuery::relayLogLabel('wss://relay.example.com/nostr')
);
}
public function testCreateNostrRequestAcceptsBackedEnumKinds(): void
{
$factory = new NostrRelayRequestFactory(12);
$q = new NostrRelayQuery(new NullLogger(), $factory);
$set = new RelaySet();
$set->addRelay(new Relay('wss://127.0.0.1:0'));
$req = $q->createNostrRequest(
defaultRelaySet: $set,
kinds: [KindsEnum::METADATA],
filters: [],
);
$this->assertInstanceOf(\swentel\nostr\Request\Request::class, $req);
}
}

33
tests/Service/NostrRelayRequestFactoryTest.php

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrRelayRequestFactory;
use PHPUnit\Framework\TestCase;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Subscription\Subscription;
final class NostrRelayRequestFactoryTest extends TestCase
{
public function testExposesConfiguredTimeout(): void
{
$f = new NostrRelayRequestFactory(21);
$this->assertSame(21, $f->getRelayRequestTimeoutSec());
}
public function testCreateTimedRequestReturnsWiredRequest(): void
{
$f = new NostrRelayRequestFactory(12);
$sub = new Subscription();
$msg = new RequestMessage($sub->getId(), []);
$set = new RelaySet();
$set->addRelay(new Relay('wss://127.0.0.1:0'));
$req = $f->createTimedRequest($set, $msg);
$this->assertInstanceOf(\swentel\nostr\Request\Request::class, $req);
}
}
Loading…
Cancel
Save