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}"