diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..527503191 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + # Same entrypoint as local development — keeps CI and `make test` in sync. + - name: Run tests + run: make test + env: + # flowsint_core hard-requires these at import time (provided by .env + # locally). Dummy values — no service is reached during tests: + # connections (redis.from_url, create_engine) are lazy. + AUTH_SECRET: ci-only-dummy-secret + REDIS_URL: redis://127.0.0.1:6379/0 diff --git a/Makefile b/Makefile index 9f96966cf..770d5d8d3 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ test: cd flowsint-types && uv run pytest cd flowsint-core && uv run pytest cd flowsint-enrichers && uv run pytest + cd flowsint-api && uv run pytest install: $(MAKE) infra-dev diff --git a/flowsint-core/pyproject.toml b/flowsint-core/pyproject.toml index ff38f61e2..f4c5ac28f 100644 --- a/flowsint-core/pyproject.toml +++ b/flowsint-core/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "redis>=5.0,<6.0", "celery>=5.3,<6.0", "python-dotenv>=1.0,<2.0", + "pyyaml>=6.0,<7.0", "requests>=2.31,<3.0", "httpx>=0.28,<0.29", "networkx>=2.6.3,<3.0.0", diff --git a/flowsint-core/tests/conftest.py b/flowsint-core/tests/conftest.py index bc39b68a3..5cd0cfc6b 100644 --- a/flowsint-core/tests/conftest.py +++ b/flowsint-core/tests/conftest.py @@ -17,6 +17,12 @@ def setup_test_environment(monkeypatch): # Set a test master key for vault tests test_key = "base64:qnHTmwYb+uoygIw9MsRMY22vS5YPchY+QOi/E79GAvM=" monkeypatch.setenv("MASTER_VAULT_KEY_V1", test_key) + # Dummy Neo4j credentials: the Neo4jConnection singleton requires them + # at construction, but driver creation is lazy — nothing connects. + # Without these, tests silently depend on the developer's local .env. + monkeypatch.setenv("NEO4J_URI_BOLT", "bolt://127.0.0.1:7687") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "test-password") @pytest.fixture(autouse=True) diff --git a/flowsint-core/tests/core/graph/test_graph_repository.py b/flowsint-core/tests/core/graph/test_graph_repository.py index 003b553b5..9ca97a030 100644 --- a/flowsint-core/tests/core/graph/test_graph_repository.py +++ b/flowsint-core/tests/core/graph/test_graph_repository.py @@ -7,6 +7,17 @@ from flowsint_core.core.graph import Neo4jGraphRepository +def repo_without_connection() -> Neo4jGraphRepository: + """Repository with no underlying connection. + + Constructed with a mock to avoid the constructor's singleton fallback, + which requires NEO4J_* credentials from the environment. + """ + repo = Neo4jGraphRepository(neo4j_connection=MagicMock()) + repo._connection = None + return repo + + class TestNeo4jGraphRepositoryInit: def test_init_with_connection(self): mock_connection = MagicMock() @@ -43,8 +54,7 @@ def test_create_node_success(self): mock_connection.query.assert_called_once() def test_create_node_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.create_node({"nodeLabel": "test", "nodeType": "domain"}, "sketch-1") @@ -80,8 +90,7 @@ def test_create_relationship_success(self): mock_connection.execute_write.assert_called_once() def test_create_relationship_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() rel_obj = { "from_type": "domain", @@ -231,8 +240,7 @@ def test_flush_batch_empty(self): mock_connection.execute_batch.assert_not_called() def test_flush_batch_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() repo._batch_operations = [("query", {})] repo.flush_batch() @@ -285,8 +293,7 @@ def test_batch_create_nodes_success(self): assert result["errors"] == [] def test_batch_create_nodes_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.batch_create_nodes( [{"nodeLabel": "test", "nodeType": "domain"}], sketch_id="sketch-1" @@ -338,8 +345,7 @@ def test_batch_create_edges_success(self): assert result["errors"] == [] def test_batch_create_edges_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.batch_create_edges([{}], sketch_id="sketch-1") @@ -385,8 +391,7 @@ def test_batch_create_edges_by_element_id_missing_fields(self): assert any("Missing required fields" in e for e in result["errors"]) def test_batch_create_edges_by_element_id_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.batch_create_edges_by_element_id([{}], sketch_id="sketch-1") @@ -409,8 +414,7 @@ def test_update_node_success(self): assert result == "elem-1" def test_update_node_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.update_node("elem-1", {"nodeLabel": "x"}, "sketch-1") @@ -428,8 +432,7 @@ def test_delete_nodes_success(self): assert result == 3 def test_delete_nodes_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.delete_nodes(["id-1"], sketch_id="sketch-1") @@ -456,8 +459,7 @@ def test_delete_relationships_success(self): assert result == 2 def test_delete_relationships_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.delete_relationships(["rel-1"], sketch_id="sketch-1") @@ -483,8 +485,7 @@ def test_delete_all_sketch_nodes_success(self): assert result == 10 def test_delete_all_sketch_nodes_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.delete_all_sketch_nodes(sketch_id="sketch-1") @@ -519,8 +520,7 @@ def test_get_sketch_graph_success(self): assert len(result["edges"]) == 1 def test_get_sketch_graph_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.get_sketch_graph(sketch_id="sketch-1") @@ -553,8 +553,7 @@ def test_update_relationship_success(self): assert result["id"] == "rel-1" def test_update_relationship_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.update_relationship("rel-1", {}, "sketch-1") @@ -577,8 +576,7 @@ def test_create_relationship_by_element_id_success(self): assert result["sketch_id"] == "sketch-1" def test_create_relationship_by_element_id_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.create_relationship_by_element_id( "elem-1", "elem-2", "CONNECTS", "sketch-1" @@ -598,8 +596,7 @@ def test_query_success(self): assert result == [{"count": 5}] def test_query_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.query("MATCH (n) RETURN n", {}) @@ -622,8 +619,7 @@ def test_update_nodes_positions_success(self): assert result == 2 def test_update_nodes_positions_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.update_nodes_positions([{"nodeId": "x", "x": 0, "y": 0}], "s") @@ -652,8 +648,7 @@ def test_get_nodes_by_ids_success(self): assert len(result) == 2 def test_get_nodes_by_ids_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.get_nodes_by_ids(["id-1"], sketch_id="sketch-1") @@ -706,8 +701,7 @@ def test_merge_nodes_reuse_existing_node(self): assert result == "old-1" def test_merge_nodes_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.merge_nodes(["old-1"], {}, None, "sketch-1") @@ -764,8 +758,7 @@ def test_get_neighbors_no_relationships(self): assert len(result["edges"]) == 0 def test_get_neighbors_no_connection(self): - repo = Neo4jGraphRepository(neo4j_connection=None) - repo._connection = None + repo = repo_without_connection() result = repo.get_neighbors("node-1", "sketch-1") diff --git a/flowsint-enrichers/tests/conftest.py b/flowsint-enrichers/tests/conftest.py index be95bac0a..161ca750d 100644 --- a/flowsint-enrichers/tests/conftest.py +++ b/flowsint-enrichers/tests/conftest.py @@ -2,6 +2,19 @@ from tests.logger import TestLogger +@pytest.fixture(autouse=True) +def setup_test_environment(monkeypatch): + """Set up test environment variables. + + Dummy Neo4j credentials: the Neo4jConnection singleton requires them + at construction, but driver creation is lazy — nothing connects. + Without these, tests silently depend on the developer's local .env. + """ + monkeypatch.setenv("NEO4J_URI_BOLT", "bolt://127.0.0.1:7687") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "test-password") + + @pytest.fixture(autouse=True) def mock_logger(monkeypatch): """Automatically replace the production Logger with TestLogger for all tests.""" diff --git a/uv.lock b/uv.lock index d0d46c085..2651c287a 100644 --- a/uv.lock +++ b/uv.lock @@ -1205,6 +1205,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, + { name = "pyyaml" }, { name = "redis" }, { name = "requests" }, { name = "sqlalchemy" }, @@ -1243,6 +1244,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0,<2.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3,<4.0" }, { name = "python-multipart", specifier = ">=0.0.27,<0.1.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, { name = "redis", specifier = ">=5.0,<6.0" }, { name = "requests", specifier = ">=2.31,<3.0" }, { name = "sqlalchemy", specifier = ">=2.0,<3.0" },