|
| 1 | +# Copyright (c) 2024-2026 CRS4 |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +""" |
| 16 | +Integration tests for SPARQL constraint handling in SHACL validation. |
| 17 | +
|
| 18 | +These tests verify that: |
| 19 | +1. Violations from SPARQL constraints (where sourceShape is a BNode) |
| 20 | + are correctly resolved to their parent NodeShape/PropertyShape |
| 21 | +2. The import cycle between utils.py and models.py is properly resolved |
| 22 | +3. Error messages include appropriate context from the parent shape |
| 23 | +
|
| 24 | +The tests use a custom profile (sparql-test) that extends ro-crate |
| 25 | +and includes a NodeShape with a sh:sparql constraint. |
| 26 | +""" |
| 27 | + |
| 28 | +import json |
| 29 | +import logging |
| 30 | +import os |
| 31 | +import tempfile |
| 32 | +from pathlib import Path |
| 33 | + |
| 34 | +import pytest |
| 35 | +from rdflib import BNode, Graph, Namespace, URIRef |
| 36 | + |
| 37 | +from rocrate_validator import models, services |
| 38 | +from rocrate_validator.models import Severity, ValidationResult |
| 39 | +from rocrate_validator.requirements.shacl.models import Shape, ShapesRegistry |
| 40 | +from rocrate_validator.requirements.shacl.utils import resolve_parent_shape |
| 41 | +from tests.conftest import TEST_DATA_PATH |
| 42 | + |
| 43 | + |
| 44 | +logger = logging.getLogger(__name__) |
| 45 | + |
| 46 | +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) |
| 47 | + |
| 48 | +SPARQL_TEST_PROFILES_PATH = os.path.join(TEST_DATA_PATH, "profiles", "sparql_test") |
| 49 | + |
| 50 | + |
| 51 | +@pytest.fixture |
| 52 | +def sparql_test_profiles_path(): |
| 53 | + """Path to the test profile with SPARQL constraints.""" |
| 54 | + return SPARQL_TEST_PROFILES_PATH |
| 55 | + |
| 56 | + |
| 57 | +@pytest.fixture |
| 58 | +def sparql_test_rocrate(): |
| 59 | + """Create a minimal valid RO-Crate for SPARQL constraint testing.""" |
| 60 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 61 | + rocrate_dir = Path(tmp_dir) / "sparql_test_crate" |
| 62 | + rocrate_dir.mkdir() |
| 63 | + |
| 64 | + metadata = { |
| 65 | + "@context": "https://w3id.org/ro/crate/1.1/context", |
| 66 | + "@graph": [ |
| 67 | + { |
| 68 | + "@id": "ro-crate-metadata.json", |
| 69 | + "@type": "CreativeWork", |
| 70 | + "conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"}, |
| 71 | + "about": {"@id": "./"}, |
| 72 | + }, |
| 73 | + { |
| 74 | + "@id": "./", |
| 75 | + "@type": "Dataset", |
| 76 | + "name": "Test RO-Crate for SPARQL Constraints", |
| 77 | + "description": "A minimal RO-Crate to test SPARQL constraint handling", |
| 78 | + "datePublished": "2024-01-01", |
| 79 | + "license": {"@id": "http://spdx.org/licenses/CC0-1.0"}, |
| 80 | + }, |
| 81 | + ], |
| 82 | + } |
| 83 | + |
| 84 | + with open(rocrate_dir / "ro-crate-metadata.json", "w") as f: |
| 85 | + json.dump(metadata, f, indent=2) |
| 86 | + |
| 87 | + yield rocrate_dir |
| 88 | + |
| 89 | + |
| 90 | +def test_sparql_profile_shape_loaded_correctly(sparql_test_profiles_path): |
| 91 | + """Test that the sparql-test profile loads the test shape with SPARQL constraint.""" |
| 92 | + registry = ShapesRegistry() |
| 93 | + shape_file = os.path.join( |
| 94 | + sparql_test_profiles_path, "must", "agent_project_intersection.ttl" |
| 95 | + ) |
| 96 | + |
| 97 | + shapes = registry.load_shapes(shape_file) |
| 98 | + |
| 99 | + assert len(shapes) > 0, "Should load at least one shape" |
| 100 | + |
| 101 | + # Find the test shape (AlwaysFailShape or similar name) |
| 102 | + test_shape = None |
| 103 | + for shape in shapes: |
| 104 | + if ( |
| 105 | + "Always" in shape.name |
| 106 | + or "Test" in shape.name |
| 107 | + or "test" in shape.name.lower() |
| 108 | + ): |
| 109 | + test_shape = shape |
| 110 | + break |
| 111 | + |
| 112 | + assert test_shape is not None, "Should find the test SPARQL shape" |
| 113 | + assert test_shape.description is not None |
| 114 | + assert len(test_shape.description) > 0 |
| 115 | + |
| 116 | + |
| 117 | +def test_sparql_constraint_with_bnode_sourceShape( |
| 118 | + sparql_test_profiles_path, sparql_test_rocrate |
| 119 | +): |
| 120 | + """ |
| 121 | + Test that SPARQL constraint violations with BNode sourceShape |
| 122 | + are handled gracefully by the validation pipeline. |
| 123 | +
|
| 124 | + Uses the sparql-test profile which includes AgentProjectIntersection |
| 125 | + shape with a sh:sparql constraint targeting ROCrateMetadataFileDescriptor. |
| 126 | + """ |
| 127 | + result = services.validate( |
| 128 | + models.ValidationSettings( |
| 129 | + rocrate_uri=sparql_test_rocrate, |
| 130 | + requirement_severity=Severity.REQUIRED, |
| 131 | + profile_identifier="sparql-test", |
| 132 | + profiles_path=sparql_test_profiles_path, |
| 133 | + ) |
| 134 | + ) |
| 135 | + |
| 136 | + assert result is not None |
| 137 | + assert isinstance(result, ValidationResult) |
| 138 | + assert result.context is not None |
| 139 | + # The SPARQL constraint uses FILTER(true) so it should produce a violation |
| 140 | + issues = list(result.get_issues(Severity.REQUIRED)) |
| 141 | + assert len(issues) > 0, "Expected issues from SPARQL constraint violation" |
| 142 | + assert issues[0].check is not None, "Issue should have an associated check" |
| 143 | + assert issues[0].check.description is not None, "Check should have a description" |
| 144 | + assert issues[0].message is not None, "Issue should have a message" |
| 145 | + assert len(issues[0].message) > 0, "Issue message should not be empty" |
| 146 | + assert ( |
| 147 | + "SPARQL constraint violation" in issues[0].message |
| 148 | + or "SPARQL" in issues[0].check.description |
| 149 | + ), "Check description should reference parent shape" |
| 150 | + |
| 151 | + |
| 152 | +def test_resolve_parent_shape_with_sparql_bnode(): |
| 153 | + """ |
| 154 | + Test resolve_parent_shape with a BNode representing a sh:sparql constraint. |
| 155 | +
|
| 156 | + This simulates the scenario where pyshacl reports a violation with |
| 157 | + sourceShape being a BNode (the sh:SPARQLConstraint). |
| 158 | + """ |
| 159 | + SHACL = Namespace("http://www.w3.org/ns/shacl#") |
| 160 | + |
| 161 | + registry = ShapesRegistry() |
| 162 | + profiles_path = "rocrate_validator/profiles/ro-crate/must" |
| 163 | + |
| 164 | + # Load shapes from profile |
| 165 | + for filename in os.listdir(profiles_path): |
| 166 | + if filename.endswith(".ttl"): |
| 167 | + registry.load_shapes(os.path.join(profiles_path, filename)) |
| 168 | + |
| 169 | + g = Graph() |
| 170 | + |
| 171 | + # Simulate a NodeShape with a SPARQL constraint (BNode) |
| 172 | + node_shape_uri = URIRef("http://example.org/TestShape") |
| 173 | + sparql_bnode = BNode() |
| 174 | + |
| 175 | + g.add((node_shape_uri, SHACL.sparql, sparql_bnode)) |
| 176 | + g.add((node_shape_uri, SHACL.targetClass, URIRef("http://example.org/TestClass"))) |
| 177 | + |
| 178 | + # Create and register the shape |
| 179 | + shape = Shape(node_shape_uri, g) |
| 180 | + shape._name = "TestShape" |
| 181 | + shape._description = "Test shape for SPARQL constraint" |
| 182 | + registry.add_shape(shape) |
| 183 | + |
| 184 | + # Resolve the parent shape from the BNode |
| 185 | + result = resolve_parent_shape(g, sparql_bnode, registry) |
| 186 | + |
| 187 | + assert result is not None, "Should resolve parent shape for SPARQL BNode" |
| 188 | + assert result.key == shape.key, "Resolved shape key should match the original shape" |
| 189 | + assert result.name == "TestShape", "Resolved shape should have correct name" |
0 commit comments