4-phase restructuring of the previously flat backend/ package. Each
phase lands as a single squash commit so future bisects stay readable
per phase if needed.
## Phase 1 — move + shim (location-only, zero behavior change)
* git mv 14 files into api/ schemas/ services/ clients/ subpackages
(history preserved via RM/R renames)
* New files: api/{admin,auth,dependencies,jupyter,platform,resources,
scripts,storage}.py + api/schedules/{schedules,runs}.py
* New files: schemas/{auth,common,jupyter,platform,resources,
schedules,scripts}.py
* New files: clients/{rclone,runtime,scheduler}.py
* Old paths kept as 1-line `from backend.<new> import *` shims so
tests/main.py/importers kept working untouched
* schemas/__init__.py now re-exports from backend.schemas.<domain>
## Phase 2 — APIRouter prefix consolidation
* Every APIRouter() now carries its prefix (e.g. prefix="/api/v1/auth")
and decorators are stripped of the redundant path prefix
* URL paths exposed to the frontend are byte-identical to before
* Affected: api/{auth,jupyter,admin,platform,resources,scripts,
storage}.py + api/schedules/{schedules,runs}.py
## Phase 3 — first service-layer extraction
* backend.services.schedules.validate_dag moved out of api/
(pure DAG validator, no Request/BackgroundTasks/DB)
* api/schedules/schedules.py now re-exports the symbol so existing
4 callsites keep working unchanged
* Added backend/tests/test_validate_dag.py: 8 unit tests covering
DAG_EMPTY, linear chain, diamond, cycle, self-edge, duplicate
edge, orphan edge, multi-root ordering
## Phase 4 — delete shims + unify test imports
* Removed 14 flat shim files + schemas/__init__.py
* Migrated 5 test files (32 import sites) to new paths:
backend.scripts.* → backend.api.scripts.*
backend.resources.* → backend.api.resources.*
backend.jupyter.* → backend.api.jupyter.*
backend.runtime_client.* → backend.clients.runtime.*
backend.schemas.UpdateScriptRequest → backend.schemas.scripts.*
* audit.py kept at backend.audit (main.py references it; not a
shim, real code)
## Final structure
backend/src/backend/
main.py, audit.py, __init__.py
api/ (10 files: routes + 2 subpackage)
schemas/ (7 files: Pydantic contracts)
services/ (storage + schedules)
clients/ (rclone, runtime, scheduler)
## Verification
* uv run python -m compileall backend/src backend/tests — clean
* uv run --package backend pytest backend/tests -q — 122 passed
(114 → 114 → 122 → 122 across phases)
* grep -r 'from backend\.\(scripts\|resources\|...\)' backend/ — 0 hits
* git blame --follow still traces file origins through the renames
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""Unit tests for backend.services.schedules.validate_dag.
|
|
|
|
Pure function — no DB, no FastAPI, no fixtures beyond SimpleNamespace
|
|
stand-ins for the SQLAlchemy rows. The function only reads five
|
|
attributes: ``node_id``, ``node_key``, ``edge_id``, ``source_node_id``,
|
|
``target_node_id``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from backend.services.schedules import validate_dag
|
|
|
|
|
|
def _node(node_id: str, node_key: str) -> SimpleNamespace:
|
|
return SimpleNamespace(node_id=node_id, node_key=node_key)
|
|
|
|
|
|
def _edge(edge_id: str, source: str, target: str) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
edge_id=edge_id,
|
|
source_node_id=source,
|
|
target_node_id=target,
|
|
)
|
|
|
|
|
|
def test_empty_nodes_is_rejected_as_dag_empty() -> None:
|
|
result = validate_dag(nodes=[], edges=[])
|
|
assert result["valid"] is False
|
|
assert result["node_count"] == 0
|
|
assert result["edge_count"] == 0
|
|
assert result["topological_order"] == []
|
|
codes = [err["code"] for err in result["errors"]]
|
|
assert "DAG_EMPTY" in codes
|
|
|
|
|
|
def test_linear_chain_orders_by_node_key() -> None:
|
|
nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")]
|
|
edges = [_edge("e1", "n1", "n2"), _edge("e2", "n2", "n3")]
|
|
result = validate_dag(nodes, edges)
|
|
assert result["valid"] is True
|
|
assert result["root_node_ids"] == ["n1"]
|
|
assert result["leaf_node_ids"] == ["n3"]
|
|
assert result["topological_order"] == ["n1", "n2", "n3"]
|
|
|
|
|
|
def test_diamond_topology_is_valid() -> None:
|
|
# A -> B -> D
|
|
# A -> C -> D
|
|
nodes = [
|
|
_node("a", "A"),
|
|
_node("b", "B"),
|
|
_node("c", "C"),
|
|
_node("d", "D"),
|
|
]
|
|
edges = [
|
|
_edge("e1", "a", "b"),
|
|
_edge("e2", "a", "c"),
|
|
_edge("e3", "b", "d"),
|
|
_edge("e4", "c", "d"),
|
|
]
|
|
result = validate_dag(nodes, edges)
|
|
assert result["valid"] is True
|
|
assert result["root_node_ids"] == ["a"]
|
|
assert result["leaf_node_ids"] == ["d"]
|
|
# Kahn's algorithm with node_key tie-breaking: starting at A, then B
|
|
# and C both become ready (B alphabetically first), then D.
|
|
assert result["topological_order"] == ["a", "b", "c", "d"]
|
|
|
|
|
|
def test_cycle_is_rejected_with_dag_cycle() -> None:
|
|
# n1 -> n2 -> n3 -> n1
|
|
nodes = [_node("n1", "A"), _node("n2", "B"), _node("n3", "C")]
|
|
edges = [
|
|
_edge("e1", "n1", "n2"),
|
|
_edge("e2", "n2", "n3"),
|
|
_edge("e3", "n3", "n1"),
|
|
]
|
|
result = validate_dag(nodes, edges)
|
|
assert result["valid"] is False
|
|
codes = [err["code"] for err in result["errors"]]
|
|
assert "DAG_CYCLE" in codes
|
|
cycle_err = next(err for err in result["errors"] if err["code"] == "DAG_CYCLE")
|
|
# The cycle should list every node in the cycle (sorted by node_key).
|
|
assert set(cycle_err["node_ids"]) == {"n1", "n2", "n3"}
|
|
|
|
|
|
def test_self_edge_is_rejected_but_does_not_count_as_cycle() -> None:
|
|
nodes = [_node("n1", "A"), _node("n2", "B")]
|
|
edges = [
|
|
_edge("e_self", "n1", "n1"),
|
|
_edge("e_real", "n1", "n2"),
|
|
]
|
|
result = validate_dag(nodes, edges)
|
|
codes = [err["code"] for err in result["errors"]]
|
|
assert "DAG_SELF_EDGE" in codes
|
|
# The A->B edge still makes the DAG valid overall except for the self-edge.
|
|
assert "DAG_CYCLE" not in codes
|
|
# One node remains reachable (B), so cycle detection must not fire.
|
|
assert result["topological_order"] == ["n1", "n2"]
|
|
|
|
|
|
def test_duplicate_edge_is_rejected_with_dag_duplicate_edge() -> None:
|
|
nodes = [_node("n1", "A"), _node("n2", "B")]
|
|
edges = [
|
|
_edge("e1", "n1", "n2"),
|
|
_edge("e1_dup", "n1", "n2"),
|
|
]
|
|
result = validate_dag(nodes, edges)
|
|
codes = [err["code"] for err in result["errors"]]
|
|
assert "DAG_DUPLICATE_EDGE" in codes
|
|
# The first edge still counts toward edge_count, the second is rejected.
|
|
assert result["edge_count"] == 2
|
|
|
|
|
|
def test_edge_to_unknown_node_is_dag_edge_node_missing() -> None:
|
|
nodes = [_node("n1", "A")]
|
|
edges = [
|
|
_edge("e1", "n1", "ghost"),
|
|
_edge("e2", "ghost", "n1"),
|
|
]
|
|
result = validate_dag(nodes, edges)
|
|
codes = [err["code"] for err in result["errors"]]
|
|
assert codes.count("DAG_EDGE_NODE_MISSING") == 2
|
|
# No cycle should be reported for orphan edges.
|
|
assert "DAG_CYCLE" not in codes
|
|
|
|
|
|
def test_multiple_roots_are_sorted_by_node_key() -> None:
|
|
nodes = [
|
|
_node("z", "Z"),
|
|
_node("a", "A"),
|
|
_node("m", "M"),
|
|
]
|
|
edges = []
|
|
result = validate_dag(nodes, edges)
|
|
assert result["valid"] is True
|
|
# All three nodes are roots (no indegree) and leaves (no outgoing).
|
|
assert result["root_node_ids"] == ["a", "m", "z"]
|
|
assert result["leaf_node_ids"] == ["a", "m", "z"]
|
|
# Topological order picks the smallest node_key first.
|
|
assert result["topological_order"] == ["a", "m", "z"]
|