443 lines
13 KiB
Python
443 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from wf_artifacts import (
|
|
DraftWorkspaceStore,
|
|
FileDraftWorkspaceStore,
|
|
WorkflowDraftWorkspace,
|
|
create_draft_workspace,
|
|
get_draft_workspace,
|
|
patch_draft_workspace,
|
|
replace_draft_workspace_document,
|
|
replace_validated_draft_document,
|
|
summarize_draft_workspace,
|
|
)
|
|
|
|
|
|
def test_draft_workspace_stores_mutable_draft_with_revision() -> None:
|
|
workspace = WorkflowDraftWorkspace(
|
|
id="echo_draft",
|
|
revision=1,
|
|
title="Echo Draft",
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=100,
|
|
)
|
|
|
|
assert workspace.id == "echo_draft"
|
|
assert workspace.revision == 1
|
|
assert workspace.draft["steps"]["echo"]["use"] == "demo.echo"
|
|
|
|
|
|
def test_draft_workspace_summary_is_compact() -> None:
|
|
workspace = WorkflowDraftWorkspace(
|
|
id="echo_draft",
|
|
revision=3,
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=200,
|
|
)
|
|
|
|
summary = summarize_draft_workspace(workspace)
|
|
|
|
assert summary["workspace_id"] == "echo_draft"
|
|
assert summary["revision"] == 3
|
|
assert summary["status"] == "valid"
|
|
assert summary["summary"]["name"] == "echo"
|
|
assert summary["summary"]["steps"] == ["echo"]
|
|
assert "draft" not in summary
|
|
|
|
|
|
def test_file_draft_workspace_store_round_trips_workspace(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
workspace = WorkflowDraftWorkspace(
|
|
id="echo_draft",
|
|
revision=1,
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=100,
|
|
)
|
|
|
|
store.save_workspace(workspace)
|
|
loaded = store.get_workspace("echo_draft")
|
|
|
|
assert loaded == workspace
|
|
|
|
|
|
def test_file_draft_workspace_store_lists_workspaces(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
store.save_workspace(
|
|
WorkflowDraftWorkspace(
|
|
id="b",
|
|
revision=1,
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=100,
|
|
)
|
|
)
|
|
store.save_workspace(
|
|
WorkflowDraftWorkspace(
|
|
id="a",
|
|
revision=1,
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=100,
|
|
)
|
|
)
|
|
|
|
assert [workspace.id for workspace in store.list_workspaces()] == ["a", "b"]
|
|
|
|
|
|
def test_file_draft_workspace_store_deletes_workspace(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
store.save_workspace(
|
|
WorkflowDraftWorkspace(
|
|
id="echo_draft",
|
|
revision=1,
|
|
draft=_draft(),
|
|
status="valid",
|
|
diagnostics=[],
|
|
created_at_epoch_ms=100,
|
|
updated_at_epoch_ms=100,
|
|
)
|
|
)
|
|
|
|
deleted = store.delete_workspace("echo_draft")
|
|
deleted_again = store.delete_workspace("echo_draft")
|
|
|
|
assert deleted is True
|
|
assert deleted_again is False
|
|
assert store.list_workspaces() == []
|
|
|
|
|
|
def test_file_draft_workspace_store_rejects_path_traversal_ids(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
|
|
try:
|
|
store.get_workspace("../outside")
|
|
except ValueError as exc:
|
|
assert "path separators are not allowed" in str(exc)
|
|
else:
|
|
raise AssertionError("expected unsafe workspace id to be rejected")
|
|
|
|
|
|
def test_create_draft_workspace_validates_and_saves(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
|
|
result = create_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
draft=_draft(),
|
|
title="Echo Draft",
|
|
)
|
|
|
|
loaded = store.get_workspace("echo_draft")
|
|
assert result["workspace_id"] == "echo_draft"
|
|
assert result["status"] == "valid"
|
|
assert loaded.revision == 1
|
|
assert loaded.status == "valid"
|
|
|
|
|
|
def test_create_draft_workspace_rejects_duplicate_id(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
|
|
result = create_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
draft=_draft(),
|
|
title="Replacement",
|
|
)
|
|
|
|
loaded = store.get_workspace("echo_draft")
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "workspace_exists"
|
|
assert loaded.revision == 1
|
|
assert loaded.title is None
|
|
|
|
|
|
def test_patch_draft_workspace_applies_patch_and_increments_revision(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
|
|
result = patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[
|
|
{
|
|
"op": "replace",
|
|
"path": "/name",
|
|
"value": "echo_v2",
|
|
}
|
|
],
|
|
)
|
|
|
|
loaded = store.get_workspace("echo_draft")
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "valid"
|
|
assert loaded.revision == 2
|
|
assert loaded.draft["name"] == "echo_v2"
|
|
|
|
|
|
def test_patch_draft_workspace_rejects_stale_revision(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[],
|
|
)
|
|
|
|
result = patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[],
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert store.get_workspace("echo_draft").revision == 2
|
|
|
|
|
|
def test_patch_draft_workspace_rejects_duplicate_revision_patch(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
|
|
first = patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/name", "value": "first"}],
|
|
)
|
|
second = patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/name", "value": "second"}],
|
|
)
|
|
|
|
loaded = store.get_workspace("echo_draft")
|
|
assert first["revision"] == 2
|
|
assert second["status"] == "conflict"
|
|
assert second["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert loaded.draft["name"] == "first"
|
|
|
|
|
|
def test_patch_draft_workspace_rejects_invalid_patch_without_revision_bump(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
|
|
result = patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[{"op": "remove", "path": "/missing"}],
|
|
)
|
|
|
|
assert result["status"] == "invalid"
|
|
assert result["diagnostics"][0]["code"] == "patch_invalid"
|
|
assert store.get_workspace("echo_draft").revision == 1
|
|
|
|
|
|
def test_replace_validated_draft_document_isolates_persisted_draft() -> None:
|
|
class ReferenceDraftWorkspaceStore(DraftWorkspaceStore):
|
|
def __init__(self) -> None:
|
|
self.workspace: WorkflowDraftWorkspace | None = None
|
|
|
|
def save_workspace(self, workspace: WorkflowDraftWorkspace) -> None:
|
|
self.workspace = workspace
|
|
|
|
def get_workspace(self, workspace_id: str) -> WorkflowDraftWorkspace:
|
|
if self.workspace is None or self.workspace.id != workspace_id:
|
|
raise KeyError(workspace_id)
|
|
return self.workspace
|
|
|
|
def list_workspaces(self) -> list[WorkflowDraftWorkspace]:
|
|
return [] if self.workspace is None else [self.workspace]
|
|
|
|
def delete_workspace(self, workspace_id: str) -> bool:
|
|
if self.workspace is None or self.workspace.id != workspace_id:
|
|
return False
|
|
self.workspace = None
|
|
return True
|
|
|
|
store = ReferenceDraftWorkspaceStore()
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
replacement = _draft()
|
|
replacement["name"] = "replacement"
|
|
|
|
replace_validated_draft_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft=replacement,
|
|
)
|
|
replacement["name"] = "mutated_after_save"
|
|
|
|
assert store.get_workspace("echo_draft").draft["name"] == "replacement"
|
|
|
|
|
|
def test_replace_draft_workspace_document_revalidates_and_increments_revision(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
replacement = {**_draft(), "name": "replacement"}
|
|
|
|
result = replace_draft_workspace_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft=replacement,
|
|
node_defs_for_draft=lambda _draft: [],
|
|
)
|
|
|
|
assert result["revision"] == 2
|
|
assert store.get_workspace("echo_draft").draft["name"] == "replacement"
|
|
|
|
|
|
def test_replace_draft_workspace_document_identical_draft_is_a_noop(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
current = store.get_workspace("echo_draft")
|
|
|
|
result = replace_draft_workspace_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft=current.draft,
|
|
node_defs_for_draft=lambda _draft: [],
|
|
)
|
|
|
|
assert result["revision"] == 1
|
|
assert store.get_workspace("echo_draft") == current
|
|
|
|
|
|
def test_replace_draft_workspace_document_rejects_stale_revision(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
patch_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
patch=[{"op": "replace", "path": "/name", "value": "current"}],
|
|
)
|
|
before = store.get_workspace("echo_draft")
|
|
|
|
result = replace_draft_workspace_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft={**_draft(), "name": "stale"},
|
|
node_defs_for_draft=lambda _draft: [],
|
|
)
|
|
|
|
assert result["status"] == "conflict"
|
|
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
|
assert store.get_workspace("echo_draft") == before
|
|
|
|
|
|
def test_replace_draft_workspace_document_rejects_structural_failure(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
before = store.get_workspace("echo_draft")
|
|
|
|
with pytest.raises(ValidationError):
|
|
replace_draft_workspace_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft={"name": "incomplete"},
|
|
node_defs_for_draft=lambda _draft: [],
|
|
)
|
|
|
|
assert store.get_workspace("echo_draft") == before
|
|
|
|
|
|
def test_replace_draft_workspace_document_persists_fresh_invalid_diagnostics(
|
|
tmp_path,
|
|
) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
replacement = _draft()
|
|
replacement["routes"] = {"echo": {"ok": "missing_step"}}
|
|
|
|
result = replace_draft_workspace_document(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
revision=1,
|
|
draft=replacement,
|
|
node_defs_for_draft=lambda _draft: [],
|
|
)
|
|
stored = store.get_workspace("echo_draft")
|
|
|
|
assert result["revision"] == 2
|
|
assert result["status"] == "invalid"
|
|
assert result["diagnostics"]
|
|
assert stored.status == "invalid"
|
|
assert stored.diagnostics == result["diagnostics"]
|
|
assert stored.draft["routes"] == replacement["routes"]
|
|
|
|
|
|
def test_get_draft_workspace_includes_full_draft_only_when_requested(tmp_path) -> None:
|
|
store = FileDraftWorkspaceStore(tmp_path)
|
|
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
|
|
|
compact = get_draft_workspace(store, workspace_id="echo_draft")
|
|
full = get_draft_workspace(
|
|
store,
|
|
workspace_id="echo_draft",
|
|
include_draft=True,
|
|
)
|
|
|
|
assert "draft" not in compact
|
|
assert full["draft"]["steps"]["echo"]["use"] == "demo.echo"
|
|
|
|
|
|
def _draft() -> dict[str, Any]:
|
|
return {
|
|
"name": "echo",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"state_schema": {"fields": {"echoed": {"type": "string"}}},
|
|
"output_schema": {"type": "object", "properties": {}},
|
|
"start": "echo",
|
|
"steps": {
|
|
"echo": {
|
|
"use": "demo.echo",
|
|
"input": [],
|
|
"output": [
|
|
{
|
|
"source": {"root": "local", "parts": ["echoed"]},
|
|
"target": {"root": "state", "parts": ["echoed"]},
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"routes": {"echo": {"ok": "__end__"}},
|
|
}
|