Newer
Older
gn-synapse-client-php / tests / SynapseClientTest.php
<?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\StatusTimeoutException;
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');
    }

    // -- userId + waitForStatus (v0.1.2) --------------------------------

    public function testUserIdMergesIntoPayloadAndOverrides(): void
    {
        $body = null;
        $client = $this->clientWith(static function ($r) use (&$body) {
            $body = json_decode((string) $r->getBody(), true, flags: JSON_THROW_ON_ERROR);
            return new Response(202, [], json_encode([
                'ok' => true, 'id' => 'e-1', 'status' => 'queued', 'deduplicated' => false,
            ]));
        });
        $client->send('bugtrail', 'task', 'created', 'normal', ['title' => 'x', 'user_id' => 'oops'], userId: 'u-42');
        self::assertSame(['title' => 'x', 'user_id' => 'u-42'], $body['payload']);
    }

    public function testUserIdNullLeavesPayloadOut(): void
    {
        $body = null;
        $client = $this->clientWith(static function ($r) use (&$body) {
            $body = json_decode((string) $r->getBody(), true, flags: JSON_THROW_ON_ERROR);
            return new Response(202, [], json_encode([
                'ok' => true, 'id' => 'e-1', 'status' => 'queued', 'deduplicated' => false,
            ]));
        });
        $client->emit('bugtrail', 'task', 'created');
        self::assertArrayNotHasKey('payload', $body);
    }

    public function testWaitForStatusPollsUntilTerminal(): void
    {
        $calls = 0;
        $client = $this->clientWith(static function () use (&$calls) {
            ++$calls;
            $status = $calls < 3 ? 'queued' : 'done';
            return new Response(200, [], json_encode([
                'id' => '11111111-1111-1111-1111-111111111111', 'source' => 'libtest',
                'subject' => 'ping', 'action' => 'done', 'priority' => 'normal',
                'status' => $status, 'created_at' => '2026-10-03T12:00:00+00:00',
                'deliveries' => [],
            ]));
        });
        $st = $client->waitForStatus('11111111-1111-1111-1111-111111111111', ['done'], 5, 0.01);
        self::assertSame('done', $st->status);
        self::assertSame(3, $calls);
    }

    public function testWaitForStatusTimeout(): void
    {
        $client = $this->clientWith(static fn () => new Response(200, [], json_encode([
            'id' => '11111111-1111-1111-1111-111111111111', 'source' => 'libtest',
            'subject' => 'ping', 'action' => 'done', 'priority' => 'normal',
            'status' => 'queued', 'created_at' => '2026-10-03T12:00:00+00:00',
            'deliveries' => [],
        ])));
        try {
            $client->waitForStatus('11111111-1111-1111-1111-111111111111', ['done'], 0.08, 0.01);
            self::fail('таймаут не сработал');
        } catch (StatusTimeoutException $ex) {
            self::assertStringContainsString('queued', $ex->detail);
        }
    }

    // -- 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,
        );
    }
}