55namespace ProfessionalWiki \NeoWiki \Tests \GraphDatabasePlugins \Neo4j \Persistence ;
66
77use Laudis \Neo4j \Contracts \ClientInterface ;
8+ use ProfessionalWiki \NeoWiki \Application \PageReadAuthorizer ;
89use ProfessionalWiki \NeoWiki \Application \SubjectLabelLookupResult ;
910use ProfessionalWiki \NeoWiki \Domain \GraphDatabase \GraphDatabasePlugin ;
1011use ProfessionalWiki \NeoWiki \Domain \Schema \SchemaName ;
1920use ProfessionalWiki \NeoWiki \Tests \Data \TestSubject ;
2021use ProfessionalWiki \NeoWiki \Tests \NeoWikiIntegrationTestCase ;
2122use ProfessionalWiki \NeoWiki \Tests \TestDoubles \InMemorySchemaLookup ;
23+ use ProfessionalWiki \NeoWiki \Tests \TestDoubles \SelectivePageReadAuthorizer ;
24+ use ProfessionalWiki \NeoWiki \Tests \TestDoubles \StubPageReadAuthorizer ;
2225
2326/**
2427 * @covers \ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jSubjectLabelLookup
@@ -142,6 +145,77 @@ public function testExcludesSubjectsWithoutWikiId(): void {
142145 );
143146 }
144147
148+ public function testExcludesForeignSubjectsReachableThroughALocalPage (): void {
149+ $ this ->saveSubjects ( new SubjectMap (
150+ TestSubject::build ( id: self ::SUBJECT_ID_1 , label: new SubjectLabel ( 'Apple Pie ' ) ),
151+ ) );
152+
153+ // A cross-wiki-shared Subject id (ADR 22) can leave a local Page holding a Subject stamped
154+ // for another wiki. The Subject filter must still withhold it so its foreign label, and a
155+ // page id that resolves against the wrong wiki, cannot surface.
156+ $ this ->getClient ()->run (
157+ 'CREATE (:Page { id: 500, wiki_id: $wikiId })-[:HasSubject { isMain: false }]-> '
158+ . '(:Subject: ' . Cypher::escape ( TestSubject::DEFAULT_SCHEMA_ID )
159+ . ' { id: "sTestSLL4444441", name: "Apple Tart", wiki_id: $otherWikiId }) ' ,
160+ [ 'wikiId ' => $ this ->currentWikiId (), 'otherWikiId ' => $ this ->currentWikiId () . '-other ' ]
161+ );
162+
163+ $ this ->assertEquals (
164+ [ new SubjectLabelLookupResult ( self ::SUBJECT_ID_1 , 'Apple Pie ' ) ],
165+ $ this ->getSubjectLabelsMatching ( 'Apple ' )
166+ );
167+ }
168+
169+ public function testReturnsALocallyOwnedSubjectOnceWhenAForeignPageAlsoReferencesIt (): void {
170+ $ this ->saveSubjectOnPage ( pageId: 7 , subjectId: self ::SUBJECT_ID_1 , label: 'Apple Pie ' );
171+
172+ // In a shared graph the same Subject node (ADR 22 keeps it by bare id) can be referenced by
173+ // a foreign wiki's Page too. Only the local owning Page is followed, so the label appears
174+ // once and the page id used for the read check resolves within this wiki, not the foreign
175+ // one whose colliding page id would gate an unrelated local page.
176+ $ this ->getClient ()->run (
177+ 'MATCH (subject:Subject { id: $subjectId }) '
178+ . 'CREATE (:Page { id: 7, wiki_id: $otherWikiId })-[:HasSubject { isMain: false }]->(subject) ' ,
179+ [ 'subjectId ' => self ::SUBJECT_ID_1 , 'otherWikiId ' => $ this ->currentWikiId () . '-other ' ]
180+ );
181+
182+ $ this ->assertEquals (
183+ [ new SubjectLabelLookupResult ( self ::SUBJECT_ID_1 , 'Apple Pie ' ) ],
184+ $ this ->getSubjectLabelsMatching ( 'Apple ' )
185+ );
186+ }
187+
188+ public function testSubjectsOnUnreadablePagesAreOmitted (): void {
189+ $ this ->saveSubjects ( new SubjectMap (
190+ TestSubject::build ( id: self ::SUBJECT_ID_1 , label: new SubjectLabel ( 'Apple Pie ' ) ),
191+ ) );
192+
193+ $ this ->assertSame (
194+ [],
195+ $ this ->newLookup ( readAuthorizer: new StubPageReadAuthorizer ( allowed: false ) )
196+ ->getSubjectLabelsMatching ( 'Apple ' , 10 , TestSubject::DEFAULT_SCHEMA_ID )
197+ );
198+ }
199+
200+ public function testOverFetchesPastUnreadableRowsToFillTheLimit (): void {
201+ $ this ->saveSubjectOnPage ( pageId: 1 , subjectId: 'sTestSLL1111141 ' , label: 'Apple 1 ' );
202+ $ this ->saveSubjectOnPage ( pageId: 2 , subjectId: 'sTestSLL1111142 ' , label: 'Apple 2 ' );
203+ $ this ->saveSubjectOnPage ( pageId: 3 , subjectId: 'sTestSLL1111143 ' , label: 'Apple 3 ' );
204+
205+ // Page 2 is hidden and sorts between the two readable rows, so a naive "LIMIT 2 then
206+ // filter" would return only Apple 1. Over-fetching then re-limiting must still yield two.
207+ $ results = $ this ->newLookup ( readAuthorizer: new SelectivePageReadAuthorizer ( deniedPageIds: [ 2 ] ) )
208+ ->getSubjectLabelsMatching ( 'Apple ' , 2 , TestSubject::DEFAULT_SCHEMA_ID );
209+
210+ $ this ->assertEquals (
211+ [
212+ new SubjectLabelLookupResult ( 'sTestSLL1111141 ' , 'Apple 1 ' ),
213+ new SubjectLabelLookupResult ( 'sTestSLL1111143 ' , 'Apple 3 ' ),
214+ ],
215+ $ results
216+ );
217+ }
218+
145219 private function saveSubjects ( SubjectMap $ subjects ): void {
146220 $ this ->newProjectionStore ()->savePage ( TestPage::build (
147221 id: 1 ,
@@ -150,6 +224,16 @@ private function saveSubjects( SubjectMap $subjects ): void {
150224 ) );
151225 }
152226
227+ private function saveSubjectOnPage ( int $ pageId , string $ subjectId , string $ label ): void {
228+ $ this ->newProjectionStore ()->savePage ( TestPage::build (
229+ id: $ pageId ,
230+ properties: TestPageProperties::build ( title: 'Page ' . $ pageId ),
231+ childSubjects: new SubjectMap (
232+ TestSubject::build ( id: $ subjectId , label: new SubjectLabel ( $ label ) )
233+ )
234+ ) );
235+ }
236+
153237 protected function newProjectionStore (): GraphDatabasePlugin {
154238 return NeoWikiExtension::getInstance ()->newNeo4jProjectionStore (
155239 new InMemorySchemaLookup (
@@ -165,10 +249,14 @@ private function getSubjectLabelsMatching( string $search, int $limit = 10 ): ar
165249 return $ this ->newLookup ()->getSubjectLabelsMatching ( $ search , $ limit , TestSubject::DEFAULT_SCHEMA_ID );
166250 }
167251
168- private function newLookup ( ClientInterface $ client = null ): Neo4jSubjectLabelLookup {
252+ private function newLookup (
253+ ClientInterface $ client = null ,
254+ ?PageReadAuthorizer $ readAuthorizer = null
255+ ): Neo4jSubjectLabelLookup {
169256 return new Neo4jSubjectLabelLookup (
170257 client: $ client ?? $ this ->getClient (),
171258 wikiId: $ this ->currentWikiId (),
259+ readAuthorizer: $ readAuthorizer ?? new StubPageReadAuthorizer ( allowed: true ),
172260 );
173261 }
174262
@@ -179,17 +267,24 @@ private function currentWikiId(): string {
179267 /**
180268 * Creates a Subject node directly in the graph, bypassing the projection store, so a test can
181269 * plant a node with a chosen wiki_id (or none at all, as written before wiki_id stamping existed).
270+ *
271+ * The node is attached to a Page carrying the same wiki_id via a HasSubject edge, so it is
272+ * reachable by the lookup's Page->Subject traversal. Without the Page it would be dropped for
273+ * having no Page at all, which would make the wiki filter these tests target untested.
182274 */
183275 private function createSubjectNode ( string $ id , string $ name , ?string $ wikiId ): void {
184- $ properties = [ 'id ' => $ id , 'name ' => $ name ];
276+ $ subjectProperties = [ 'id ' => $ id , 'name ' => $ name ];
277+ $ pageProperties = [ 'id ' => 0 ];
185278
186279 if ( $ wikiId !== null ) {
187- $ properties ['wiki_id ' ] = $ wikiId ;
280+ $ subjectProperties ['wiki_id ' ] = $ wikiId ;
281+ $ pageProperties ['wiki_id ' ] = $ wikiId ;
188282 }
189283
190284 $ this ->getClient ()->run (
191- 'CREATE (n:Subject: ' . Cypher::escape ( TestSubject::DEFAULT_SCHEMA_ID ) . ' $properties) ' ,
192- [ 'properties ' => $ properties ]
285+ 'CREATE (:Page $pageProperties)-[:HasSubject { isMain: false }]-> '
286+ . '(:Subject: ' . Cypher::escape ( TestSubject::DEFAULT_SCHEMA_ID ) . ' $subjectProperties) ' ,
287+ [ 'pageProperties ' => $ pageProperties , 'subjectProperties ' => $ subjectProperties ]
193288 );
194289 }
195290
0 commit comments