Merge branch 'main' of https://github.com/christianlouis/DocuElevate into copilot/fix-pricing-page-issues
This commit is contained in:
@@ -62,6 +62,8 @@ from app.main import app as fastapi_app # noqa: E402
|
||||
from app.models import ( # noqa: F401, E402
|
||||
DocumentMetadata,
|
||||
FileRecord,
|
||||
Pipeline,
|
||||
PipelineStep,
|
||||
ProcessingLog,
|
||||
SavedSearch,
|
||||
UserProfile,
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""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
|
||||
@@ -1713,3 +1713,271 @@ class TestGetTextWithContent:
|
||||
assert "text" in data
|
||||
assert data["text"] # Should have non-empty text
|
||||
assert "No text" not in data["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline-info tests: file_detail and file_view views
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPipelineInfoInViews:
|
||||
"""Tests that pipeline information is correctly resolved and passed to templates."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_file(db_session, pipeline_id=None):
|
||||
from app.models import FileRecord
|
||||
|
||||
f = FileRecord(
|
||||
filehash="ph_" + str(pipeline_id),
|
||||
original_filename="doc.pdf",
|
||||
local_filename="/tmp/doc.pdf",
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
pipeline_id=pipeline_id,
|
||||
)
|
||||
db_session.add(f)
|
||||
db_session.commit()
|
||||
return f
|
||||
|
||||
@staticmethod
|
||||
def _make_system_pipeline(db_session, is_default=True):
|
||||
from app.models import Pipeline, PipelineStep
|
||||
|
||||
p = Pipeline(
|
||||
owner_id=None,
|
||||
name="Standard Processing Pipeline",
|
||||
description="System default",
|
||||
is_default=is_default,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(p)
|
||||
db_session.flush()
|
||||
|
||||
for pos, (step_type, label) in enumerate(
|
||||
[
|
||||
("convert_to_pdf", "Convert to PDF"),
|
||||
("ocr", "OCR"),
|
||||
("send_to_destinations", "Send"),
|
||||
]
|
||||
):
|
||||
db_session.add(PipelineStep(pipeline_id=p.id, position=pos, step_type=step_type, label=label, enabled=True))
|
||||
|
||||
db_session.commit()
|
||||
return p
|
||||
|
||||
@staticmethod
|
||||
def _make_custom_pipeline(db_session):
|
||||
from app.models import Pipeline, PipelineStep
|
||||
|
||||
p = Pipeline(
|
||||
owner_id="user1",
|
||||
name="My Custom Pipeline",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(p)
|
||||
db_session.flush()
|
||||
db_session.add(PipelineStep(pipeline_id=p.id, position=0, step_type="ocr", label="OCR", enabled=True))
|
||||
db_session.commit()
|
||||
return p
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _resolve_pipeline unit tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_resolve_pipeline_explicit_assignment(self, db_session):
|
||||
"""File with an explicit pipeline_id resolves to that pipeline."""
|
||||
from app.views.files import _resolve_pipeline
|
||||
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
|
||||
|
||||
info = _resolve_pipeline(db_session, file_rec)
|
||||
|
||||
assert info is not None
|
||||
assert info["id"] == pipeline.id
|
||||
assert info["is_explicit"] is True
|
||||
assert info["is_system"] is True
|
||||
|
||||
def test_resolve_pipeline_fallback_to_system_default(self, db_session):
|
||||
"""File without pipeline_id falls back to system-default pipeline."""
|
||||
from app.views.files import _resolve_pipeline
|
||||
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
|
||||
info = _resolve_pipeline(db_session, file_rec)
|
||||
|
||||
assert info is not None
|
||||
assert info["id"] == pipeline.id
|
||||
assert info["is_explicit"] is False
|
||||
assert info["is_system"] is True
|
||||
assert info["is_default"] is True
|
||||
|
||||
def test_resolve_pipeline_returns_none_when_no_pipeline_in_db(self, db_session):
|
||||
"""Returns None when no pipeline exists (empty database)."""
|
||||
from app.views.files import _resolve_pipeline
|
||||
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
info = _resolve_pipeline(db_session, file_rec)
|
||||
|
||||
assert info is None
|
||||
|
||||
def test_resolve_pipeline_includes_steps(self, db_session):
|
||||
"""Returned dict contains the pipeline's steps in order."""
|
||||
from app.views.files import _resolve_pipeline
|
||||
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
|
||||
|
||||
info = _resolve_pipeline(db_session, file_rec)
|
||||
|
||||
assert info is not None
|
||||
assert len(info["steps"]) == 3
|
||||
assert info["steps"][0].step_type == "convert_to_pdf"
|
||||
|
||||
def test_resolve_pipeline_custom_pipeline(self, db_session):
|
||||
"""File with an explicit custom pipeline resolves correctly."""
|
||||
from app.views.files import _resolve_pipeline
|
||||
|
||||
pipeline = self._make_custom_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
|
||||
|
||||
info = _resolve_pipeline(db_session, file_rec)
|
||||
|
||||
assert info is not None
|
||||
assert info["id"] == pipeline.id
|
||||
assert info["name"] == "My Custom Pipeline"
|
||||
assert info["is_system"] is False
|
||||
assert info["is_explicit"] is True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _compute_processing_flow pipeline filtering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_compute_flow_without_pipeline_shows_all_stages(self):
|
||||
"""Without a pipeline, all hardcoded stages are included."""
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
flow = _compute_processing_flow([], pipeline_steps=None)
|
||||
# Should include standard stages (create_file_record, check_text, etc.)
|
||||
keys = {s["key"] for s in flow}
|
||||
assert "create_file_record" in keys
|
||||
assert "extract_metadata_with_gpt" in keys
|
||||
|
||||
def test_step_type_mapping_is_complete(self):
|
||||
"""Every step type in PIPELINE_STEP_TYPES has an entry in _STEP_TYPE_TO_STAGES."""
|
||||
from app.api.pipelines import PIPELINE_STEP_TYPES
|
||||
from app.views.files import _STEP_TYPE_TO_STAGES
|
||||
|
||||
missing = set(PIPELINE_STEP_TYPES.keys()) - set(_STEP_TYPE_TO_STAGES.keys())
|
||||
assert not missing, (
|
||||
f"The following pipeline step types are missing from _STEP_TYPE_TO_STAGES "
|
||||
f"in app/views/files.py: {missing}. "
|
||||
"Add them with their corresponding Celery log stage key(s) (use [] if none yet)."
|
||||
)
|
||||
|
||||
def test_compute_flow_with_pipeline_filters_stages(self, db_session):
|
||||
"""With a pipeline, only mapped stages are shown (plus always-show and ran stages)."""
|
||||
|
||||
from app.models import PipelineStep
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
# pipeline has: convert_to_pdf, ocr, send_to_destinations
|
||||
|
||||
# Query steps explicitly (no SQLAlchemy relationship defined on Pipeline)
|
||||
steps = db_session.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline.id).all()
|
||||
|
||||
flow = _compute_processing_flow([], pipeline_steps=steps)
|
||||
# When no logs and pipeline steps provided, only pipeline-mapped + always-show stages appear
|
||||
keys = {s["key"] for s in flow}
|
||||
# Always show
|
||||
assert "create_file_record" in keys
|
||||
# ocr maps to check_text / extract_text / process_with_ocr
|
||||
assert "check_text" in keys or "extract_text" in keys
|
||||
# embed_metadata not in pipeline → should be absent (no logs ran it)
|
||||
assert "embed_metadata_into_pdf" not in keys
|
||||
|
||||
def test_compute_flow_with_pipeline_always_shows_ran_stages(self, db_session):
|
||||
"""Stages that actually ran are always shown even if not in the pipeline."""
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.models import PipelineStep
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
steps = db_session.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline.id).all()
|
||||
|
||||
# Simulate a log entry for embed_metadata_into_pdf (not in this pipeline)
|
||||
ran_log = Mock(
|
||||
step_name="embed_metadata_into_pdf",
|
||||
status="success",
|
||||
message="Done",
|
||||
timestamp=Mock(),
|
||||
task_id="t1",
|
||||
)
|
||||
|
||||
flow = _compute_processing_flow([ran_log], pipeline_steps=steps)
|
||||
keys = {s["key"] for s in flow}
|
||||
assert "embed_metadata_into_pdf" in keys
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Integration: view endpoints pass pipeline_info to template
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_file_detail_page_includes_pipeline_name(self, client, db_session):
|
||||
"""GET /files/{id}/detail response body contains the pipeline name."""
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
|
||||
response = client.get(f"/files/{file_rec.id}/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Standard Processing Pipeline" in response.content
|
||||
|
||||
def test_file_detail_page_shows_system_default_badge(self, client, db_session):
|
||||
"""File without explicit pipeline shows 'System Default' badge in detail view."""
|
||||
self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
|
||||
response = client.get(f"/files/{file_rec.id}/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"System Default" in response.content
|
||||
|
||||
def test_file_detail_page_shows_custom_badge_for_custom_pipeline(self, client, db_session):
|
||||
"""File with a custom (non-system) pipeline shows 'Custom' badge."""
|
||||
pipeline = self._make_custom_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
|
||||
|
||||
response = client.get(f"/files/{file_rec.id}/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"My Custom Pipeline" in response.content
|
||||
assert b"Custom" in response.content
|
||||
|
||||
def test_file_view_page_includes_pipeline_name(self, client, db_session):
|
||||
"""GET /files/{id} response body contains the pipeline name in the sidebar."""
|
||||
pipeline = self._make_system_pipeline(db_session)
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Standard Processing Pipeline" in response.content
|
||||
|
||||
def test_file_view_page_no_pipeline_shows_standard(self, client, db_session):
|
||||
"""When no pipeline exists, file view shows 'Standard' fallback text."""
|
||||
# No pipeline in DB
|
||||
file_rec = self._make_file(db_session, pipeline_id=None)
|
||||
|
||||
response = client.get(f"/files/{file_rec.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Standard" in response.content
|
||||
|
||||
Reference in New Issue
Block a user