Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.40% |
75 / 77 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
| LogisticsEventStoreContractTests | |
97.40% |
75 / 77 |
|
85.71% |
6 / 7 |
9 | |
0.00% |
0 / 1 |
| createStore | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| event | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| ids | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| testAppendGetAndListInCreationOrder | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
| testFiltersSortingAndPaging | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
| testLastModifiedIsTheNewestEventAndARepeatedIriIsRefused | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
3.01 | |||
| testEraseForRemovesOneObjectsEventsOnly | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| testStoredEventsAreSnapshots | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Testing\Contract; |
| 6 | |
| 7 | use DateTimeImmutable; |
| 8 | use LambdaTwelve\OneRecord\Model\Builder\ObjectBuilder; |
| 9 | use LambdaTwelve\OneRecord\Model\Builder\Values; |
| 10 | use LambdaTwelve\OneRecord\Model\LogisticsEvent; |
| 11 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 12 | use LambdaTwelve\OneRecord\Server\Spi\EventQuery; |
| 13 | use LambdaTwelve\OneRecord\Server\Spi\LogisticsEventStore; |
| 14 | use LambdaTwelve\OneRecord\Server\Spi\StoreException; |
| 15 | use 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 | */ |
| 24 | trait 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 | } |