Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.10% covered (success)
96.10%
74 / 77
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
LogisticsObjectStoreContractTests
96.10% covered (success)
96.10%
74 / 77
71.43% covered (warning)
71.43%
5 / 7
11
0.00% covered (danger)
0.00%
0 / 1
 createStore
n/a
0 / 0
n/a
0 / 0
0
 object
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 testCreateStoresRevisionOne
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 testCreatingTheSameUriTwiceIsAnAlreadyExistsError
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 testSaveRevisionIsCompareAndSet
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
3.00
 testAtAnswersTheRevisionCurrentAtAnInstant
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 testStoredRevisionsAreSnapshots
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 testEraseRemovesEveryRevision
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\LogisticsObject;
10use LambdaTwelve\OneRecord\Rdf\Iri;
11use LambdaTwelve\OneRecord\Server\Spi\LogisticsObjectStore;
12use LambdaTwelve\OneRecord\Server\Spi\StoreException;
13use 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 */
22trait 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}