File size: 7,664 Bytes
3d2098f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Server integration test: the full wizard flow over HTTP with mock adapters.

Covers the job lifecycle (submit -> poll -> result), artifact endpoints,
static file serving of the rendered MP4, and the clone route.
"""

from __future__ import annotations

import os
import time

import pytest

# Mock adapters must be selected BEFORE server.app builds its Pipeline.
os.environ.update(
    ADAPTER_LLM="mock", ADAPTER_TTS="mock", ADAPTER_STOCK="mock",
    ADAPTER_SFX="mock", ADAPTER_MUSIC="mock", ADAPTER_REFDATA="mock",
    ADAPTER_TRANSCRIPT="mock",
)

from tests.mocks import register_mocks  # noqa: E402

register_mocks()

from fastapi.testclient import TestClient  # noqa: E402

from core.stages import topics as topics_stage  # noqa: E402
from server.app import app  # noqa: E402

client = TestClient(app)


def wait_job(job_id: str, timeout: float = 120.0) -> dict:
    deadline = time.time() + timeout
    while time.time() < deadline:
        job = client.get(f"/api/jobs/{job_id}").json()
        if job["status"] == "done":
            return job["result"]
        if job["status"] == "error":
            raise AssertionError(f"job {job['kind']} failed: {job['error']}")
        time.sleep(0.2)
    raise AssertionError("job timed out")


@pytest.fixture(scope="module")
def project_id(request):
    resp = client.post("/api/projects", json={
        "name": "Server Smoke",
        "niche": {"topic": "deep sea creatures", "keywords": ["ocean"],
                  "subreddits": ["ocean"], "audience": "everyone", "tone": "fun"},
        "format": "youtube_short",
        "references": [
            {"kind": "youtube", "name": "@deepseadaily"},
            {"kind": "instagram_style", "name": "oceanreels",
             "url": "https://instagram.com/oceanreels", "notes": "big captions"},
        ],
    })
    assert resp.status_code == 200, resp.text
    pid = resp.json()["id"]
    request.addfinalizer(lambda: client.delete(f"/api/projects/{pid}"))
    return pid


def test_health():
    body = client.get("/api/health").json()
    assert body["ok"] and "caption_presets" in body
    assert "tts_providers" in body


def test_wizard_flow(project_id, monkeypatch):
    monkeypatch.setattr(topics_stage, "_news_signals", lambda p: [])
    monkeypatch.setattr(topics_stage, "_reddit_signals", lambda p: [])

    # style: prompt prepare (job) then refresh, then edit-and-save the output
    job = client.post(f"/api/projects/{project_id}/style/prompt").json()
    assert "STYLE CARD" in wait_job(job["job_id"])["prompt"]
    job = client.post(f"/api/projects/{project_id}/style/refresh", json={}).json()
    style = wait_job(job["job_id"])
    assert style["style_card"] and style["prompt_used"]
    style["style_card"] = "EDITED style card"
    assert client.put(f"/api/projects/{project_id}/style", json=style).json()["style_card"] == "EDITED style card"

    # suggest references (job-based)
    job = client.post(f"/api/projects/{project_id}/references/suggest", json={"n": 3}).json()
    assert isinstance(wait_job(job["job_id"]), list)

    # topics: prompt prepare + generate + inline edit
    job = client.post(f"/api/projects/{project_id}/topics/prompt", json={"n": 5}).json()
    assert wait_job(job["job_id"])["prompt"]
    job = client.post(f"/api/projects/{project_id}/topics/generate", json={"n": 5}).json()
    topics = wait_job(job["job_id"])["topics"]
    assert len(topics) == 5
    topics[0]["title"] = "Edited topic title"
    resp = client.put(f"/api/projects/{project_id}/topics", json={"topics": topics})
    assert resp.json()["topics"][0]["title"] == "Edited topic title"

    # create a reel for the chosen topic
    reel = client.post(f"/api/projects/{project_id}/reels",
                       json={"topic": "Edited topic title"}).json()
    rid = reel["id"]
    base = f"/api/projects/{project_id}/reels/{rid}"

    # script: prompt prepare + generate + patch + scene regen
    assert "scripts" in client.post(f"{base}/script/prompt", json={"topic": "Edited topic title"}).json()["prompt"].lower()
    job = client.post(f"{base}/script/generate", json={"topic": "Edited topic title"}).json()
    script = wait_job(job["job_id"])
    assert len(script["hook_options"]) == 3 and script["prompt_used"]
    script["selected_hook"] = 1
    assert client.patch(f"{base}/script", json=script).json()["selected_hook"] == 1
    job = client.post(f"{base}/script/regenerate-scene", json={"scene": 1}).json()
    assert "Rewritten" in wait_job(job["job_id"])["scenes"][0]["narration"]

    # voices listing (with providers) + voice synthesis
    vinfo = client.get("/api/voices").json()
    assert vinfo["voices"] and "providers" in vinfo
    job = client.post(f"{base}/voice", json={"voice": "", "provider": "mock"}).json()
    assert wait_job(job["job_id"])["total_duration_sec"] > 3

    # media + swap
    job = client.post(f"{base}/media").json()
    media = wait_job(job["job_id"])
    assert media["scenes"] and media["scenes"][0]["candidates"]
    before = media["scenes"][0]["selected"]
    swapped = client.post(f"{base}/media/swap", json={"line_index": 0}).json()
    assert swapped["scenes"][0]["selected"] != before

    # render one variant, then fetch the file over HTTP
    job = client.post(f"{base}/render", json={
        "variants": [{"name": "Variant A", "caption_preset": "bold-center-karaoke", "clip_offset": 0}],
    }).json()
    renders = wait_job(job["job_id"], timeout=300)
    assert renders["results"]
    url = renders["results"][0]["url"]
    assert url.startswith("/files/")
    video = client.get(url)
    assert video.status_code == 200 and len(video.content) > 50_000

    # reel payload includes everything
    payload = client.get(base).json()
    assert payload["script"] and payload["voice"] and payload["media"] and payload["renders"]
    # project payload lists the reel
    proj = client.get(f"/api/projects/{project_id}").json()
    assert any(r["id"] == rid for r in proj["reels"])


def test_multiple_reels_via_api(project_id):
    r1 = client.post(f"/api/projects/{project_id}/reels", json={"topic": "alpha"}).json()
    r2 = client.post(f"/api/projects/{project_id}/reels", json={"topic": "beta"}).json()
    reels = client.get(f"/api/projects/{project_id}/reels").json()
    ids = {r["id"] for r in reels}
    assert {r1["id"], r2["id"]} <= ids
    assert client.delete(f"/api/projects/{project_id}/reels/{r1['id']}").json()["ok"]
    remaining = {r["id"] for r in client.get(f"/api/projects/{project_id}/reels").json()}
    assert r1["id"] not in remaining and r2["id"] in remaining


def test_clone_route(project_id):
    job = client.post(f"/api/projects/{project_id}/clone",
                      json={"url": "https://www.youtube.com/shorts/abcdefghijk"}).json()
    result = wait_job(job["job_id"])
    assert result["reel"]["clone_source"].endswith("abcdefghijk")
    assert result["script"]["clone_source"].endswith("abcdefghijk")

    job = client.post(f"/api/projects/{project_id}/clone",
                      json={"url": "https://example.com/nope"}).json()
    status = None
    for _ in range(50):
        status = client.get(f"/api/jobs/{job['job_id']}").json()
        if status["status"] in ("done", "error"):
            break
        time.sleep(0.1)
    assert status["status"] == "error" and "YouTube" in status["error"]


def test_bad_render_preset(project_id):
    reel = client.post(f"/api/projects/{project_id}/reels", json={"topic": "x"}).json()
    resp = client.post(f"/api/projects/{project_id}/reels/{reel['id']}/render", json={
        "variants": [{"name": "X", "caption_preset": "nope", "clip_offset": 0}],
    })
    assert resp.status_code == 400