Spaces:
Sleeping
Sleeping
File size: 14,683 Bytes
94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec b7e4cb3 94f31ec b7e4cb3 94f31ec b7e4cb3 94f31ec b7e4cb3 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec 8de3fe7 94f31ec e382248 8de3fe7 e382248 8de3fe7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | """
Integration tests for the Workspace API router.
Tests verify:
- Endpoints are mounted and accept validated requests
- Document sections include user stories, functional/non-functional requirements, and use cases
- API rejects invalid input with proper validation errors
"""
from fastapi.testclient import TestClient
class TestWorkspaceEndpointsMounted:
"""Verify all workspace endpoints are mounted and responding."""
def test_evaluate_endpoint_exists(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/evaluate", json={"idea": ""})
assert response.status_code == 200
def test_evaluate_rejects_missing_body(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/evaluate")
assert response.status_code == 422
def test_directions_endpoint_exists(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/directions", json={"answers": {}})
assert response.status_code == 200
def test_directions_rejects_missing_body(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/directions")
assert response.status_code == 422
def test_generate_endpoint_exists(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/generate", json={
"direction_id": "dir-a",
"brief": "Build a task manager"
})
assert response.status_code == 200
def test_generate_rejects_missing_body(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/generate")
assert response.status_code == 422
def test_refine_endpoint_exists(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/refine", json={
"section_id": "sec-test",
"content": "Some content",
"prompt": "Make it better"
})
assert response.status_code == 200
def test_refine_rejects_missing_body(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/refine")
assert response.status_code == 422
class TestDocumentSectionsIncludeRequirements:
"""Verify document generation sections include user stories, requirements, and use cases."""
def test_sections_include_user_stories(self):
"""SECTIONS must include a User Stories section."""
from app.services.workspace_service import SECTIONS
section_titles = [s["title"].lower() for s in SECTIONS]
assert any("user stor" in t for t in section_titles), \
f"SECTIONS must include 'User Stories' but got: {section_titles}"
def test_sections_include_functional_requirements(self):
"""SECTIONS must include a Functional Requirements section."""
from app.services.workspace_service import SECTIONS
section_titles = [s["title"].lower() for s in SECTIONS]
assert any("functional requirement" in t for t in section_titles), \
f"SECTIONS must include 'Functional Requirements' but got: {section_titles}"
def test_sections_include_non_functional_requirements(self):
"""SECTIONS must include a Non-Functional Requirements section."""
from app.services.workspace_service import SECTIONS
section_titles = [s["title"].lower() for s in SECTIONS]
assert any("non-functional" in t or "non functional" in t for t in section_titles), \
f"SECTIONS must include 'Non-Functional Requirements' but got: {section_titles}"
def test_sections_include_use_cases(self):
"""SECTIONS must include a Use Cases section."""
from app.services.workspace_service import SECTIONS
section_titles = [s["title"].lower() for s in SECTIONS]
assert any("use case" in t for t in section_titles), \
f"SECTIONS must include 'Use Cases' but got: {section_titles}"
class TestEvaluateEndpointSchema:
"""Verify the evaluate endpoint returns correct response schema."""
def test_evaluate_returns_correct_structure(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/evaluate", json={
"idea": "Build a task management app with AI-powered prioritization"
})
assert response.status_code == 200
data = response.json()
assert "scores" in data
assert "overall_score" in data
assert "threshold_met" in data
assert "weak_dimensions" in data
assert "targeted_questions" in data
def test_evaluate_empty_idea_returns_fallback(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/evaluate", json={"idea": ""})
assert response.status_code == 200
data = response.json()
assert data["overall_score"] == 0.0
assert data["threshold_met"] is False
assert len(data["targeted_questions"]) > 0
class TestDirectionsEndpointSchema:
"""Verify the directions endpoint returns correct response schema."""
def test_directions_returns_correct_structure(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/directions", json={
"answers": {"q1": "B2C", "q2": "Web app", "q3": "Task management", "q4": "AI-powered prioritization"}
})
assert response.status_code == 200
data = response.json()
assert "directions" in data
assert isinstance(data["directions"], list)
if len(data["directions"]) > 0:
direction = data["directions"][0]
assert "id" in direction
assert "title" in direction
assert "description" in direction
assert "tags" in direction
def test_directions_empty_answers_returns_fallback(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/directions", json={"answers": {}})
assert response.status_code == 200
data = response.json()
assert len(data["directions"]) >= 2
class TestRefineEndpoint:
"""Verify the refine endpoint handles edge cases gracefully."""
def test_refine_empty_content_returns_original(self, authenticated_client):
client = authenticated_client
response = client.post("/api/workspace/refine", json={
"section_id": "sec-test",
"content": "",
"prompt": "Make it better"
})
assert response.status_code == 200
data = response.json()
assert data["section_id"] == "sec-test"
assert data["content"] == ""
def test_refine_survives_braces_in_section_content(self):
"""Braces in user content must not break prompt assembly.
Technical sections routinely contain `{...}` - JSON samples, code, route
templates like `/prd/{session_id}`. `str.format` does not re-format the
values it substitutes, so this is safe today; the test pins that, and
pins that a successful refine returns refined content rather than
silently handing back the original (the shape every swallowed failure in
this function takes).
"""
import asyncio
from app.services import workspace_service
content = 'Call `GET /prd/{session_id}` and read {"status": "ok"}.'
prompt = "Add an example with {placeholders}"
seen: dict[str, str] = {}
class _StubStructured:
async def ainvoke(self, messages):
seen["system"] = messages[0]["content"]
return workspace_service.RefinedSection(
content="Refined body.", suggestions=["a", "b"]
)
class _StubLLM:
def with_structured_output(self, _schema):
return _StubStructured()
original = workspace_service.get_chat_model
workspace_service.get_chat_model = lambda **kwargs: _StubLLM()
try:
result = asyncio.run(
workspace_service.refine_section("sec-1", content, prompt)
)
finally:
workspace_service.get_chat_model = original
# Refine actually happened rather than silently returning the original.
assert result["content"] == "Refined body."
# ...and both pieces of user text reached the model intact.
assert content in seen["system"]
assert prompt in seen["system"]
class TestPipelineGenerationPath:
"""Guards the path /api/workspace/generate actually takes.
`SECTIONS` and the tests above now describe only the degraded single-agent
fallback, used when the orchestrator cannot start. Workspace generation
normally runs the full agent team, and nothing above would notice if that
mapping broke - these cover it.
"""
def test_section_ids_round_trip_to_roles(self):
"""Every agent's section id must map back to that agent.
`save_workspace` uses this inverse to decide which sections are agent
output; if it stops round-tripping, saved projects silently lose every
agent and the dashboard reports "No output".
"""
from app.services.workspace_service import (
_role_section_id,
pipeline_section_order,
role_for_section_id,
)
for role in pipeline_section_order():
assert role_for_section_id(_role_section_id(role)) == role
def test_legacy_section_ids_are_not_mistaken_for_roles(self):
"""Fallback section ids must not be read as agent output."""
from app.services.workspace_service import SECTIONS, role_for_section_id
for section in SECTIONS:
assert role_for_section_id(section["id"]) is None
def test_pipeline_order_matches_the_orchestrator(self):
"""The section order must be derived from the phases, not restated."""
from app.core.pipeline_orchestrator import PipelineOrchestrator
from app.services.workspace_service import pipeline_section_order
expected = [
role.value
for role in PipelineOrchestrator.PHASE_1
+ PipelineOrchestrator.PHASE_2
+ PipelineOrchestrator.PHASE_3
]
assert pipeline_section_order() == expected
assert len(expected) == 11
def test_titles_render_acronyms(self):
"""`.title()` alone produces "Ux Designer" and "Qa Strategist"."""
from app.services.workspace_service import _role_section_title
assert _role_section_title("ux_designer") == "UX Designer"
assert _role_section_title("api_designer") == "API Designer"
assert _role_section_title("qa_strategist") == "QA Strategist"
assert _role_section_title("devops_architect") == "DevOps Architect"
assert _role_section_title("solution_architect") == "Solution Architect"
def test_save_puts_agent_output_at_top_level_and_metadata_under_underscore(self):
"""The dashboard reads `artifacts` as a role -> markdown map.
Writing workspace metadata at the top level made a saved spec report
"4 Agents" and render chips reading "type" and "brief".
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.core import models
from app.services.workspace_service import _role_section_id, save_workspace
# Its own in-memory engine - never the shared one, whose teardown is
# destructive elsewhere in this suite.
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
try:
user = models.User(
id=1, google_id="g", email="w@example.com", full_name="W", role="user"
)
db.add(user)
db.commit()
sections = [
{
"id": _role_section_id("solution_architect"),
"title": "Solution Architect",
"content": "## Architecture\nContent.",
"order": 0,
},
{
"id": "sec-overview",
"title": "Project Overview",
"content": "Fallback section.",
"order": 1,
},
]
project = save_workspace(
db=db,
current_user=user,
title="T",
direction_id="dir-a",
brief="b",
sections=sections,
)
assert project.artifacts["solution_architect"] == "## Architecture\nContent."
assert "_workspace" in project.artifacts
assert len(project.artifacts["_workspace"]["sections"]) == 2
# Metadata must not sit alongside roles, or it gets counted as one.
for key in ("type", "direction_id", "brief", "sections"):
assert key not in project.artifacts
# The fallback section has no role, so it is not agent output.
roles = [k for k in project.artifacts if not k.startswith("_")]
assert roles == ["solution_architect"]
finally:
db.close()
engine.dispose()
class TestWorkspaceRequiresAuth:
"""The workspace routes are the most expensive in the app.
`/generate` runs the whole 12-agent pipeline and the other four are each an
authoring-tier call. Only `/save` used to require a caller, so anyone who
could reach the host could spend the project's NVIDIA credits.
"""
ROUTES = [
("/api/workspace/evaluate", {"idea": "x"}),
("/api/workspace/clarify", {"idea": "x"}),
("/api/workspace/directions", {"answers": {}}),
("/api/workspace/generate", {"direction_id": "d", "brief": "b"}),
("/api/workspace/refine", {"section_id": "s", "content": "c", "prompt": "p"}),
("/api/workspace/save", {"title": "t", "artifacts": {}}),
]
def test_every_route_rejects_anonymous_callers(self):
from app.main import app
client = TestClient(app)
allowed = [
path for path, body in self.ROUTES
if client.post(path, json=body).status_code != 401
]
assert not allowed, f"Reachable without credentials: {allowed}"
|