File size: 8,235 Bytes
37e3d5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf1fb5f
 
 
 
 
 
 
37e3d5a
 
bf1fb5f
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Focused persistence, safety, API, and progress tests for the gallery."""

from __future__ import annotations

import asyncio
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from app import main as main_module
from app.gallery import (
    GALLERY_ARTIFACT_NAMES,
    GalleryError,
    GalleryStore,
    REQUIRED_GALLERY_ARTIFACTS,
)
from app.pipeline import JobRegistry, _complete_vision_with_feedback


def _fake_job(root: Path, item_id: str, marker: str = "one") -> Path:
    job_dir = root / item_id
    job_dir.mkdir(parents=True)
    for name in GALLERY_ARTIFACT_NAMES:
        (job_dir / name).write_bytes(f"{marker}:{name}".encode())
    return job_dir


def _result(name: str = "Test Object") -> dict:
    return {
        "targetName": name,
        "components": 7,
        "materials": 3,
        "elapsedSeconds": 12.5,
        "generationMode": "hosted-unreviewed-preview",
        "generatedPass": "hosted-preview",
        "reviewStatus": "unreviewed",
    }


def test_atomic_publish_is_restart_readable_and_independent_of_job(tmp_path):
    item_id = "a" * 32
    source = _fake_job(tmp_path / "runs", item_id)
    store = GalleryStore(tmp_path / "gallery")

    item = store.publish(
        item_id=item_id,
        job_dir=source,
        result=_result("Persistent Mug"),
        created_at=100.25,
    )
    assert item["id"] == item_id
    assert item["targetName"] == "Persistent Mug"
    assert item["thumbnailUrl"].endswith("/reference.png")
    assert item["stats"] == {
        "components": 7,
        "materials": 3,
        "elapsedSeconds": 12.5,
    }
    assert set(item["artifacts"]) == set(GALLERY_ARTIFACT_NAMES)

    # Gallery copies do not depend on temporary run retention.
    for child in source.iterdir():
        child.unlink()
    source.rmdir()

    restarted = GalleryStore(tmp_path / "gallery")
    detail = restarted.get(item_id)
    assert detail == item
    assert restarted.artifact_path(item_id, "factory.ts").read_bytes().startswith(b"one:")
    assert restarted.list_items()["items"] == [item]


def test_newest_first_pagination_and_concurrent_commits(tmp_path):
    store = GalleryStore(tmp_path / "gallery")
    ids = [f"{number:032x}" for number in range(1, 7)]
    sources = {
        item_id: _fake_job(tmp_path / "runs", item_id, item_id)
        for item_id in ids
    }

    def publish(index: int):
        item_id = ids[index]
        return store.publish(
            item_id=item_id,
            job_dir=sources[item_id],
            result=_result(f"Object {index}"),
            created_at=100.0 + index,
        )

    with ThreadPoolExecutor(max_workers=6) as pool:
        list(pool.map(publish, range(len(ids))))

    page = store.list_items(offset=1, limit=2)
    assert page["total"] == 6
    assert page["hasMore"] is True
    assert [item["id"] for item in page["items"]] == [ids[4], ids[3]]

    # Hidden staging debris and malformed complete-looking directories never
    # leak into a listing.
    (tmp_path / "gallery" / ".publishing-debris").mkdir()
    corrupt = tmp_path / "gallery" / ("f" * 32)
    corrupt.mkdir()
    (corrupt / "item.json").write_text("{}")
    assert store.list_items()["total"] == 6


def test_failed_or_unsafe_publish_leaves_no_visible_item(tmp_path):
    item_id = "b" * 32
    source = _fake_job(tmp_path / "runs", item_id)
    (source / "standalone.html").unlink()
    store = GalleryStore(tmp_path / "gallery")

    with pytest.raises(GalleryError, match="missing required"):
        store.publish(item_id=item_id, job_dir=source, result=_result())

    assert store.get(item_id) is None
    assert store.list_items()["items"] == []
    assert not (tmp_path / "gallery" / item_id).exists()
    assert not list((tmp_path / "gallery").glob(".publishing-*"))

    # Strict ids and artifact names/path containment are enforced at reads.
    assert store.get("../" + item_id) is None
    assert store.artifact_path(item_id, "../../etc/passwd") is None


def test_symlinked_required_artifact_outside_job_is_refused(tmp_path):
    item_id = "c" * 32
    source = _fake_job(tmp_path / "runs", item_id)
    (source / "factory.ts").unlink()
    outside = tmp_path / "outside.ts"
    outside.write_text("not a job artifact")
    (source / "factory.ts").symlink_to(outside)
    store = GalleryStore(tmp_path / "gallery")

    with pytest.raises(GalleryError):
        store.publish(item_id=item_id, job_dir=source, result=_result())
    assert store.get(item_id) is None


def test_gallery_http_api_and_strict_pagination(tmp_path, monkeypatch):
    item_id = "d" * 32
    store = GalleryStore(tmp_path / "gallery")
    store.publish(
        item_id=item_id,
        job_dir=_fake_job(tmp_path / "runs", item_id),
        result=_result("API Mug"),
        created_at=200,
    )
    monkeypatch.setattr(main_module, "gallery_store", store)

    with TestClient(main_module.app) as client:
        listing = client.get("/api/gallery?offset=0&limit=1")
        assert listing.status_code == 200
        assert listing.json()["items"][0]["id"] == item_id

        detail = client.get(f"/api/gallery/{item_id}")
        assert detail.status_code == 200
        assert detail.json()["targetName"] == "API Mug"

        artifact = client.get(f"/api/gallery/{item_id}/artifacts/model.bundle.js")
        assert artifact.status_code == 200
        assert artifact.headers["cache-control"].endswith("immutable")

        assert client.get("/api/gallery?offset=-1").status_code == 422
        assert client.get("/api/gallery?limit=101").status_code == 422
        assert client.get("/api/gallery/not-an-id").status_code == 404
        assert client.get(
            f"/api/gallery/{item_id}/artifacts/item.json"
        ).status_code == 404
        assert client.get("/gallery").status_code == 200
        assert client.get(f"/gallery/{item_id}").status_code == 200
        assert client.get("/gallery/not-an-id").status_code == 404


@pytest.mark.parametrize(
    ("value", "expected"),
    [
        (None, True),
        ("true", True),
        ("ON", True),
        ("1", True),
        ("false", False),
        ("Off", False),
        ("0", False),
        ("", None),
        ("maybe", None),
    ],
)
def test_share_multipart_parser(value, expected):
    assert main_module._parse_share_preference(value) is expected


@pytest.mark.asyncio
async def test_slow_llm_emits_truthful_bounded_progress(tmp_path, monkeypatch):
    class DelayedLLM:
        async def complete_vision(self, *, system, messages):
            await asyncio.sleep(0.055)
            return "finished"

    monkeypatch.setattr("app.pipeline.LLM_PROGRESS_INTERVAL_S", 0.01)
    job = JobRegistry(tmp_path / "runs").create()
    result = await _complete_vision_with_feedback(
        job,
        llm=DelayedLLM(),
        system="system",
        messages=[],
        attempt=2,
        max_attempts=4,
    )
    assert result == "finished"
    progress = [
        event for event in job.events
        if event["stage"] == "spec-authoring" and event["status"] == "progress"
    ]
    assert len(progress) >= 2
    assert all(event["data"]["attempt"] == 2 for event in progress)
    assert all(event["data"]["maxAttempts"] == 4 for event in progress)
    assert all("elapsedSeconds" in event["data"] for event in progress)
    assert "%" not in " ".join(event["message"] for event in progress)


def test_job_event_waiter_stays_set_until_a_stream_consumes_it(tmp_path):
    job = JobRegistry(tmp_path / "runs").create()
    job.waiter.clear()
    job.emit("generation", "progress", "Factory still running.")
    assert job.waiter.is_set()


def test_required_gallery_artifact_set_is_served_allowlist_subset():
    assert REQUIRED_GALLERY_ARTIFACTS <= set(GALLERY_ARTIFACT_NAMES)


def test_publish_rejects_untrusted_staging_token(tmp_path):
    store = GalleryStore(tmp_path / "gallery")
    job_dir = tmp_path / "job"
    job_dir.mkdir()
    with pytest.raises(GalleryError, match="staging token"):
        store.publish(
            item_id="a" * 32,
            job_dir=job_dir,
            result={"targetName": "Object"},
            staging_token="../../escape",
        )