Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.83% covered (success)
97.83%
90 / 92
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActionRequestStoreContractTests
97.83% covered (success)
97.83%
90 / 92
90.00% covered (success)
90.00%
9 / 10
13
0.00% covered (danger)
0.00%
0 / 1
 createStore
n/a
0 / 0
n/a
0 / 0
0
 change
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 subscription
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 verification
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 delegation
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 ids
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 testSaveGetAndReplace
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 testEveryPayloadTypeRoundTrips
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 testAuditTrailListsChangeAndVerificationRequestsOfOneObjectOldestFirst
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 testPendingChangesAndAcceptedByType
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 testTransitionIsCompareAndSetOnTheStoredStatus
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Testing\Contract;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Api\AccessDelegation;
9use LambdaTwelve\OneRecord\Api\ActionRequest;
10use LambdaTwelve\OneRecord\Api\ActionRequestType;
11use LambdaTwelve\OneRecord\Api\Error;
12use LambdaTwelve\OneRecord\Api\Permission;
13use LambdaTwelve\OneRecord\Api\RequestStatus;
14use LambdaTwelve\OneRecord\Api\Subscription;
15use LambdaTwelve\OneRecord\Api\SubscriptionEventType;
16use LambdaTwelve\OneRecord\Api\TopicType;
17use LambdaTwelve\OneRecord\Api\Verification;
18use LambdaTwelve\OneRecord\Change\Change;
19use LambdaTwelve\OneRecord\Change\Operation;
20use LambdaTwelve\OneRecord\Change\OperationObject;
21use LambdaTwelve\OneRecord\Rdf\Iri;
22use LambdaTwelve\OneRecord\Rdf\Literal;
23use LambdaTwelve\OneRecord\Server\Spi\ActionRequestStore;
24use LambdaTwelve\OneRecord\Server\Spi\AuditTrailQuery;
25use LambdaTwelve\OneRecord\Server\Spi\StoreException;
26use 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 */
35trait 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}