Coverage for tests / test_integration.py: 100%

142 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-03-21 11:59 +0000

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5"""Integration tests for sparqlite against real SPARQL endpoints. 

6 

7Uses OpenCitations data model for realistic test data. 

8All tests verify complete expected results, not just individual elements. 

9Tests run against both Virtuoso and QLever backends via parametrized fixtures. 

10""" 

11 

12import re 

13import warnings 

14 

15import pytest 

16 

17from sparqlite import EndpointError, QueryError, SPARQLClient 

18 

19 

20TEST_GRAPH = "https://w3id.org/oc/meta/test" 

21 

22PREFIXES = """ 

23PREFIX fabio: <http://purl.org/spar/fabio/> 

24PREFIX dcterms: <http://purl.org/dc/terms/> 

25PREFIX prism: <http://prismstandard.org/namespaces/basic/2.0/> 

26PREFIX datacite: <http://purl.org/spar/datacite/> 

27PREFIX literal: <http://www.essepuntato.it/2010/06/literalreification/> 

28PREFIX pro: <http://purl.org/spar/pro/> 

29PREFIX foaf: <http://xmlns.com/foaf/0.1/> 

30""" 

31 

32NT_PATTERN = re.compile(r"(<[^>]+>)\s+(<[^>]+>)\s+(.+?)\s*\.\s*$") 

33 

34 

35def parse_ntriples(data: bytes) -> set[tuple[str, str, str]]: 

36 triples = set() 

37 for line in data.decode().strip().split("\n"): 

38 line = line.strip() 

39 if not line or line.startswith("#"): 

40 continue 

41 match = NT_PATTERN.match(line) 

42 if match: 

43 triples.add((match.group(1), match.group(2), match.group(3))) 

44 return triples 

45 

46 

47class TestSelectQuery: 

48 """Tests for SELECT queries using OpenCitations data model.""" 

49 

50 def test_select_articles_with_titles(self, client, test_data): 

51 """Test SELECT query for journal articles with titles.""" 

52 result = client.query(f""" 

53 {PREFIXES} 

54 SELECT ?article ?title 

55 FROM <{TEST_GRAPH}> 

56 WHERE {{ 

57 ?article a fabio:JournalArticle ; 

58 dcterms:title ?title . 

59 }} 

60 ORDER BY ?title 

61 """) 

62 

63 expected = [ 

64 { 

65 "article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/1"}, 

66 "title": {"type": "literal", "value": "A study on citation networks"}, 

67 }, 

68 { 

69 "article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/2"}, 

70 "title": {"type": "literal", "value": "Machine learning in bibliometrics"}, 

71 }, 

72 ] 

73 

74 assert isinstance(result, dict) 

75 assert result["results"]["bindings"] == expected 

76 

77 def test_select_articles_with_dois(self, client, test_data): 

78 """Test SELECT query for articles with DOI identifiers.""" 

79 result = client.query(f""" 

80 {PREFIXES} 

81 SELECT ?article ?title ?doi 

82 FROM <{TEST_GRAPH}> 

83 WHERE {{ 

84 ?article a fabio:JournalArticle ; 

85 dcterms:title ?title ; 

86 datacite:hasIdentifier ?id . 

87 ?id datacite:usesIdentifierScheme datacite:doi ; 

88 literal:hasLiteralValue ?doi . 

89 }} 

90 ORDER BY ?doi 

91 """) 

92 

93 expected = [ 

94 { 

95 "article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/1"}, 

96 "title": {"type": "literal", "value": "A study on citation networks"}, 

97 "doi": {"type": "literal", "value": "10.1000/test.001"}, 

98 }, 

99 { 

100 "article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/2"}, 

101 "title": {"type": "literal", "value": "Machine learning in bibliometrics"}, 

102 "doi": {"type": "literal", "value": "10.1000/test.002"}, 

103 }, 

104 ] 

105 

106 assert result["results"]["bindings"] == expected 

107 

108 def test_select_authors_and_articles(self, client, test_data): 

109 """Test SELECT query for authors and their articles.""" 

110 result = client.query(f""" 

111 {PREFIXES} 

112 SELECT ?name ?title 

113 FROM <{TEST_GRAPH}> 

114 WHERE {{ 

115 ?article a fabio:JournalArticle ; 

116 dcterms:title ?title ; 

117 pro:isDocumentContextFor ?role . 

118 ?role pro:withRole pro:author ; 

119 pro:isHeldBy ?author . 

120 ?author foaf:name ?name . 

121 }} 

122 ORDER BY ?name 

123 """) 

124 

125 expected = [ 

126 { 

127 "name": {"type": "literal", "value": "Jane Doe"}, 

128 "title": {"type": "literal", "value": "Machine learning in bibliometrics"}, 

129 }, 

130 { 

131 "name": {"type": "literal", "value": "John Smith"}, 

132 "title": {"type": "literal", "value": "A study on citation networks"}, 

133 }, 

134 ] 

135 

136 assert result["results"]["bindings"] == expected 

137 

138 def test_select_alias(self, client, test_data): 

139 """Test select() method alias.""" 

140 result = client.select(f""" 

141 {PREFIXES} 

142 SELECT ?article 

143 FROM <{TEST_GRAPH}> 

144 WHERE {{ ?article a fabio:JournalArticle }} 

145 ORDER BY ?article 

146 """) 

147 

148 expected = [ 

149 {"article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/1"}}, 

150 {"article": {"type": "uri", "value": "https://w3id.org/oc/meta/br/2"}}, 

151 ] 

152 

153 assert isinstance(result, dict) 

154 assert result["results"]["bindings"] == expected 

155 

156 def test_empty_result(self, client, test_data): 

157 """Test empty result set.""" 

158 result = client.query(f""" 

159 {PREFIXES} 

160 SELECT ?article 

161 FROM <{TEST_GRAPH}> 

162 WHERE {{ 

163 ?article dcterms:title "Nonexistent Title" . 

164 }} 

165 """) 

166 

167 assert result["results"]["bindings"] == [] 

168 

169 

170class TestAskQuery: 

171 """Tests for ASK queries using OpenCitations data model.""" 

172 

173 def test_ask_doi_exists(self, client, test_data): 

174 """Test ASK query to check if a DOI exists.""" 

175 result = client.ask(f""" 

176 {PREFIXES} 

177 ASK {{ 

178 GRAPH <{TEST_GRAPH}> {{ 

179 ?id datacite:usesIdentifierScheme datacite:doi ; 

180 literal:hasLiteralValue "10.1000/test.001" . 

181 }} 

182 }} 

183 """) 

184 

185 assert isinstance(result, bool) 

186 assert result is True 

187 

188 def test_ask_doi_not_exists(self, client, test_data): 

189 """Test ASK query for non-existent DOI.""" 

190 result = client.ask(f""" 

191 {PREFIXES} 

192 ASK {{ 

193 GRAPH <{TEST_GRAPH}> {{ 

194 ?id literal:hasLiteralValue "10.9999/nonexistent" . 

195 }} 

196 }} 

197 """) 

198 

199 assert isinstance(result, bool) 

200 assert result is False 

201 

202 def test_ask_author_exists(self, client, test_data): 

203 """Test ASK query to check if an author exists.""" 

204 result = client.ask(f""" 

205 {PREFIXES} 

206 ASK {{ 

207 GRAPH <{TEST_GRAPH}> {{ 

208 ?author foaf:name "John Smith" . 

209 }} 

210 }} 

211 """) 

212 

213 assert result is True 

214 

215 def test_ask_book_exists(self, client, test_data): 

216 """Test ASK query to check if a book exists.""" 

217 result = client.ask(f""" 

218 {PREFIXES} 

219 ASK {{ 

220 GRAPH <{TEST_GRAPH}> {{ 

221 ?article a fabio:Book . 

222 }} 

223 }} 

224 """) 

225 

226 assert isinstance(result, bool) 

227 assert result is True 

228 

229 

230class TestConstructQuery: 

231 """Tests for CONSTRUCT queries using OpenCitations data model.""" 

232 

233 def test_construct_author_article_relationships(self, client, test_data): 

234 """Test CONSTRUCT query for author-article relationships.""" 

235 result = client.construct(f""" 

236 {PREFIXES} 

237 CONSTRUCT {{ 

238 ?author foaf:name ?name ; 

239 foaf:made ?article . 

240 ?article dcterms:title ?title . 

241 }} 

242 FROM <{TEST_GRAPH}> 

243 WHERE {{ 

244 ?article a fabio:JournalArticle ; 

245 dcterms:title ?title ; 

246 pro:isDocumentContextFor ?role . 

247 ?role pro:withRole pro:author ; 

248 pro:isHeldBy ?author . 

249 ?author foaf:name ?name . 

250 }} 

251 """) 

252 

253 assert isinstance(result, bytes) 

254 triples = parse_ntriples(result) 

255 assert len(triples) == 6 

256 

257 expected = { 

258 ('<https://w3id.org/oc/meta/ra/1>', '<http://xmlns.com/foaf/0.1/name>', '"John Smith"'), 

259 ('<https://w3id.org/oc/meta/ra/1>', '<http://xmlns.com/foaf/0.1/made>', '<https://w3id.org/oc/meta/br/1>'), 

260 ('<https://w3id.org/oc/meta/br/1>', '<http://purl.org/dc/terms/title>', '"A study on citation networks"'), 

261 ('<https://w3id.org/oc/meta/ra/2>', '<http://xmlns.com/foaf/0.1/name>', '"Jane Doe"'), 

262 ('<https://w3id.org/oc/meta/ra/2>', '<http://xmlns.com/foaf/0.1/made>', '<https://w3id.org/oc/meta/br/2>'), 

263 ('<https://w3id.org/oc/meta/br/2>', '<http://purl.org/dc/terms/title>', '"Machine learning in bibliometrics"'), 

264 } 

265 assert triples == expected 

266 

267 def test_construct_article_metadata(self, client, test_data): 

268 """Test CONSTRUCT query for article metadata graph.""" 

269 result = client.construct(f""" 

270 {PREFIXES} 

271 CONSTRUCT {{ 

272 ?article a fabio:JournalArticle ; 

273 dcterms:title ?title ; 

274 prism:publicationDate ?date . 

275 }} 

276 FROM <{TEST_GRAPH}> 

277 WHERE {{ 

278 ?article a fabio:JournalArticle ; 

279 dcterms:title ?title ; 

280 prism:publicationDate ?date . 

281 }} 

282 """) 

283 

284 assert isinstance(result, bytes) 

285 triples = parse_ntriples(result) 

286 assert len(triples) == 6 

287 

288 expected = { 

289 ('<https://w3id.org/oc/meta/br/1>', '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>', '<http://purl.org/spar/fabio/JournalArticle>'), 

290 ('<https://w3id.org/oc/meta/br/1>', '<http://purl.org/dc/terms/title>', '"A study on citation networks"'), 

291 ('<https://w3id.org/oc/meta/br/1>', '<http://prismstandard.org/namespaces/basic/2.0/publicationDate>', '"2024-01-15"'), 

292 ('<https://w3id.org/oc/meta/br/2>', '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>', '<http://purl.org/spar/fabio/JournalArticle>'), 

293 ('<https://w3id.org/oc/meta/br/2>', '<http://purl.org/dc/terms/title>', '"Machine learning in bibliometrics"'), 

294 ('<https://w3id.org/oc/meta/br/2>', '<http://prismstandard.org/namespaces/basic/2.0/publicationDate>', '"2024-03-20"'), 

295 } 

296 assert triples == expected 

297 

298 def test_construct_book(self, client, test_data): 

299 """Test CONSTRUCT query for books.""" 

300 result = client.construct(f""" 

301 {PREFIXES} 

302 CONSTRUCT {{ ?s ?p ?o }} 

303 FROM <{TEST_GRAPH}> 

304 WHERE {{ 

305 ?s a fabio:Book ; 

306 ?p ?o . 

307 }} 

308 """) 

309 

310 assert isinstance(result, bytes) 

311 triples = parse_ntriples(result) 

312 assert len(triples) == 3 

313 

314 expected = { 

315 ('<https://w3id.org/oc/meta/br/3>', '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>', '<http://purl.org/spar/fabio/Book>'), 

316 ('<https://w3id.org/oc/meta/br/3>', '<http://purl.org/dc/terms/title>', '"Introduction to Semantic Web"'), 

317 ('<https://w3id.org/oc/meta/br/3>', '<http://prismstandard.org/namespaces/basic/2.0/publicationDate>', '"2023-06-01"'), 

318 } 

319 assert triples == expected 

320 

321 def test_construct_empty(self, client, test_data): 

322 """Test CONSTRUCT with no results.""" 

323 result = client.construct(f""" 

324 {PREFIXES} 

325 CONSTRUCT {{ ?s ?p ?o }} 

326 FROM <{TEST_GRAPH}> 

327 WHERE {{ 

328 ?s a fabio:Thesis . 

329 }} 

330 """) 

331 

332 assert isinstance(result, bytes) 

333 triples = parse_ntriples(result) 

334 assert len(triples) == 0 

335 

336 

337class TestDescribeQuery: 

338 """Tests for DESCRIBE queries using OpenCitations data model.""" 

339 

340 def test_describe_bibliographic_resource(self, client, test_data): 

341 """Test DESCRIBE query for a bibliographic resource.""" 

342 result = client.describe(f""" 

343 {PREFIXES} 

344 DESCRIBE <https://w3id.org/oc/meta/br/1> 

345 FROM <{TEST_GRAPH}> 

346 """) 

347 

348 assert isinstance(result, bytes) 

349 triples = parse_ntriples(result) 

350 assert len(triples) == 5 

351 

352 expected = { 

353 ('<https://w3id.org/oc/meta/br/1>', '<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>', '<http://purl.org/spar/fabio/JournalArticle>'), 

354 ('<https://w3id.org/oc/meta/br/1>', '<http://purl.org/dc/terms/title>', '"A study on citation networks"'), 

355 ('<https://w3id.org/oc/meta/br/1>', '<http://prismstandard.org/namespaces/basic/2.0/publicationDate>', '"2024-01-15"'), 

356 ('<https://w3id.org/oc/meta/br/1>', '<http://purl.org/spar/datacite/hasIdentifier>', '<https://w3id.org/oc/meta/id/1>'), 

357 ('<https://w3id.org/oc/meta/br/1>', '<http://purl.org/spar/pro/isDocumentContextFor>', '<https://w3id.org/oc/meta/ar/1>'), 

358 } 

359 assert triples == expected 

360 

361 

362class TestUpdateQuery: 

363 """Tests for UPDATE queries using OpenCitations data model.""" 

364 

365 def test_update_insert_new_article(self, client): 

366 """Test INSERT DATA for a new article.""" 

367 new_graph = "https://w3id.org/oc/meta/test/insert" 

368 client.update(f""" 

369 {PREFIXES} 

370 INSERT DATA {{ 

371 GRAPH <{new_graph}> {{ 

372 <https://w3id.org/oc/meta/br/99> a fabio:JournalArticle ; 

373 dcterms:title "Newly inserted article" ; 

374 prism:publicationDate "2024-06-01" . 

375 }} 

376 }} 

377 """) 

378 

379 result = client.ask(f""" 

380 {PREFIXES} 

381 ASK {{ 

382 GRAPH <{new_graph}> {{ 

383 ?article dcterms:title "Newly inserted article" . 

384 }} 

385 }} 

386 """) 

387 assert bool(result) is True 

388 

389 client.update(f"CLEAR GRAPH <{new_graph}>") 

390 

391 def test_update_delete_article(self, client): 

392 """Test DELETE DATA for an article.""" 

393 temp_graph = "https://w3id.org/oc/meta/test/delete" 

394 client.update(f""" 

395 {PREFIXES} 

396 INSERT DATA {{ 

397 GRAPH <{temp_graph}> {{ 

398 <https://w3id.org/oc/meta/br/100> a fabio:JournalArticle ; 

399 dcterms:title "Article to delete" . 

400 }} 

401 }} 

402 """) 

403 

404 client.update(f""" 

405 {PREFIXES} 

406 DELETE DATA {{ 

407 GRAPH <{temp_graph}> {{ 

408 <https://w3id.org/oc/meta/br/100> a fabio:JournalArticle ; 

409 dcterms:title "Article to delete" . 

410 }} 

411 }} 

412 """) 

413 

414 result = client.ask(f""" 

415 {PREFIXES} 

416 ASK {{ 

417 GRAPH <{temp_graph}> {{ 

418 ?article dcterms:title "Article to delete" . 

419 }} 

420 }} 

421 """) 

422 assert result is False 

423 

424 

425class TestClientLifecycle: 

426 """Tests for client lifecycle management.""" 

427 

428 def test_context_manager(self, endpoint): 

429 """Test context manager protocol.""" 

430 with SPARQLClient(endpoint) as client: 

431 result = client.ask("ASK { ?s ?p ?o }") 

432 assert isinstance(result, bool) 

433 

434 def test_close_idempotent(self, endpoint): 

435 """Test that close() can be called multiple times.""" 

436 client = SPARQLClient(endpoint) 

437 client.close() 

438 client.close() 

439 client.close() 

440 

441 def test_connection_reuse(self, client, test_data): 

442 """Test that curl handle is reused across requests.""" 

443 curl_id = id(client._curl) 

444 

445 client.query(f""" 

446 {PREFIXES} 

447 SELECT * FROM <{TEST_GRAPH}> WHERE {{ ?s a fabio:JournalArticle }} 

448 """) 

449 assert id(client._curl) == curl_id 

450 

451 client.ask(f""" 

452 {PREFIXES} 

453 ASK {{ GRAPH <{TEST_GRAPH}> {{ ?s a fabio:Book }} }} 

454 """) 

455 assert id(client._curl) == curl_id 

456 

457 def test_resource_warning(self, endpoint): 

458 """Test that ResourceWarning is raised on unclosed client.""" 

459 client = SPARQLClient(endpoint) 

460 

461 with warnings.catch_warnings(record=True) as w: 

462 warnings.simplefilter("always") 

463 del client 

464 

465 assert len(w) == 1 

466 assert issubclass(w[0].category, ResourceWarning) 

467 assert "SPARQLClient was not closed" in str(w[0].message) 

468 

469 

470class TestErrorHandling: 

471 """Tests for error handling.""" 

472 

473 def test_query_error_syntax(self, client): 

474 """Test QueryError on syntax error.""" 

475 with pytest.raises(QueryError): 

476 client.query("SELECT * WHERE { INVALID SYNTAX }") 

477 

478 def test_endpoint_error_invalid_url(self): 

479 """Test EndpointError on invalid endpoint.""" 

480 client = SPARQLClient("http://localhost:59999/nonexistent") 

481 

482 with pytest.raises(EndpointError): 

483 client.ask("ASK { ?s ?p ?o }") 

484 

485 client.close() 

486 

487 

488class TestRetryLogic: 

489 """Tests for retry logic.""" 

490 

491 def test_max_retries_reached(self): 

492 """Test that max retries are respected.""" 

493 client = SPARQLClient( 

494 "http://localhost:59999/nonexistent", 

495 max_retries=0, 

496 ) 

497 

498 with pytest.raises(EndpointError): 

499 client.ask("ASK { ?s ?p ?o }") 

500 

501 client.close()