Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
SubscriptionStoreContractTests
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
4 / 4
6
100.00% covered (success)
100.00%
1 / 1
 createStores
n/a
0 / 0
n/a
0 / 0
0
 subscription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 request
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 testOffersAreRegisteredAnsweredAndWithdrawn
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 testSubscribersComeFromAcceptedRequestsOnly
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Testing\Contract;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Api\ActionRequest;
9use LambdaTwelve\OneRecord\Api\RequestStatus;
10use LambdaTwelve\OneRecord\Api\Subscription;
11use LambdaTwelve\OneRecord\Api\SubscriptionEventType;
12use LambdaTwelve\OneRecord\Api\TopicType;
13use LambdaTwelve\OneRecord\Rdf\Iri;
14use LambdaTwelve\OneRecord\Server\Spi\ActionRequestStore;
15use LambdaTwelve\OneRecord\Server\Spi\SubscriptionStore;
16use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
17
18/**
19 * The tests behind SubscriptionStoreContract, as a trait, for hosts whose test cases must
20 * extend a framework base class (Laravel's Testbench, Drupal's KernelTestBase)
21 * and so cannot extend the abstract contract. Use it in any PHPUnit TestCase
22 * and implement the abstract hook(s); the abstract class is this trait on a
23 * bare TestCase.
24 */
25trait SubscriptionStoreContractTests
26{
27    protected const string CONTRACT_OBJECT = 'https://1r.example.com/logistics-objects/p1';
28    protected const string CONTRACT_PARTNER = 'https://1r.partner.example/logistics-objects/partner';
29    protected const string CONTRACT_OTHER_PARTNER = 'https://1r.other.example/logistics-objects/other';
30    protected const string CONTRACT_HOLDER = 'https://1r.example.com/logistics-objects/holder';
31
32    /**
33     * @return array{requests: ActionRequestStore, subscriptions: SubscriptionStore}
34     */
35    abstract protected function createStores(): array;
36
37    protected function subscription(string $subscriber, TopicType $type, string $topic, ?DateTimeImmutable $expiresAt = null, bool $body = false): Subscription
38    {
39        return new Subscription(new Iri($subscriber), $type, $topic, [SubscriptionEventType::LogisticsObjectUpdated, SubscriptionEventType::LogisticsEventReceived], sendLogisticsObjectBody: $body, expiresAt: $expiresAt);
40    }
41
42    protected function request(string $id, Subscription $subscription, RequestStatus $status = RequestStatus::Accepted): ActionRequest
43    {
44        $request = ActionRequest::create(new Iri('https://1r.example.com/action-requests/' . $id), $subscription, $subscription->subscriber, new DateTimeImmutable('2026-10-02T12:00:00Z'));
45
46        return $status === RequestStatus::Pending ? $request : $request->withStatus($status, new DateTimeImmutable('2026-10-02T12:01:00Z'), new Iri(self::CONTRACT_HOLDER));
47    }
48
49    public function testOffersAreRegisteredAnsweredAndWithdrawn(): void
50    {
51        ['subscriptions' => $store] = $this->createStores();
52        self::assertSame([], $store->offered(TopicType::Identifier, self::CONTRACT_OBJECT));
53
54        $plain = $this->subscription(self::CONTRACT_HOLDER, TopicType::Identifier, self::CONTRACT_OBJECT);
55        $withBody = $this->subscription(self::CONTRACT_HOLDER, TopicType::Identifier, self::CONTRACT_OBJECT, body: true);
56        $byType = $this->subscription(self::CONTRACT_HOLDER, TopicType::Type, Cargo::Piece);
57        $store->offer($plain);
58        $store->offer($withBody);
59        $store->offer($byType);
60
61        self::assertCount(2, $store->offered(TopicType::Identifier, self::CONTRACT_OBJECT));
62        self::assertCount(1, $store->offered(TopicType::Type, Cargo::Piece));
63        self::assertSame(Cargo::Piece, $store->offered(TopicType::Type, Cargo::Piece)[0]->topic);
64        self::assertSame([], $store->offered(TopicType::Type, Cargo::Shipment));
65
66        $store->withdraw($plain);
67        self::assertSame([], $store->offered(TopicType::Identifier, self::CONTRACT_OBJECT), 'withdraw matches subscriber, topic type and topic, whatever the other fields');
68        self::assertCount(1, $store->offered(TopicType::Type, Cargo::Piece), 'other topics untouched');
69    }
70
71    public function testSubscribersComeFromAcceptedRequestsOnly(): void
72    {
73        ['requests' => $requests, 'subscriptions' => $store] = $this->createStores();
74        $now = new DateTimeImmutable('2026-10-02T15:00:00Z');
75        $object = new Iri(self::CONTRACT_OBJECT);
76        // PHPStan remembers a narrowed return type for an identical call, so the empty check uses another object.
77        self::assertSame([], $store->subscribersOf(new Iri('https://1r.example.com/logistics-objects/p0'), [Cargo::Piece], $now));
78
79        $requests->save($this->request('by-id', $this->subscription(self::CONTRACT_PARTNER, TopicType::Identifier, self::CONTRACT_OBJECT)));
80        $requests->save($this->request('by-type', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Type, Cargo::Piece)));
81        $requests->save($this->request('pending', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Identifier, self::CONTRACT_OBJECT), RequestStatus::Pending));
82        $requests->save($this->request('rejected', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Identifier, self::CONTRACT_OBJECT), RequestStatus::Rejected));
83        $requests->save($this->request('expired', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Identifier, self::CONTRACT_OBJECT, new DateTimeImmutable('2026-10-02T14:00:00Z'))));
84        $requests->save($this->request('elsewhere', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Identifier, 'https://1r.example.com/logistics-objects/p2')));
85        $requests->save($this->request('shipments', $this->subscription(self::CONTRACT_OTHER_PARTNER, TopicType::Type, Cargo::Shipment)));
86
87        $subscribers = $store->subscribersOf($object, [Cargo::Piece], $now);
88        $byRequest = [];
89        foreach ($subscribers as $entry) {
90            $byRequest[basename($entry['request']->value)] = $entry['subscription']->subscriber->value;
91        }
92        ksort($byRequest);
93        self::assertSame(['by-id' => self::CONTRACT_PARTNER, 'by-type' => self::CONTRACT_OTHER_PARTNER], $byRequest, 'accepted, unexpired, covering the object by id or type');
94
95        // Revoking the accepted request removes the subscriber.
96        $revoked = $requests->get(new Iri('https://1r.example.com/action-requests/by-id'));
97        self::assertNotNull($revoked);
98        $requests->save($revoked->withStatus(RequestStatus::Revoked, $now, new Iri(self::CONTRACT_PARTNER)));
99        self::assertCount(1, $store->subscribersOf($object, [Cargo::Piece], $now));
100        $asShipment = $store->subscribersOf($object, [Cargo::Shipment], $now);
101        self::assertCount(1, $asShipment, 'a type subscription covers every object of that type');
102        self::assertSame('shipments', basename($asShipment[0]['request']->value));
103    }
104}