Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.83% |
90 / 92 |
|
90.00% |
9 / 10 |
CRAP | |
0.00% |
0 / 1 |
| ActionRequestStoreContractTests | |
97.83% |
90 / 92 |
|
90.00% |
9 / 10 |
13 | |
0.00% |
0 / 1 |
| createStore | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| change | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| subscription | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| verification | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| delegation | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| ids | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| testSaveGetAndReplace | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
1 | |||
| testEveryPayloadTypeRoundTrips | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| testAuditTrailListsChangeAndVerificationRequestsOfOneObjectOldestFirst | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
| testPendingChangesAndAcceptedByType | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| testTransitionIsCompareAndSetOnTheStoredStatus | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
3.02 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Testing\Contract; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Api\AccessDelegation; |
| 9 | use LambdaTwelve\OneRecord\Api\ActionRequest; |
| 10 | use LambdaTwelve\OneRecord\Api\ActionRequestType; |
| 11 | use LambdaTwelve\OneRecord\Api\Error; |
| 12 | use LambdaTwelve\OneRecord\Api\Permission; |
| 13 | use LambdaTwelve\OneRecord\Api\RequestStatus; |
| 14 | use LambdaTwelve\OneRecord\Api\Subscription; |
| 15 | use LambdaTwelve\OneRecord\Api\SubscriptionEventType; |
| 16 | use LambdaTwelve\OneRecord\Api\TopicType; |
| 17 | use LambdaTwelve\OneRecord\Api\Verification; |
| 18 | use LambdaTwelve\OneRecord\Change\Change; |
| 19 | use LambdaTwelve\OneRecord\Change\Operation; |
| 20 | use LambdaTwelve\OneRecord\Change\OperationObject; |
| 21 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 22 | use LambdaTwelve\OneRecord\Rdf\Literal; |
| 23 | use LambdaTwelve\OneRecord\Server\Spi\ActionRequestStore; |
| 24 | use LambdaTwelve\OneRecord\Server\Spi\AuditTrailQuery; |
| 25 | use LambdaTwelve\OneRecord\Server\Spi\StoreException; |
| 26 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 27 | |
| 28 | /** |
| 29 | * The tests behind ActionRequestStoreContract, as a trait, for hosts whose test cases must |
| 30 | * extend a framework base class (Laravel's Testbench, Drupal's KernelTestBase) |
| 31 | * and so cannot extend the abstract contract. Use it in any PHPUnit TestCase |
| 32 | * and implement the abstract hook(s); the abstract class is this trait on a |
| 33 | * bare TestCase. |
| 34 | */ |
| 35 | trait ActionRequestStoreContractTests |
| 36 | { |
| 37 | protected const string CONTRACT_OBJECT = 'https://1r.example.com/logistics-objects/p1'; |
| 38 | protected const string CONTRACT_OTHER = 'https://1r.example.com/logistics-objects/p2'; |
| 39 | protected const string CONTRACT_PARTNER = 'https://1r.partner.example/logistics-objects/partner'; |
| 40 | protected const string CONTRACT_HOLDER = 'https://1r.example.com/logistics-objects/holder'; |
| 41 | |
| 42 | abstract protected function createStore(): ActionRequestStore; |
| 43 | |
| 44 | protected function change(string $id, string $at, int $revision = 1, string $object = self::CONTRACT_OBJECT): ActionRequest |
| 45 | { |
| 46 | $change = new Change(new Iri($object), $revision, [Operation::add(new Iri($object), new Iri(Cargo::goodsDescription), new OperationObject(Literal::XSD_STRING, 'Books ' . $id))], 'Change ' . $id); |
| 47 | |
| 48 | return ActionRequest::create(new Iri('https://1r.example.com/action-requests/' . $id), $change, new Iri(self::CONTRACT_PARTNER), new DateTimeImmutable($at)); |
| 49 | } |
| 50 | |
| 51 | protected function subscription(string $id, string $at, string $topic = self::CONTRACT_OBJECT, TopicType $type = TopicType::Identifier): ActionRequest |
| 52 | { |
| 53 | $subscription = new Subscription(new Iri(self::CONTRACT_PARTNER), $type, $topic, [SubscriptionEventType::LogisticsObjectUpdated]); |
| 54 | |
| 55 | return ActionRequest::create(new Iri('https://1r.example.com/action-requests/' . $id), $subscription, new Iri(self::CONTRACT_PARTNER), new DateTimeImmutable($at)); |
| 56 | } |
| 57 | |
| 58 | protected function verification(string $id, string $at, string $object = self::CONTRACT_OBJECT): ActionRequest |
| 59 | { |
| 60 | $verification = new Verification(new Iri($object), [Error::of('Weight missing', '422', 'No grossWeight.', Cargo::grossWeight)], 1); |
| 61 | |
| 62 | return ActionRequest::create(new Iri('https://1r.example.com/action-requests/' . $id), $verification, new Iri(self::CONTRACT_PARTNER), new DateTimeImmutable($at)); |
| 63 | } |
| 64 | |
| 65 | protected function delegation(string $id, string $at, string $object = self::CONTRACT_OBJECT): ActionRequest |
| 66 | { |
| 67 | $delegation = new AccessDelegation([Permission::GetLogisticsObject], [new Iri(self::CONTRACT_PARTNER)], [new Iri($object)]); |
| 68 | |
| 69 | return ActionRequest::create(new Iri('https://1r.example.com/action-requests/' . $id), $delegation, new Iri(self::CONTRACT_PARTNER), new DateTimeImmutable($at)); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * @param list<ActionRequest> $requests |
| 74 | * @return list<string> |
| 75 | */ |
| 76 | private static function ids(array $requests): array |
| 77 | { |
| 78 | return array_map(static fn(ActionRequest $r): string => basename($r->iri->value), $requests); |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * A second save() under the same IRI replaces status, status history and |
| 83 | * errors, what transition() changes; the request's identity, payload and |
| 84 | * the objects it concerns never change after the first save (see the |
| 85 | * interface), so nothing here moves a request between objects or types. |
| 86 | */ |
| 87 | public function testSaveGetAndReplace(): void |
| 88 | { |
| 89 | $store = $this->createStore(); |
| 90 | $request = $this->change('c1', '2026-10-02T12:00:00.000Z'); |
| 91 | self::assertNull($store->get($request->iri)); |
| 92 | |
| 93 | $store->save($request); |
| 94 | $read = $store->get($request->iri); |
| 95 | self::assertNotNull($read); |
| 96 | self::assertSame(RequestStatus::Pending, $read->status); |
| 97 | self::assertSame(ActionRequestType::Change, $read->type); |
| 98 | self::assertSame(self::CONTRACT_PARTNER, $read->requestedBy->value); |
| 99 | self::assertSame('2026-10-02T12:00:00.000+00:00', $read->requestedAt->format(DATE_RFC3339_EXTENDED)); |
| 100 | self::assertInstanceOf(Change::class, $read->payload); |
| 101 | self::assertSame('Change c1', $read->payload->description); |
| 102 | |
| 103 | $accepted = $request->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:05:00.000Z'), new Iri(self::CONTRACT_HOLDER)); |
| 104 | $store->save($accepted); |
| 105 | $read = $store->get($request->iri); |
| 106 | self::assertNotNull($read); |
| 107 | self::assertSame(RequestStatus::Accepted, $read->status); |
| 108 | self::assertSame('2026-10-02T12:05:00.000+00:00', $read->statusSince?->format(DATE_RFC3339_EXTENDED)); |
| 109 | self::assertCount(1, $read->history, 'the pending phase is history'); |
| 110 | self::assertSame(RequestStatus::Pending, $read->history[0]->status); |
| 111 | self::assertSame(self::CONTRACT_HOLDER, $read->history[0]->changedBy?->value); |
| 112 | |
| 113 | $failed = $accepted->withStatus(RequestStatus::Failed, new DateTimeImmutable('2026-10-02T12:06:00.000Z'), null, [Error::of('Conflict', '409', 'Stale revision.')]); |
| 114 | $store->save($failed); |
| 115 | self::assertSame('Conflict', $store->get($request->iri)?->errors[0]->title ?? null, 'errors are kept'); |
| 116 | } |
| 117 | |
| 118 | public function testEveryPayloadTypeRoundTrips(): void |
| 119 | { |
| 120 | $store = $this->createStore(); |
| 121 | foreach ([$this->subscription('s1', '2026-10-02T12:00:00Z'), $this->verification('v1', '2026-10-02T12:00:00Z'), $this->delegation('d1', '2026-10-02T12:00:00Z')] as $request) { |
| 122 | $store->save($request); |
| 123 | $read = $store->get($request->iri); |
| 124 | self::assertNotNull($read); |
| 125 | self::assertSame($request->type, $read->type); |
| 126 | self::assertSame($request->payload::class, $read->payload::class); |
| 127 | } |
| 128 | $subscription = $store->get(new Iri('https://1r.example.com/action-requests/s1'))?->payload; |
| 129 | self::assertInstanceOf(Subscription::class, $subscription); |
| 130 | self::assertSame(self::CONTRACT_OBJECT, $subscription->topic); |
| 131 | $verification = $store->get(new Iri('https://1r.example.com/action-requests/v1'))?->payload; |
| 132 | self::assertInstanceOf(Verification::class, $verification); |
| 133 | self::assertSame('Weight missing', $verification->errors[0]->title); |
| 134 | $delegation = $store->get(new Iri('https://1r.example.com/action-requests/d1'))?->payload; |
| 135 | self::assertInstanceOf(AccessDelegation::class, $delegation); |
| 136 | self::assertSame([Permission::GetLogisticsObject], $delegation->permissions); |
| 137 | } |
| 138 | |
| 139 | public function testAuditTrailListsChangeAndVerificationRequestsOfOneObjectOldestFirst(): void |
| 140 | { |
| 141 | $store = $this->createStore(); |
| 142 | $store->save($this->change('c2', '2026-10-02T13:00:00Z')); |
| 143 | $store->save($this->change('c1', '2026-10-02T12:00:00Z')->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:30:00Z'), new Iri(self::CONTRACT_HOLDER))); |
| 144 | $store->save($this->verification('v1', '2026-10-02T14:00:00Z')); |
| 145 | $store->save($this->subscription('s1', '2026-10-02T12:10:00Z'), ); |
| 146 | $store->save($this->delegation('d1', '2026-10-02T12:20:00Z')); |
| 147 | $store->save($this->change('other', '2026-10-02T12:00:00Z', 1, self::CONTRACT_OTHER)); |
| 148 | $object = new Iri(self::CONTRACT_OBJECT); |
| 149 | |
| 150 | self::assertSame(['c1', 'c2', 'v1'], self::ids($store->auditTrail($object, AuditTrailQuery::all())), 'changes and verifications about this object, by request time'); |
| 151 | self::assertSame(['c1'], self::ids($store->auditTrail($object, new AuditTrailQuery(status: RequestStatus::Accepted)))); |
| 152 | self::assertSame(['c2', 'v1'], self::ids($store->auditTrail($object, new AuditTrailQuery(status: RequestStatus::Pending)))); |
| 153 | self::assertSame(['c2', 'v1'], self::ids($store->auditTrail($object, new AuditTrailQuery(updatedFrom: new DateTimeImmutable('2026-10-02T12:45:00Z')))), 'updated-from compares the last status change'); |
| 154 | self::assertSame(['c1'], self::ids($store->auditTrail($object, new AuditTrailQuery(updatedTo: new DateTimeImmutable('2026-10-02T12:45:00Z'))))); |
| 155 | self::assertSame([], $store->auditTrail(new Iri('https://1r.example.com/logistics-objects/none'), AuditTrailQuery::all())); |
| 156 | } |
| 157 | |
| 158 | public function testPendingChangesAndAcceptedByType(): void |
| 159 | { |
| 160 | $store = $this->createStore(); |
| 161 | $store->save($this->change('c1', '2026-10-02T12:00:00Z')->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:30:00Z'), new Iri(self::CONTRACT_HOLDER))); |
| 162 | $store->save($this->change('c2', '2026-10-02T13:00:00Z')); |
| 163 | $store->save($this->change('c3', '2026-10-02T13:30:00Z')); |
| 164 | $store->save($this->verification('v1', '2026-10-02T14:00:00Z')); |
| 165 | $store->save($this->subscription('s1', '2026-10-02T12:00:00Z')->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:01:00Z'), new Iri(self::CONTRACT_HOLDER))); |
| 166 | $store->save($this->subscription('s2', '2026-10-02T12:00:00Z')); |
| 167 | $store->save($this->delegation('d1', '2026-10-02T12:00:00Z')->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:01:00Z'), new Iri(self::CONTRACT_HOLDER))); |
| 168 | |
| 169 | self::assertSame(['c2', 'c3'], self::ids($store->pendingChanges(new Iri(self::CONTRACT_OBJECT))), 'pending changes only, no verifications'); |
| 170 | self::assertSame(['s1'], self::ids($store->accepted(ActionRequestType::Subscription))); |
| 171 | self::assertSame(['d1'], self::ids($store->accepted(ActionRequestType::AccessDelegation))); |
| 172 | self::assertSame(['c1'], self::ids($store->accepted(ActionRequestType::Change))); |
| 173 | self::assertSame([], $store->accepted(ActionRequestType::Verification)); |
| 174 | } |
| 175 | |
| 176 | public function testTransitionIsCompareAndSetOnTheStoredStatus(): void |
| 177 | { |
| 178 | $store = $this->createStore(); |
| 179 | $pending = $this->change('c1', '2026-10-02T12:00:00Z'); |
| 180 | $accepted = $pending->withStatus(RequestStatus::Accepted, new DateTimeImmutable('2026-10-02T12:05:00Z'), new Iri(self::CONTRACT_HOLDER)); |
| 181 | $revoked = $pending->withStatus(RequestStatus::Revoked, new DateTimeImmutable('2026-10-02T12:06:00Z'), new Iri(self::CONTRACT_PARTNER)); |
| 182 | |
| 183 | try { |
| 184 | $store->transition($accepted, RequestStatus::Pending); |
| 185 | self::fail('an unknown request cannot transition'); |
| 186 | } catch (StoreException $e) { |
| 187 | self::assertSame(StoreException::NOT_FOUND, $e->kind); |
| 188 | } |
| 189 | $store->save($pending); |
| 190 | $store->transition($accepted, RequestStatus::Pending); |
| 191 | self::assertSame(RequestStatus::Accepted, $store->get($pending->iri)?->status); |
| 192 | |
| 193 | try { |
| 194 | $store->transition($revoked, RequestStatus::Pending); |
| 195 | self::fail('a decision made on a stale snapshot must not win'); |
| 196 | } catch (StoreException $e) { |
| 197 | self::assertSame(StoreException::STATUS_CONFLICT, $e->kind); |
| 198 | } |
| 199 | self::assertSame(RequestStatus::Accepted, $store->get($pending->iri)->status, 'the stored state is untouched'); |
| 200 | } |
| 201 | } |