Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.10% |
74 / 77 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
| LogisticsObjectStoreContractTests | |
96.10% |
74 / 77 |
|
71.43% |
5 / 7 |
11 | |
0.00% |
0 / 1 |
| createStore | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
| object | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| testCreateStoresRevisionOne | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| testCreatingTheSameUriTwiceIsAnAlreadyExistsError | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| testSaveRevisionIsCompareAndSet | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
3.00 | |||
| testAtAnswersTheRevisionCurrentAtAnInstant | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| testStoredRevisionsAreSnapshots | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| testEraseRemovesEveryRevision | |
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\LogisticsObject; |
| 10 | use LambdaTwelve\OneRecord\Rdf\Iri; |
| 11 | use LambdaTwelve\OneRecord\Server\Spi\LogisticsObjectStore; |
| 12 | use LambdaTwelve\OneRecord\Server\Spi\StoreException; |
| 13 | use LambdaTwelve\OneRecord\Vocabulary\Generated\Cargo; |
| 14 | |
| 15 | /** |
| 16 | * The tests behind LogisticsObjectStoreContract, as a trait, for hosts whose test cases must |
| 17 | * extend a framework base class (Laravel's Testbench, Drupal's KernelTestBase) |
| 18 | * and so cannot extend the abstract contract. Use it in any PHPUnit TestCase |
| 19 | * and implement the abstract hook(s); the abstract class is this trait on a |
| 20 | * bare TestCase. |
| 21 | */ |
| 22 | trait LogisticsObjectStoreContractTests |
| 23 | { |
| 24 | abstract protected function createStore(): LogisticsObjectStore; |
| 25 | |
| 26 | protected function object(string $id, string $description = 'Books'): LogisticsObject |
| 27 | { |
| 28 | return ObjectBuilder::of(Cargo::Piece)->set(Cargo::goodsDescription, $description)->set(Cargo::coload, false)->build(new Iri('https://1r.example.com/logistics-objects/' . $id)); |
| 29 | } |
| 30 | |
| 31 | public function testCreateStoresRevisionOne(): void |
| 32 | { |
| 33 | $store = $this->createStore(); |
| 34 | $at = new DateTimeImmutable('2026-10-02T12:00:00.000Z'); |
| 35 | $object = $this->object('p1'); |
| 36 | |
| 37 | $stored = $store->create($object, $at); |
| 38 | |
| 39 | self::assertSame(1, $stored->revision); |
| 40 | self::assertSame(1, $stored->latestRevision); |
| 41 | self::assertTrue($stored->isLatest()); |
| 42 | self::assertSame($at->format(DATE_RFC3339_EXTENDED), $stored->createdAt->format(DATE_RFC3339_EXTENDED)); |
| 43 | self::assertSame($at->format(DATE_RFC3339_EXTENDED), $stored->lastModified->format(DATE_RFC3339_EXTENDED)); |
| 44 | self::assertTrue($store->exists($object->iri)); |
| 45 | self::assertTrue($store->latest($object->iri)?->object->isSameAs($object), 'the object reads back identical'); |
| 46 | self::assertFalse($store->exists(new Iri('https://1r.example.com/logistics-objects/nope'))); |
| 47 | self::assertNull($store->latest(new Iri('https://1r.example.com/logistics-objects/nope'))); |
| 48 | } |
| 49 | |
| 50 | public function testCreatingTheSameUriTwiceIsAnAlreadyExistsError(): void |
| 51 | { |
| 52 | $store = $this->createStore(); |
| 53 | $store->create($this->object('p1'), new DateTimeImmutable('2026-10-02T12:00:00Z')); |
| 54 | |
| 55 | try { |
| 56 | $store->create($this->object('p1', 'Again'), new DateTimeImmutable('2026-10-02T12:01:00Z')); |
| 57 | self::fail('expected a StoreException'); |
| 58 | } catch (StoreException $e) { |
| 59 | self::assertSame(StoreException::ALREADY_EXISTS, $e->kind); |
| 60 | } |
| 61 | self::assertSame('Books', $store->latest($this->object('p1')->iri)?->object->literal(Cargo::goodsDescription), 'the first object is untouched'); |
| 62 | } |
| 63 | |
| 64 | public function testSaveRevisionIsCompareAndSet(): void |
| 65 | { |
| 66 | $store = $this->createStore(); |
| 67 | $iri = $this->object('p1')->iri; |
| 68 | $t1 = new DateTimeImmutable('2026-10-02T12:00:00.000Z'); |
| 69 | $t2 = new DateTimeImmutable('2026-10-02T13:00:00.000Z'); |
| 70 | $store->create($this->object('p1'), $t1); |
| 71 | |
| 72 | try { |
| 73 | $store->saveRevision($this->object('p1', 'Stale'), 7, $t2); |
| 74 | self::fail('a wrong expected revision must be refused'); |
| 75 | } catch (StoreException $e) { |
| 76 | self::assertSame(StoreException::REVISION_CONFLICT, $e->kind); |
| 77 | } |
| 78 | try { |
| 79 | $store->saveRevision($this->object('ghost', 'Nowhere'), 0, $t2); |
| 80 | self::fail('an unknown object cannot get a revision'); |
| 81 | } catch (StoreException $e) { |
| 82 | self::assertSame(StoreException::NOT_FOUND, $e->kind); |
| 83 | } |
| 84 | |
| 85 | $second = $store->saveRevision($this->object('p1', 'Magazines'), 1, $t2); |
| 86 | self::assertSame(2, $second->revision); |
| 87 | self::assertSame(2, $second->latestRevision); |
| 88 | self::assertSame($t1->format(DATE_RFC3339_EXTENDED), $second->createdAt->format(DATE_RFC3339_EXTENDED), 'createdAt is the object\'s, not the revision\'s'); |
| 89 | self::assertSame($t2->format(DATE_RFC3339_EXTENDED), $second->lastModified->format(DATE_RFC3339_EXTENDED)); |
| 90 | |
| 91 | $first = $store->revision($iri, 1); |
| 92 | self::assertNotNull($first); |
| 93 | self::assertSame(1, $first->revision); |
| 94 | self::assertSame(2, $first->latestRevision, 'an old revision knows the latest one'); |
| 95 | self::assertFalse($first->isLatest()); |
| 96 | self::assertSame('Books', $first->object->literal(Cargo::goodsDescription)); |
| 97 | self::assertSame('Magazines', $store->latest($iri)?->object->literal(Cargo::goodsDescription)); |
| 98 | self::assertNull($store->revision($iri, 3)); |
| 99 | self::assertNull($store->revision($iri, 0)); |
| 100 | } |
| 101 | |
| 102 | public function testAtAnswersTheRevisionCurrentAtAnInstant(): void |
| 103 | { |
| 104 | $store = $this->createStore(); |
| 105 | $iri = $this->object('p1')->iri; |
| 106 | $store->create($this->object('p1'), new DateTimeImmutable('2026-10-02T12:00:00Z')); |
| 107 | $store->saveRevision($this->object('p1', 'Magazines'), 1, new DateTimeImmutable('2026-10-02T13:00:00Z')); |
| 108 | |
| 109 | self::assertNull($store->at($iri, new DateTimeImmutable('2026-10-02T11:59:59Z')), 'before it existed'); |
| 110 | self::assertSame(1, $store->at($iri, new DateTimeImmutable('2026-10-02T12:00:00Z'))?->revision, 'the instant of creation counts'); |
| 111 | self::assertSame(1, $store->at($iri, new DateTimeImmutable('2026-10-02T12:59:59Z'))?->revision); |
| 112 | self::assertSame(2, $store->at($iri, new DateTimeImmutable('2026-10-02T13:00:00Z'))?->revision); |
| 113 | self::assertSame(2, $store->at($iri, new DateTimeImmutable('2030-01-01T00:00:00Z'))?->revision); |
| 114 | } |
| 115 | |
| 116 | public function testStoredRevisionsAreSnapshots(): void |
| 117 | { |
| 118 | $store = $this->createStore(); |
| 119 | $object = $this->object('p1'); |
| 120 | $store->create($object, new DateTimeImmutable('2026-10-02T12:00:00Z')); |
| 121 | |
| 122 | // Editing the object handed in, or the one read back, must not rewrite history (AR-015). |
| 123 | $object->graph->add(new \LambdaTwelve\OneRecord\Rdf\Triple($object->iri, new Iri(Cargo::goodsDescription), \LambdaTwelve\OneRecord\Rdf\Literal::string('INJECTED'))); |
| 124 | $read = $store->latest($object->iri); |
| 125 | self::assertNotNull($read); |
| 126 | self::assertSame(['Books'], array_map(static fn($t): string => $t instanceof \LambdaTwelve\OneRecord\Rdf\Literal ? $t->lexical : '', $read->object->values(Cargo::goodsDescription))); |
| 127 | $read->object->graph->add(new \LambdaTwelve\OneRecord\Rdf\Triple($object->iri, new Iri(Cargo::goodsDescription), \LambdaTwelve\OneRecord\Rdf\Literal::string('INJECTED'))); |
| 128 | self::assertCount(1, $store->revision($object->iri, 1)?->object->values(Cargo::goodsDescription) ?? []); |
| 129 | } |
| 130 | |
| 131 | public function testEraseRemovesEveryRevision(): void |
| 132 | { |
| 133 | $store = $this->createStore(); |
| 134 | $iri = $this->object('p1')->iri; |
| 135 | $store->create($this->object('p1'), new DateTimeImmutable('2026-10-02T12:00:00Z')); |
| 136 | $store->saveRevision($this->object('p1', 'Magazines'), 1, new DateTimeImmutable('2026-10-02T13:00:00Z')); |
| 137 | |
| 138 | $store->erase($iri); |
| 139 | |
| 140 | self::assertFalse($store->exists($iri)); |
| 141 | self::assertNull($store->latest($iri)); |
| 142 | self::assertNull($store->revision($iri, 1)); |
| 143 | self::assertNull($store->at($iri, new DateTimeImmutable('2030-01-01T00:00:00Z'))); |
| 144 | $store->erase($iri); |
| 145 | self::assertSame(1, $store->create($this->object('p1'), new DateTimeImmutable('2026-10-02T14:00:00Z'))->revision, 'the URI can be used again'); |
| 146 | } |
| 147 | } |