Establishes the contract layer the rest of v0.2.0 builds on. Pre-1.0
SemVer break: ModelPublisher::publishEntityChange() now takes BridgeOp
instead of a raw string.
Interfaces (Symfony idiom: same namespace as concrete, like HubInterface
next to Hub):
- PublisherInterface — publish(string, array, bool)
- ModelPublisherInterface — publishEntityChange(object, BridgeOp)
- CorrelationContextInterface — set/get/clear
App code should typehint these instead of the concretes so swappable
implementations (offline-buffer publisher, multi-hub fan-out, request-
stamp correlation) remain non-breaking. Concrete classes implement them
unchanged; autowire continues to inject the implementations transparently.
BridgeOp: PHP 8.1 string-backed enum with cases Upsert / Delete /
Replace / Event matching PLAN.md §4's envelope `op` wire format.
Internal call sites updated; tests use the cases directly.
Switched typehints:
- ModelPublisher ctor: PublisherInterface + CorrelationContextInterface
- DoctrineBridgeListener ctor: ModelPublisherInterface
- HealthController ctor: PublisherInterface (still emits `Publisher`
as bundle canary value — `::class` resolves to the concrete class
name regardless of typehint, so bundled-supervisor.sh's grep stays
green)
- skeleton PingController ctor: PublisherInterface (canonical app
pattern — example/todo has no Publisher consumer to update)
Drive-by: removed deprecated setAccessible(true) call in
ModelPublisher::extractId — PHP 8.1+ allows reflection without it.
PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot all pass; dev
container compiles in the example app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
PHP
139 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace PhpQml\Bridge\Tests;
|
|
|
|
use PhpQml\Bridge\Attribute\BridgeResource;
|
|
use PhpQml\Bridge\BridgeOp;
|
|
use PhpQml\Bridge\CorrelationContext;
|
|
use PhpQml\Bridge\ModelPublisher;
|
|
use PhpQml\Bridge\Publisher;
|
|
use PhpQml\Bridge\Tests\Helper\HubSpy;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
|
use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer;
|
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
|
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
|
use Symfony\Component\Serializer\Serializer;
|
|
|
|
#[BridgeResource(name: 'todo')]
|
|
final class FakeTodo
|
|
{
|
|
public function __construct(
|
|
public string $id,
|
|
public string $title,
|
|
public bool $done = false,
|
|
) {
|
|
}
|
|
|
|
public function getId(): string
|
|
{
|
|
return $this->id;
|
|
}
|
|
}
|
|
|
|
final class FakeNotMarked
|
|
{
|
|
public function __construct(public int $id, public string $title)
|
|
{
|
|
}
|
|
|
|
public function getId(): int
|
|
{
|
|
return $this->id;
|
|
}
|
|
}
|
|
|
|
#[CoversClass(ModelPublisher::class)]
|
|
final class ModelPublisherTest extends TestCase
|
|
{
|
|
private HubSpy $hub;
|
|
private CorrelationContext $context;
|
|
private ModelPublisher $publisher;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->hub = new HubSpy('urn:uuid:test');
|
|
$this->context = new CorrelationContext();
|
|
$serializer = new Serializer(
|
|
[new BackedEnumNormalizer(), new DateTimeNormalizer(), new ObjectNormalizer()],
|
|
[new JsonEncoder()],
|
|
);
|
|
$this->publisher = new ModelPublisher(
|
|
new Publisher($this->hub),
|
|
$this->context,
|
|
$serializer,
|
|
);
|
|
}
|
|
|
|
public function testUpsertDualPublishesToCollectionAndEntityTopics(): void
|
|
{
|
|
$todo = new FakeTodo(id: '019de596-be1c-7642-985c-edcadeef9b5d', title: 'milk', done: false);
|
|
|
|
$this->publisher->publishEntityChange($todo, BridgeOp::Upsert);
|
|
|
|
// The HubSpy only retains the LAST update. To validate both topics,
|
|
// re-publish and check the second envelope, but for the assertion of
|
|
// semantics we instead use a recording HubSpy that captures all.
|
|
// Simplification: confirm the final captured update is the entity
|
|
// topic (published last by ModelPublisher).
|
|
self::assertNotNull($this->hub->captured);
|
|
self::assertSame(
|
|
['app://model/todo/019de596-be1c-7642-985c-edcadeef9b5d'],
|
|
$this->hub->captured->getTopics(),
|
|
);
|
|
|
|
$envelope = json_decode($this->hub->captured->getData(), true);
|
|
self::assertSame('upsert', $envelope['op']);
|
|
self::assertSame('019de596-be1c-7642-985c-edcadeef9b5d', $envelope['id']);
|
|
self::assertSame('milk', $envelope['data']['title']);
|
|
self::assertFalse($envelope['data']['done']);
|
|
self::assertGreaterThan(0, $envelope['version']);
|
|
self::assertArrayNotHasKey('correlationKey', $envelope);
|
|
}
|
|
|
|
public function testCorrelationKeyIsEchoedWhenContextHasIt(): void
|
|
{
|
|
$this->context->set('idem-1234');
|
|
$this->publisher->publishEntityChange(
|
|
new FakeTodo(id: '1', title: 'x'),
|
|
BridgeOp::Upsert,
|
|
);
|
|
|
|
$envelope = json_decode($this->hub->captured->getData(), true);
|
|
self::assertSame('idem-1234', $envelope['correlationKey']);
|
|
}
|
|
|
|
public function testDeleteOpOmitsData(): void
|
|
{
|
|
$this->publisher->publishEntityChange(
|
|
new FakeTodo(id: '7', title: 'gone'),
|
|
BridgeOp::Delete,
|
|
);
|
|
|
|
$envelope = json_decode($this->hub->captured->getData(), true);
|
|
self::assertSame('delete', $envelope['op']);
|
|
self::assertNull($envelope['data']);
|
|
}
|
|
|
|
public function testEntitiesWithoutBridgeResourceAreIgnored(): void
|
|
{
|
|
$this->publisher->publishEntityChange(new FakeNotMarked(1, 'x'), BridgeOp::Upsert);
|
|
|
|
self::assertNull($this->hub->captured);
|
|
}
|
|
|
|
public function testVersionIncreasesOnEachPublish(): void
|
|
{
|
|
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'a'), BridgeOp::Upsert);
|
|
$first = json_decode($this->hub->captured->getData(), true)['version'];
|
|
|
|
$this->publisher->publishEntityChange(new FakeTodo(id: '1', title: 'b'), BridgeOp::Upsert);
|
|
$second = json_decode($this->hub->captured->getData(), true)['version'];
|
|
|
|
self::assertGreaterThan($first, $second);
|
|
}
|
|
}
|