|
| 1 | +"""Controller-level tests: business rules without an HTTP request. |
| 2 | +
|
| 3 | +These exist to prove the split is real -- the rules they exercise used |
| 4 | +to live inside route handlers and could only be reached through a |
| 5 | +TestClient. Anything here that needs FastAPI has not been separated |
| 6 | +properly. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +from dataclasses import dataclass |
| 12 | + |
| 13 | +import pytest |
| 14 | + |
| 15 | +from app.controllers import files, runs |
| 16 | +from app.controllers.errors import ( |
| 17 | + Conflict, |
| 18 | + DomainError, |
| 19 | + InvalidRequest, |
| 20 | + NotFound, |
| 21 | + PayloadTooLarge, |
| 22 | + Unauthorized, |
| 23 | +) |
| 24 | + |
| 25 | + |
| 26 | +@dataclass |
| 27 | +class _Upload: |
| 28 | + """Stand-in for a ConversationFile row (only stored_name matters).""" |
| 29 | + |
| 30 | + stored_name: str |
| 31 | + |
| 32 | + |
| 33 | +class TestHarnessPrompt: |
| 34 | + """The prompt the agent receives is assembled, not passed through.""" |
| 35 | + |
| 36 | + def test_no_files_still_gets_the_save_hint(self) -> None: |
| 37 | + out = runs.build_harness_prompt("summarize", []) |
| 38 | + assert out.startswith("summarize") |
| 39 | + assert "Save any result files" in out |
| 40 | + assert "Uploaded files" not in out |
| 41 | + |
| 42 | + def test_uploads_are_named_by_stored_name(self) -> None: |
| 43 | + """stored_name, not filename: the on-disk name carries a unique |
| 44 | + prefix and is the one the agent can actually open.""" |
| 45 | + out = runs.build_harness_prompt("go", [_Upload("ab12cd34_sales.xlsx")]) |
| 46 | + assert "ab12cd34_sales.xlsx" in out |
| 47 | + assert "Uploaded files in your working directory" in out |
| 48 | + |
| 49 | + def test_multiple_uploads_are_comma_joined(self) -> None: |
| 50 | + out = runs.build_harness_prompt("go", [_Upload("a_1.xlsx"), _Upload("b_2.csv")]) |
| 51 | + assert "a_1.xlsx, b_2.csv" in out |
| 52 | + |
| 53 | + def test_user_prompt_is_never_mutated(self) -> None: |
| 54 | + prompt = "summarize the sheet" |
| 55 | + out = runs.build_harness_prompt(prompt, [_Upload("x_1.xlsx")]) |
| 56 | + assert out.startswith(prompt) |
| 57 | + assert prompt == "summarize the sheet" |
| 58 | + |
| 59 | + |
| 60 | +class TestSafeFilename: |
| 61 | + @pytest.mark.parametrize( |
| 62 | + ("raw", "expected"), |
| 63 | + [ |
| 64 | + ("sales.xlsx", "sales.xlsx"), |
| 65 | + ("../../etc/passwd", "passwd"), |
| 66 | + ("/abs/path/report.csv", "report.csv"), |
| 67 | + ("we!rd na@me.txt", "we_rd_na_me.txt"), |
| 68 | + ("", "upload"), |
| 69 | + ("...", "upload"), |
| 70 | + ("..", "upload"), |
| 71 | + ], |
| 72 | + ) |
| 73 | + def test_sanitizing(self, raw: str, expected: str) -> None: |
| 74 | + assert files.safe_filename(raw) == expected |
| 75 | + |
| 76 | + def test_name_is_length_capped(self) -> None: |
| 77 | + assert len(files.safe_filename("a" * 500 + ".xlsx")) == 200 |
| 78 | + |
| 79 | + def test_no_result_can_escape_a_directory(self) -> None: |
| 80 | + for raw in ("../../x", "/etc/shadow", "..\\..\\win.ini", "a/b/c"): |
| 81 | + cleaned = files.safe_filename(raw) |
| 82 | + assert "/" not in cleaned and "\\" not in cleaned |
| 83 | + assert cleaned not in ("", ".", "..") |
| 84 | + |
| 85 | + |
| 86 | +class TestDomainErrors: |
| 87 | + """Status codes belong to the error type, so routes need no mapping.""" |
| 88 | + |
| 89 | + @pytest.mark.parametrize( |
| 90 | + ("exc", "code"), |
| 91 | + [ |
| 92 | + (InvalidRequest, 400), |
| 93 | + (Unauthorized, 401), |
| 94 | + (NotFound, 404), |
| 95 | + (Conflict, 409), |
| 96 | + (PayloadTooLarge, 413), |
| 97 | + ], |
| 98 | + ) |
| 99 | + def test_status_codes(self, exc: type[DomainError], code: int) -> None: |
| 100 | + assert exc("nope").status_code == code |
| 101 | + |
| 102 | + def test_detail_is_the_message(self) -> None: |
| 103 | + err = NotFound("conversation not found") |
| 104 | + assert err.detail == "conversation not found" |
| 105 | + assert str(err) == "conversation not found" |
0 commit comments