"""Tests for the pipelines API endpoints. Covers CRUD operations for pipelines and steps, ownership/admin access control, step reordering, and the assign-pipeline-to-file endpoint. """ import json from unittest.mock import patch import pytest from app.models import FileRecord, Pipeline, PipelineStep # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_test_file_record(db_session, owner_id=None): """Insert a minimal FileRecord and return it. Default owner_id=None so tests work without an authenticated session. """ fr = FileRecord( owner_id=owner_id, filehash="abc123", original_filename="test.pdf", local_filename="/tmp/test.pdf", file_size=1024, mime_type="application/pdf", ) db_session.add(fr) db_session.commit() db_session.refresh(fr) return fr # --------------------------------------------------------------------------- # Unit tests – step-types catalogue # --------------------------------------------------------------------------- @pytest.mark.unit class TestStepTypesCatalogue: """Tests for the step-types read-only catalogue endpoint.""" def test_step_types_returns_dict(self, client): """GET /api/pipelines/step-types returns a dict of known types.""" r = client.get("/api/pipelines/step-types") assert r.status_code == 200 data = r.json() assert isinstance(data, dict) # Core built-in types must be present for key in ("convert_to_pdf", "ocr", "extract_metadata", "embed_metadata", "compute_embedding"): assert key in data, f"Expected step type '{key}' in catalogue" def test_each_type_has_label_and_description(self, client): """Each step-type entry has at least a label and description.""" r = client.get("/api/pipelines/step-types") for key, meta in r.json().items(): assert "label" in meta, f"Step type '{key}' missing 'label'" assert "description" in meta, f"Step type '{key}' missing 'description'" # --------------------------------------------------------------------------- # Integration tests – Pipeline CRUD # --------------------------------------------------------------------------- @pytest.mark.integration class TestPipelineCRUD: """Full CRUD test-suite for pipeline management.""" def test_list_pipelines_empty(self, client): """List returns an empty array when no pipelines exist.""" r = client.get("/api/pipelines") assert r.status_code == 200 assert r.json() == [] def test_create_pipeline(self, client): """POST /api/pipelines creates a new pipeline.""" r = client.post( "/api/pipelines", json={"name": "My Pipeline", "description": "Test description"}, ) assert r.status_code == 201 data = r.json() assert data["name"] == "My Pipeline" assert data["description"] == "Test description" assert data["is_default"] is False assert data["is_active"] is True assert data["id"] is not None def test_create_pipeline_duplicate_name_same_owner_rejected(self, client): """Creating two pipelines with the same name is rejected with 409.""" client.post("/api/pipelines", json={"name": "Dupe"}) r = client.post("/api/pipelines", json={"name": "Dupe"}) assert r.status_code == 409 def test_create_pipeline_empty_name_rejected(self, client): """An empty pipeline name returns 422.""" r = client.post("/api/pipelines", json={"name": " "}) assert r.status_code == 422 def test_get_pipeline_includes_steps(self, client): """GET /api/pipelines/{id} returns the pipeline with a steps array.""" created = client.post("/api/pipelines", json={"name": "With Steps"}).json() r = client.get(f"/api/pipelines/{created['id']}") assert r.status_code == 200 assert "steps" in r.json() assert r.json()["steps"] == [] def test_get_pipeline_not_found(self, client): """GET on a non-existent pipeline returns 404.""" r = client.get("/api/pipelines/99999") assert r.status_code == 404 def test_update_pipeline(self, client): """PUT /api/pipelines/{id} updates name and description.""" created = client.post("/api/pipelines", json={"name": "Original"}).json() r = client.put( f"/api/pipelines/{created['id']}", json={"name": "Renamed", "description": "New desc"}, ) assert r.status_code == 200 assert r.json()["name"] == "Renamed" assert r.json()["description"] == "New desc" def test_update_pipeline_empty_name_rejected(self, client): """Updating a pipeline with an empty name returns 422.""" created = client.post("/api/pipelines", json={"name": "Good"}).json() r = client.put(f"/api/pipelines/{created['id']}", json={"name": ""}) assert r.status_code == 422 def test_update_pipeline_name_conflict_rejected(self, client): """Updating a pipeline's name to one already taken returns 409.""" client.post("/api/pipelines", json={"name": "Taken"}) second = client.post("/api/pipelines", json={"name": "Other"}).json() r = client.put(f"/api/pipelines/{second['id']}", json={"name": "Taken"}) assert r.status_code == 409 def test_delete_pipeline(self, client): """DELETE /api/pipelines/{id} removes the pipeline.""" created = client.post("/api/pipelines", json={"name": "Deletable"}).json() r = client.delete(f"/api/pipelines/{created['id']}") assert r.status_code == 204 assert client.get(f"/api/pipelines/{created['id']}").status_code == 404 def test_delete_pipeline_not_found(self, client): """Deleting a non-existent pipeline returns 404.""" r = client.delete("/api/pipelines/99999") assert r.status_code == 404 def test_is_default_flag(self, client): """Setting is_default=True marks the pipeline as default.""" r = client.post("/api/pipelines", json={"name": "Default Pipeline", "is_default": True}) assert r.status_code == 201 assert r.json()["is_default"] is True def test_only_one_default_per_owner(self, client): """When a new default is set, the old one is cleared.""" first = client.post("/api/pipelines", json={"name": "First Default", "is_default": True}).json() second = client.post("/api/pipelines", json={"name": "Second Default", "is_default": True}).json() assert second["is_default"] is True # The first should no longer be default first_updated = client.get(f"/api/pipelines/{first['id']}").json() assert first_updated["is_default"] is False def test_list_returns_created_pipeline(self, client): """After creating a pipeline it appears in the list.""" client.post("/api/pipelines", json={"name": "Visible"}) r = client.get("/api/pipelines") names = [p["name"] for p in r.json()] assert "Visible" in names # --------------------------------------------------------------------------- # Integration tests – Step management # --------------------------------------------------------------------------- @pytest.mark.integration class TestPipelineStepManagement: """Tests for adding, updating, deleting, and reordering pipeline steps.""" def _create_pipeline(self, client, name="Test Pipeline"): return client.post("/api/pipelines", json={"name": name}).json() def test_add_step(self, client): """POST /api/pipelines/{id}/steps adds a step.""" p = self._create_pipeline(client) r = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}) assert r.status_code == 201 step = r.json() assert step["step_type"] == "ocr" assert step["position"] == 0 assert step["enabled"] is True def test_add_step_with_config(self, client): """A step can be added with a custom config dict.""" p = self._create_pipeline(client) r = client.post( f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr", "config": {"force_cloud_ocr": True}}, ) assert r.status_code == 201 assert r.json()["config"]["force_cloud_ocr"] is True def test_add_step_with_custom_label(self, client): """A step can override the default label.""" p = self._create_pipeline(client) r = client.post( f"/api/pipelines/{p['id']}/steps", json={"step_type": "convert_to_pdf", "label": "My Converter"}, ) assert r.status_code == 201 assert r.json()["label"] == "My Converter" def test_add_invalid_step_type_rejected(self, client): """An unrecognised step type returns 422.""" p = self._create_pipeline(client) r = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "nonexistent_step"}) assert r.status_code == 422 def test_steps_appended_in_order(self, client): """Multiple steps are appended in position order.""" p = self._create_pipeline(client) client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "convert_to_pdf"}) client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}) client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "extract_metadata"}) details = client.get(f"/api/pipelines/{p['id']}").json() types = [s["step_type"] for s in details["steps"]] assert types == ["convert_to_pdf", "ocr", "extract_metadata"] def test_update_step(self, client): """PUT /api/pipelines/{id}/steps/{step_id} updates enabled flag.""" p = self._create_pipeline(client) step = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}).json() r = client.put( f"/api/pipelines/{p['id']}/steps/{step['id']}", json={"enabled": False}, ) assert r.status_code == 200 assert r.json()["enabled"] is False def test_update_step_not_found(self, client): """Updating a step on the wrong pipeline returns 404.""" p = self._create_pipeline(client) r = client.put(f"/api/pipelines/{p['id']}/steps/99999", json={"enabled": False}) assert r.status_code == 404 def test_delete_step(self, client): """DELETE /api/pipelines/{id}/steps/{step_id} removes the step.""" p = self._create_pipeline(client) step = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}).json() r = client.delete(f"/api/pipelines/{p['id']}/steps/{step['id']}") assert r.status_code == 204 details = client.get(f"/api/pipelines/{p['id']}").json() assert details["steps"] == [] def test_delete_step_compacts_positions(self, client): """After deleting a step, remaining steps have contiguous positions.""" p = self._create_pipeline(client) s1 = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "convert_to_pdf"}).json() client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}) client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "extract_metadata"}) client.delete(f"/api/pipelines/{p['id']}/steps/{s1['id']}") details = client.get(f"/api/pipelines/{p['id']}").json() positions = [s["position"] for s in details["steps"]] assert positions == sorted(positions) assert positions[0] == 0 def test_reorder_steps(self, client): """PUT /api/pipelines/{id}/steps/reorder reorders all steps.""" p = self._create_pipeline(client) s1 = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "convert_to_pdf"}).json() s2 = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}).json() s3 = client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "extract_metadata"}).json() # Reverse order r = client.put( f"/api/pipelines/{p['id']}/steps/reorder", json=[s3["id"], s2["id"], s1["id"]], ) assert r.status_code == 200 types = [s["step_type"] for s in r.json()] assert types == ["extract_metadata", "ocr", "convert_to_pdf"] def test_reorder_steps_invalid_ids_rejected(self, client): """Providing wrong step IDs returns 422.""" p = self._create_pipeline(client) client.post(f"/api/pipelines/{p['id']}/steps", json={"step_type": "ocr"}) r = client.put(f"/api/pipelines/{p['id']}/steps/reorder", json=[99999]) assert r.status_code == 422 # --------------------------------------------------------------------------- # Integration tests – System pipeline (admin endpoint) # --------------------------------------------------------------------------- @pytest.mark.integration class TestSystemPipeline: """Tests for the admin system-pipeline creation endpoint.""" def test_create_system_pipeline_as_admin(self, client): """An admin can create a system pipeline (owner_id=NULL).""" with patch("app.api.pipelines._is_admin", return_value=True): r = client.post( "/api/pipelines/admin/system", json={"name": "Global Default", "is_default": True}, ) assert r.status_code == 201 data = r.json() assert data["owner_id"] is None assert data["is_default"] is True def test_create_system_pipeline_as_non_admin_forbidden(self, client): """A non-admin user cannot create a system pipeline.""" r = client.post( "/api/pipelines/admin/system", json={"name": "Should Fail"}, ) assert r.status_code == 403 # --------------------------------------------------------------------------- # Integration tests – File pipeline assignment # --------------------------------------------------------------------------- @pytest.mark.integration class TestAssignPipelineToFile: """Tests for POST /api/files/{id}/assign-pipeline.""" def test_assign_pipeline_to_file(self, client, db_session): """Assigning a pipeline to a file stores pipeline_id on the record.""" # File with no owner so anonymous test session can access it fr = _make_test_file_record(db_session, owner_id=None) pipeline = client.post("/api/pipelines", json={"name": "Assign Test"}).json() r = client.post(f"/api/files/{fr.id}/assign-pipeline?pipeline_id={pipeline['id']}") assert r.status_code == 200 data = r.json() assert data["file_id"] == fr.id assert data["pipeline_id"] == pipeline["id"] db_session.refresh(fr) assert fr.pipeline_id == pipeline["id"] def test_clear_pipeline_from_file(self, client, db_session): """Passing no pipeline_id clears the assignment.""" fr = _make_test_file_record(db_session, owner_id=None) pipeline = client.post("/api/pipelines", json={"name": "Clearable"}).json() client.post(f"/api/files/{fr.id}/assign-pipeline?pipeline_id={pipeline['id']}") r = client.post(f"/api/files/{fr.id}/assign-pipeline") assert r.status_code == 200 assert r.json()["pipeline_id"] is None def test_assign_nonexistent_pipeline_returns_404(self, client, db_session): """Assigning a non-existent pipeline returns 404.""" fr = _make_test_file_record(db_session, owner_id=None) r = client.post(f"/api/files/{fr.id}/assign-pipeline?pipeline_id=99999") assert r.status_code == 404 def test_assign_pipeline_to_nonexistent_file_returns_404(self, client): """Assigning a pipeline to a non-existent file returns 404.""" r = client.post("/api/files/99999/assign-pipeline?pipeline_id=1") assert r.status_code == 404 # --------------------------------------------------------------------------- # Unit tests – API helper logic # --------------------------------------------------------------------------- @pytest.mark.unit class TestPipelineAPIHelpers: """Unit tests for API helper functions (no DB required).""" def test_serialize_step_returns_expected_keys(self, db_session): """_serialize_step returns all required fields.""" from app.api.pipelines import _serialize_step # Build a minimal in-memory step p = Pipeline(owner_id="u1", name="P", is_default=False, is_active=True) db_session.add(p) db_session.commit() s = PipelineStep( pipeline_id=p.id, position=0, step_type="ocr", label="OCR", config=json.dumps({"force_cloud_ocr": False}), enabled=True, ) db_session.add(s) db_session.commit() result = _serialize_step(s) for key in ("id", "pipeline_id", "position", "step_type", "label", "config", "enabled"): assert key in result, f"Missing key '{key}' in serialized step" assert result["config"] == {"force_cloud_ocr": False} def test_serialize_pipeline_returns_expected_keys(self, db_session): """_serialize_pipeline returns all required fields.""" from app.api.pipelines import _serialize_pipeline p = Pipeline(owner_id="u1", name="MyPipeline", is_default=True, is_active=True) db_session.add(p) db_session.commit() result = _serialize_pipeline(p) for key in ("id", "owner_id", "name", "description", "is_default", "is_active"): assert key in result, f"Missing key '{key}' in serialized pipeline" def test_can_access_system_pipeline(self): """Anyone can read a system pipeline (owner_id=None).""" from app.api.pipelines import _can_access_pipeline p = Pipeline(owner_id=None, name="System", is_default=False, is_active=True) assert _can_access_pipeline(p, "any_user", admin=False) is True def test_cannot_write_system_pipeline_as_regular_user(self): """Regular users cannot modify system pipelines.""" from app.api.pipelines import _can_write_pipeline p = Pipeline(owner_id=None, name="System", is_default=False, is_active=True) assert _can_write_pipeline(p, "regular_user", admin=False) is False def test_admin_can_write_system_pipeline(self): """Admins can modify system pipelines.""" from app.api.pipelines import _can_write_pipeline p = Pipeline(owner_id=None, name="System", is_default=False, is_active=True) assert _can_write_pipeline(p, "admin", admin=True) is True def test_user_can_access_own_pipeline(self): """A user can access pipelines they own.""" from app.api.pipelines import _can_access_pipeline p = Pipeline(owner_id="user1", name="Mine", is_default=False, is_active=True) assert _can_access_pipeline(p, "user1", admin=False) is True def test_user_cannot_access_other_users_pipeline(self): """A regular user cannot access another user's pipeline.""" from app.api.pipelines import _can_access_pipeline p = Pipeline(owner_id="user1", name="Theirs", is_default=False, is_active=True) assert _can_access_pipeline(p, "user2", admin=False) is False def test_admin_can_access_any_pipeline(self): """Admins can access any pipeline regardless of owner.""" from app.api.pipelines import _can_access_pipeline p = Pipeline(owner_id="user99", name="Private", is_default=False, is_active=True) assert _can_access_pipeline(p, "admin", admin=True) is True # --------------------------------------------------------------------------- # Unit + integration tests – seed_default_pipeline # --------------------------------------------------------------------------- @pytest.mark.unit class TestSeedDefaultPipeline: """Tests for the seed_default_pipeline startup helper.""" def test_seed_creates_pipeline(self, db_session): """seed_default_pipeline creates exactly one system pipeline.""" from app.api.pipelines import seed_default_pipeline result = seed_default_pipeline(db_session) assert result == 1 pipelines = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).all() assert len(pipelines) == 1 def test_seeded_pipeline_is_default(self, db_session): """The seeded pipeline has is_default=True and is_active=True.""" from app.api.pipelines import seed_default_pipeline seed_default_pipeline(db_session) p = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).first() assert p is not None assert p.is_default is True assert p.is_active is True def test_seeded_pipeline_has_correct_name(self, db_session): """The seeded pipeline uses the canonical DEFAULT_PIPELINE_NAME.""" from app.api.pipelines import DEFAULT_PIPELINE_NAME, seed_default_pipeline seed_default_pipeline(db_session) p = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).first() assert p.name == DEFAULT_PIPELINE_NAME def test_seeded_pipeline_steps_count(self, db_session): """The seeded pipeline has the correct number of steps.""" from app.api.pipelines import _DEFAULT_PIPELINE_STEPS, seed_default_pipeline seed_default_pipeline(db_session) p = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).first() steps = db_session.query(PipelineStep).filter(PipelineStep.pipeline_id == p.id).all() assert len(steps) == len(_DEFAULT_PIPELINE_STEPS) def test_seeded_pipeline_step_types_and_order(self, db_session): """Steps are in the correct order and match the expected step types.""" from app.api.pipelines import _DEFAULT_PIPELINE_STEPS, seed_default_pipeline seed_default_pipeline(db_session) p = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).first() steps = ( db_session.query(PipelineStep) .filter(PipelineStep.pipeline_id == p.id) .order_by(PipelineStep.position) .all() ) expected_types = [step_type for step_type, _ in _DEFAULT_PIPELINE_STEPS] actual_types = [s.step_type for s in steps] assert actual_types == expected_types def test_seeded_pipeline_all_steps_enabled(self, db_session): """All seeded steps are enabled by default.""" from app.api.pipelines import seed_default_pipeline seed_default_pipeline(db_session) p = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).first() steps = db_session.query(PipelineStep).filter(PipelineStep.pipeline_id == p.id).all() assert all(s.enabled for s in steps), "All seeded steps should be enabled" def test_seed_is_idempotent(self, db_session): """Calling seed_default_pipeline twice does not create a duplicate.""" from app.api.pipelines import seed_default_pipeline first = seed_default_pipeline(db_session) second = seed_default_pipeline(db_session) assert first == 1 assert second == 0 # No-op on second call count = db_session.query(Pipeline).filter(Pipeline.owner_id.is_(None)).count() assert count == 1 def test_seeded_pipeline_visible_via_api(self, client): """The default pipeline is visible in the GET /api/pipelines listing.""" from app.api.pipelines import DEFAULT_PIPELINE_NAME, seed_default_pipeline from app.database import get_db # Seed using the same DB session that the test client uses db = next(client.app.dependency_overrides[get_db]()) seed_default_pipeline(db) r = client.get("/api/pipelines") assert r.status_code == 200 names = [p["name"] for p in r.json()] assert DEFAULT_PIPELINE_NAME in names def test_seeded_pipeline_default_flag_visible_via_api(self, client): """The seeded pipeline is returned with is_default=True via the API.""" from app.api.pipelines import DEFAULT_PIPELINE_NAME, seed_default_pipeline from app.database import get_db db = next(client.app.dependency_overrides[get_db]()) seed_default_pipeline(db) r = client.get("/api/pipelines") default_pipelines = [p for p in r.json() if p["name"] == DEFAULT_PIPELINE_NAME] assert len(default_pipelines) == 1 assert default_pipelines[0]["is_default"] is True