Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
42 / 42 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| SubscriptionStoreContractTests | |
100.00% |
42 / 42 |
|
100.00% |
4 / 4 |
6 | |
100.00% |
1 / 1 |
| createStores | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| subscription | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| request | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| testOffersAreRegisteredAnsweredAndWithdrawn | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| testSubscribersComeFromAcceptedRequestsOnly | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Testing\Contract; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Api\ActionRequest; |
| 9 | use LambdaTwelve\OneRecord\Api\RequestStatus; |
| 10 | use LambdaTwelve\OneRecord\Api\Subscription; |
| 11 | use LambdaTwelve\OneRecord\Api\SubscriptionEventType; |
| 12 | use LambdaTwelve\OneRecord\Api\TopicType; |
| 13 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 14 | use LambdaTwelve\OneRecord\Server\Spi\ActionRequestStore; |
| 15 | use LambdaTwelve\OneRecord\Server\Spi\SubscriptionStore; |
| 16 | use 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 | */ |
| 25 | trait 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 | } |