<?php
declare(strict_types=1);
namespace GNexus\Synapse\Tests;
use GNexus\Synapse\Config\SynapseConfig;
use GNexus\Synapse\Exception\AuthException;
use GNexus\Synapse\Exception\ConfigurationException;
use GNexus\Synapse\Exception\ForbiddenException;
use GNexus\Synapse\Exception\NotFoundException;
use GNexus\Synapse\Exception\ServerException;
use GNexus\Synapse\Exception\SynapseException;
use GNexus\Synapse\Exception\TransportException;
use GNexus\Synapse\Exception\ValidationException;
use GNexus\Synapse\SynapseClient;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/** PSR-3 фейк: копит (level, message) пары. */
final class FakeLogger implements LoggerInterface
{
/** @var list<array{string, string}> */
public array $records = [];
public function emergency(string|\Stringable $message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert(string|\Stringable $message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical(string|\Stringable $message, array $context = []): void { $this->log('critical', $message, $context); }
public function error(string|\Stringable $message, array $context = []): void { $this->log('error', $message, $context); }
public function warning(string|\Stringable $message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice(string|\Stringable $message, array $context = []): void { $this->log('notice', $message, $context); }
public function info(string|\Stringable $message, array $context = []): void { $this->log('info', $message, $context); }
public function debug(string|\Stringable $message, array $context = []): void { $this->log('debug', $message, $context); }
public function log(mixed $level, string|\Stringable $message, array $context = []): void
{
$this->records[] = [$level, (string) $message];
}
public function warnings(): array
{
return array_values(array_filter($this->records, static fn ($r) => $r[0] === 'warning'));
}
}
final class SynapseClientTest extends TestCase
{
// -- send ---------------------------------------------------------
public function testSendSuccessBuildsRequest(): void
{
$req = null;
$client = $this->clientWith(static function ($r) use (&$req) {
$req = $r;
return new Response(202, [], json_encode([
'ok' => true, 'id' => 'e-1', 'status' => 'queued', 'deduplicated' => false,
]));
});
$event = $client->send('bugtrail', 'test', 'failed', 'high', ['user_id' => '42'], 'd1');
self::assertSame('e-1', $event->id);
self::assertSame('queued', $event->status);
self::assertFalse($event->deduplicated);
self::assertSame('/api/v1/events', $req->getUri()->getPath());
self::assertSame('Bearer syn_test', $req->getHeaderLine('authorization'));
self::assertStringStartsWith('gnexus-synapse-php/', $req->getHeaderLine('user-agent'));
$body = json_decode((string) $req->getBody(), true, flags: JSON_THROW_ON_ERROR);
self::assertSame([
'source' => 'bugtrail', 'subject' => 'test', 'action' => 'failed',
'priority' => 'high', 'payload' => ['user_id' => '42'], 'dedup_key' => 'd1',
], $body);
}
public function testSendMapsStatusErrors(): void
{
$cases = [
401 => [AuthException::class, 'API-ключ неизвестен или отозван'],
403 => [ForbiddenException::class, 'не совпадает с источником ключа'],
422 => [ValidationException::class, 'Тип не зарегистрирован'],
500 => [ServerException::class, 'boom'],
418 => [SynapseException::class, 'teapot'],
];
foreach ($cases as $code => [$cls, $detail]) {
$client = $this->clientWith(static fn () => new Response($code, [], json_encode(['detail' => $detail])));
try {
$client->send('s', 'x', 'y');
self::fail("ожидалось исключение для HTTP $code");
} catch (SynapseException $ex) {
self::assertSame($cls, get_class($ex));
self::assertSame($detail, $ex->detail);
self::assertSame($code, $ex->statusCode);
}
}
}
public function testSendTransportErrorWrapped(): void
{
$cause = new FakeNetworkException('connection refused', new Request('POST', 'http://x'));
$client = new SynapseClient(
self::config(),
FakeHttpClient::failing($cause),
FakePsr17::requestFactory(),
FakePsr17::streamFactory(),
);
try {
$client->send('s', 'x', 'y');
self::fail('ожидался TransportException');
} catch (TransportException $ex) {
self::assertStringContainsString('Synapse недоступен', $ex->detail);
self::assertSame($cause, $ex->transportError);
}
}
public function testLocalValidationNoHttp(): void
{
$called = 0;
$client = $this->clientWith(static function () use (&$called) {
++$called;
return new Response(202, [], '{}');
});
try {
$client->send('Wrong Source', 'x', 'y');
self::fail('ожидалась ValidationException');
} catch (ValidationException $ex) {
self::assertNull($ex->statusCode); // локальная, не сервер
}
self::assertSame(0, $called);
}
// -- emit ---------------------------------------------------------
public function testEmitSwallowsAndLogs(): void
{
$logs = new FakeLogger();
$client = $this->clientWith(
static fn () => new Response(503, [], json_encode(['detail' => 'нет воркеров'])),
$logs,
);
self::assertNull($client->emit('s', 'x', 'y'));
self::assertCount(1, $logs->warnings());
self::assertStringContainsString('нет воркеров', $logs->warnings()[0][1]);
}
public function testEmitThrowTrueRethrows(): void
{
$client = $this->clientWith(static fn () => new Response(503, [], json_encode(['detail' => 'бум'])));
$this->expectException(ServerException::class);
$client->emit('s', 'x', 'y', throw: true);
}
// -- batch --------------------------------------------------------
public function testBatchPartialAndIndexRemap(): void
{
$client = $this->clientWith(static fn () => new Response(202, [], json_encode([
'results' => [
['ok' => true, 'id' => 'a', 'status' => 'queued', 'deduplicated' => false],
['ok' => false, 'index' => 1, 'detail' => 'Тип не зарегистрирован'],
],
])));
$result = $client->sendBatch([
['source' => 's', 'subject' => 'x', 'action' => 'y1'],
['subject' => 'x', 'action' => 'y'], // нет source — локальный reject
['source' => 's', 'subject' => 'x', 'action' => 'y2'], // сервер отверг
]);
self::assertFalse($result->allAccepted());
self::assertSame(['a'], array_map(static fn ($e) => $e->id, $result->accepted()));
$rejected = $result->rejected();
self::assertCount(2, $rejected);
self::assertSame(1, $rejected[0]->index);
self::assertStringContainsString('source', $rejected[0]->detail);
self::assertSame(2, $rejected[1]->index);
self::assertSame('Тип не зарегистрирован', $rejected[1]->detail);
}
public function testBatchAllRejectedByServer(): void
{
$client = $this->clientWith(static fn () => new Response(422, [], json_encode([
'results' => [
['ok' => false, 'index' => 0, 'detail' => 'Тип не зарегистрирован'],
['ok' => false, 'index' => 1, 'detail' => 'бум'],
],
])));
$result = $client->sendBatch([
['source' => 's', 'subject' => 'x', 'action' => 'y'],
['source' => 's', 'subject' => 'x', 'action' => 'z'],
]);
self::assertFalse($result->allAccepted());
self::assertCount(2, $result->rejected());
}
public function testBatchEmptyNoHttp(): void
{
$client = $this->clientWith(static function (): never {
throw new \RuntimeException('HTTP не нужен');
});
$result = $client->sendBatch([]);
self::assertSame([], $result->results);
self::assertTrue($result->allAccepted());
}
// -- status / health ----------------------------------------------
public function testStatusParsesAnd404(): void
{
$eid = '11111111-1111-1111-1111-111111111111';
$other = '22222222-2222-2222-2222-222222222222';
$client = $this->clientWith(static function ($req) use ($eid, $other) {
if (str_ends_with($req->getUri()->getPath(), $other)) {
return new Response(404, [], json_encode(['detail' => 'Событие не найдено']));
}
return new Response(200, [], json_encode([
'id' => $eid, 'source' => 'bugtrail', 'subject' => 'test', 'action' => 'failed',
'priority' => 'high', 'status' => 'delivered',
'created_at' => '2026-10-03T12:00:00+00:00', 'expires_at' => null,
'deliveries' => [
['channel' => 'internal_log', 'target' => null, 'status' => 'delivered',
'attempts' => 1, 'error' => null, 'rendered_message' => 'тест упал'],
['channel' => 'telegram', 'target' => 'qa-chat', 'status' => 'retrying',
'attempts' => 2, 'error' => '429', 'rendered_message' => null],
],
]));
});
$st = $client->status($eid);
self::assertSame('delivered', $st->status);
self::assertSame(2, $st->deliveries[1]->attempts);
self::assertSame('retrying', $st->deliveries[1]->status);
self::assertNull($st->expiresAt);
$this->expectException(NotFoundException::class);
$client->status($other);
}
public function testHealthAndNoKeyConfigError(): void
{
$client = $this->clientWith(static fn () => new Response(200, [], json_encode(['status' => 'ok'])));
self::assertSame(['status' => 'ok'], $client->health());
self::assertSame(['status' => 'ok'], $client->ready());
// ключа нет — send поднимает ConfigurationException до HTTP
$noKey = new SynapseClient(
new SynapseConfig('http://synapse.test', null),
FakeHttpClient::failing(new FakeNetworkException('не должен дойти', new Request('POST', 'http://x'))),
FakePsr17::requestFactory(),
FakePsr17::streamFactory(),
);
$this->expectException(ConfigurationException::class);
$noKey->send('s', 'x', 'y');
}
// -- helpers --------------------------------------------------------
private static function config(): SynapseConfig
{
return new SynapseConfig('http://synapse.test', 'syn_test');
}
private function clientWith(callable $handler, ?LoggerInterface $logger = null): SynapseClient
{
return new SynapseClient(
self::config(),
new FakeHttpClient($handler),
FakePsr17::requestFactory(),
FakePsr17::streamFactory(),
$logger,
);
}
}