Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.40% covered (success)
97.40%
75 / 77
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogisticsEventStoreContractTests
97.40% covered (success)
97.40%
75 / 77
85.71% covered (warning)
85.71%
6 / 7
9
0.00% covered (danger)
0.00%
0 / 1
 createStore
n/a
0 / 0
n/a
0 / 0
0
 event
100.00% covered (success)
100.00%
5 / 5
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
 testAppendGetAndListInCreationOrder
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 testFiltersSortingAndPaging
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 testLastModifiedIsTheNewestEventAndARepeatedIriIsRefused
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
3.01
 testEraseForRemovesOneObjectsEventsOnly
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 testStoredEventsAreSnapshots
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Testing\Contract;
6
7use DateTimeImmutable;
8use LambdaTwelve\OneRecord\Model\Builder\ObjectBuilder;
9use LambdaTwelve\OneRecord\Model\Builder\Values;
10use LambdaTwelve\OneRecord\Model\LogisticsEvent;
11use LambdaTwelve\OneRecord\Rdf\Iri;
12use LambdaTwelve\OneRecord\Server\Spi\EventQuery;
13use LambdaTwelve\OneRecord\Server\Spi\LogisticsEventStore;
14use LambdaTwelve\OneRecord\Server\Spi\StoreException;
15use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo;
16
17/**
18 * The tests behind LogisticsEventStoreContract, as a trait, for hosts whose test cases must
19 * extend a framework base class (Laravel's Testbench, Drupal's KernelTestBase)
20 * and so cannot extend the abstract contract. Use it in any PHPUnit TestCase
21 * and implement the abstract hook(s); the abstract class is this trait on a
22 * bare TestCase.
23 */
24trait LogisticsEventStoreContractTests
25{
26    protected const string CONTRACT_OBJECT = 'https://1r.example.com/logistics-objects/p1';
27    protected const string CONTRACT_OTHER = 'https://1r.example.com/logistics-objects/p2';
28
29    abstract protected function createStore(): LogisticsEventStore;
30
31    protected function event(string $id, string $code, string $occurred, string $created, string $object = self::CONTRACT_OBJECT): LogisticsEvent
32    {
33        return ObjectBuilder::ofEvent()
34            ->set(Cargo::eventDate, Values::dateTime(new DateTimeImmutable($occurred)))
35            ->set(Cargo::eventCode, Values::code('StatusCode', $code))
36            ->set(Cargo::eventName, 'Event ' . $id)
37            ->buildEvent(new Iri($object . '/logistics-events/' . $id), new Iri($object), new DateTimeImmutable($created));
38    }
39
40    /**
41     * @param list<LogisticsEvent> $events
42     * @return list<string>
43     */
44    private static function ids(array $events): array
45    {
46        return array_map(static fn(LogisticsEvent $e): string => basename($e->iri->value), $events);
47    }
48
49    public function testAppendGetAndListInCreationOrder(): void
50    {
51        $store = $this->createStore();
52        $a = $this->event('a', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:00:00Z');
53        $b = $this->event('b', 'ARR', '2026-10-02T09:00:00Z', '2026-10-02T12:05:00Z');
54        $store->append($a);
55        $store->append($b);
56        $store->append($this->event('c', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:10:00Z', self::CONTRACT_OTHER));
57
58        $got = $store->get($a->iri);
59        self::assertNotNull($got);
60        self::assertSame($a->iri->value, $got->iri->value);
61        self::assertSame(self::CONTRACT_OBJECT, $got->logisticsObject->value);
62        self::assertSame('2026-10-02T12:00:00+00:00', $got->created->format(DATE_ATOM));
63        self::assertTrue($got->matchesCode('DEP'));
64        self::assertNull($store->get(new Iri(self::CONTRACT_OBJECT . '/logistics-events/nope')));
65
66        self::assertSame(['a', 'b'], self::ids($store->query(new Iri(self::CONTRACT_OBJECT), EventQuery::all())), 'only this object\'s events, oldest created first');
67        self::assertSame(['c'], self::ids($store->query(new Iri(self::CONTRACT_OTHER), EventQuery::all())));
68        self::assertSame([], $store->query(new Iri('https://1r.example.com/logistics-objects/none'), EventQuery::all()));
69    }
70
71    public function testFiltersSortingAndPaging(): void
72    {
73        $store = $this->createStore();
74        $store->append($this->event('a', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:00:00Z'));
75        $store->append($this->event('b', 'ARR', '2026-10-02T09:00:00Z', '2026-10-02T12:05:00Z'));
76        $store->append($this->event('c', 'DEP', '2026-10-02T11:00:00Z', '2026-10-02T12:10:00Z'));
77        $object = new Iri(self::CONTRACT_OBJECT);
78
79        self::assertSame(['a', 'c'], self::ids($store->query($object, new EventQuery(eventCodes: ['DEP']))));
80        self::assertSame(['a', 'c'], self::ids($store->query($object, new EventQuery(eventCodes: ['https://onerecord.iata.org/ns/code-lists/StatusCode#DEP']))), 'codes may be given as IRIs');
81        self::assertSame(['a', 'b', 'c'], self::ids($store->query($object, new EventQuery(eventCodes: ['DEP', 'ARR']))));
82        self::assertSame([], self::ids($store->query($object, new EventQuery(eventCodes: ['RCS']))));
83        self::assertSame(['b', 'c'], self::ids($store->query($object, new EventQuery(createdAfter: new DateTimeImmutable('2026-10-02T12:00:00Z')))), 'after is exclusive');
84        self::assertSame(['a', 'b'], self::ids($store->query($object, new EventQuery(createdBefore: new DateTimeImmutable('2026-10-02T12:10:00Z')))), 'before is exclusive');
85        self::assertSame(['a', 'c'], self::ids($store->query($object, new EventQuery(occurredAfter: new DateTimeImmutable('2026-10-02T09:30:00Z')))));
86        self::assertSame(['b', 'a'], self::ids($store->query($object, new EventQuery(occurredBefore: new DateTimeImmutable('2026-10-02T10:30:00Z'), sort: EventQuery::SORT_EVENT_ASC))));
87        self::assertSame(['c', 'a', 'b'], self::ids($store->query($object, new EventQuery(sort: EventQuery::SORT_EVENT_DESC))));
88        self::assertSame(['c', 'b', 'a'], self::ids($store->query($object, new EventQuery(sort: EventQuery::SORT_CREATED_DESC))));
89        self::assertSame(['b', 'c'], self::ids($store->query($object, new EventQuery(limit: 2, skip: 1))));
90        self::assertSame([], self::ids($store->query($object, new EventQuery(skip: 3))));
91    }
92
93    public function testLastModifiedIsTheNewestEventAndARepeatedIriIsRefused(): void
94    {
95        $store = $this->createStore();
96        $object = new Iri(self::CONTRACT_OBJECT);
97        self::assertNull($store->lastModified($object));
98
99        $store->append($this->event('a', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:05:00Z'));
100        $store->append($this->event('b', 'ARR', '2026-10-02T09:00:00Z', '2026-10-02T12:00:00Z'));
101        self::assertSame('2026-10-02T12:05:00+00:00', $store->lastModified($object)?->format(DATE_ATOM), 'the newest event, not the last appended');
102
103        try {
104            $store->append($this->event('a', 'RCS', '2026-10-02T10:00:00Z', '2026-10-02T12:30:00Z'));
105            self::fail('an event IRI is appended once');
106        } catch (StoreException $e) {
107            self::assertSame(StoreException::ALREADY_EXISTS, $e->kind);
108        }
109        self::assertTrue($store->get(new Iri(self::CONTRACT_OBJECT . '/logistics-events/a'))?->matchesCode('DEP'), 'the original stays');
110
111        // The same IRI filed under another object is still the same IRI (AR-022).
112        $sameIriOtherObject = ObjectBuilder::ofEvent()
113            ->set(Cargo::eventDate, Values::dateTime(new DateTimeImmutable('2026-10-02T10:00:00Z')))
114            ->buildEvent(new Iri(self::CONTRACT_OBJECT . '/logistics-events/a'), new Iri(self::CONTRACT_OTHER), new DateTimeImmutable('2026-10-02T12:30:00Z'));
115        try {
116            $store->append($sameIriOtherObject);
117            self::fail('event IRIs are unique on the server');
118        } catch (StoreException $e) {
119            self::assertSame(StoreException::ALREADY_EXISTS, $e->kind);
120        }
121        self::assertSame([], $store->query(new Iri(self::CONTRACT_OTHER), EventQuery::all()));
122    }
123
124    public function testEraseForRemovesOneObjectsEventsOnly(): void
125    {
126        $store = $this->createStore();
127        $store->append($this->event('a', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:00:00Z'));
128        $store->append($this->event('c', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:10:00Z', self::CONTRACT_OTHER));
129
130        $store->eraseFor(new Iri(self::CONTRACT_OBJECT));
131
132        self::assertSame([], $store->query(new Iri(self::CONTRACT_OBJECT), EventQuery::all()));
133        self::assertNull($store->get(new Iri(self::CONTRACT_OBJECT . '/logistics-events/a')));
134        self::assertNull($store->lastModified(new Iri(self::CONTRACT_OBJECT)));
135        self::assertSame(['c'], self::ids($store->query(new Iri(self::CONTRACT_OTHER), EventQuery::all())));
136    }
137
138    public function testStoredEventsAreSnapshots(): void
139    {
140        $store = $this->createStore();
141        $event = $this->event('a', 'DEP', '2026-10-02T10:00:00Z', '2026-10-02T12:00:00Z');
142        $store->append($event);
143
144        // Editing the event handed in, or the one read back, must not edit the log (R2-005).
145        $event->graph->add(new \LambdaTwelve\OneRecord\Rdf\Triple($event->iri, new Iri(Cargo::eventName), \LambdaTwelve\OneRecord\Rdf\Literal::string('after-write')));
146        $read = $store->get($event->iri);
147        self::assertNotNull($read);
148        self::assertCount(1, $read->graph->objects($event->iri, Cargo::eventName));
149        $read->graph->add(new \LambdaTwelve\OneRecord\Rdf\Triple($event->iri, new Iri(Cargo::eventName), \LambdaTwelve\OneRecord\Rdf\Literal::string('after-read')));
150        self::assertCount(1, $store->get($event->iri)?->graph->objects($event->iri, Cargo::eventName) ?? []);
151        $listed = $store->query(new Iri(self::CONTRACT_OBJECT), EventQuery::all());
152        self::assertCount(1, $listed[0]->graph->objects($event->iri, Cargo::eventName));
153    }
154}