|
| 1 | +"""Integration tests for the /v1/saved-prompts REST API endpoints.""" |
| 2 | + |
| 3 | +import pytest |
| 4 | +from fastapi import HTTPException, Request, status |
| 5 | +from sqlalchemy.orm import Session |
| 6 | + |
| 7 | +from app.endpoints.saved_prompts import ( |
| 8 | + create_saved_prompts_handler, |
| 9 | + delete_saved_prompts_handler, |
| 10 | + get_saved_prompts_config_handler, |
| 11 | + list_saved_prompts_handler, |
| 12 | +) |
| 13 | +from authentication.interface import AuthTuple |
| 14 | +from configuration import AppConfig |
| 15 | +from models.api.requests import SavedPromptCreateRequest |
| 16 | +from models.api.responses.successful import SavedPromptResponse |
| 17 | +from tests.integration.conftest import ( |
| 18 | + TEST_NON_EXISTENT_ID, |
| 19 | + TEST_OTHER_USER_ID, |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +@pytest.fixture(name="other_auth") |
| 24 | +def other_auth_fixture() -> AuthTuple: |
| 25 | + """Auth tuple for a different user than noop default auth.""" |
| 26 | + return (TEST_OTHER_USER_ID, "other-user", True, "test_token") |
| 27 | + |
| 28 | + |
| 29 | +async def create_prompt_via_handler( |
| 30 | + request: Request, |
| 31 | + auth: AuthTuple, |
| 32 | + name: str, |
| 33 | + content: str, |
| 34 | +) -> SavedPromptResponse: |
| 35 | + """Create a saved prompt through the real create handler. |
| 36 | +
|
| 37 | + Parameters: |
| 38 | + request: FastAPI request for authorization middleware. |
| 39 | + auth: Authenticated user tuple. |
| 40 | + name: Prompt display name. |
| 41 | + content: Prompt body. |
| 42 | +
|
| 43 | + Returns: |
| 44 | + SavedPromptResponse from the create handler. |
| 45 | + """ |
| 46 | + return await create_saved_prompts_handler( |
| 47 | + request=request, |
| 48 | + body=SavedPromptCreateRequest(name=name, content=content), |
| 49 | + auth=auth, |
| 50 | + ) |
| 51 | + |
| 52 | + |
| 53 | +@pytest.mark.asyncio |
| 54 | +async def test_get_saved_prompts_config_returns_limits( |
| 55 | + test_config: AppConfig, |
| 56 | + test_request: Request, |
| 57 | + test_auth: AuthTuple, |
| 58 | +) -> None: |
| 59 | + """Config endpoint returns saved-prompts limits from loaded configuration.""" |
| 60 | + expected = test_config.configuration.saved_prompts |
| 61 | + |
| 62 | + response = await get_saved_prompts_config_handler( |
| 63 | + auth=test_auth, |
| 64 | + request=test_request, |
| 65 | + ) |
| 66 | + |
| 67 | + assert response.max_prompts_per_user == expected.max_prompts_per_user |
| 68 | + assert response.max_display_name_length == expected.max_display_name_length |
| 69 | + assert response.max_content_length == expected.max_content_length |
| 70 | + |
| 71 | + |
| 72 | +@pytest.mark.asyncio |
| 73 | +async def test_list_saved_prompts_empty_for_new_user( |
| 74 | + test_config: AppConfig, |
| 75 | + test_request: Request, |
| 76 | + test_auth: AuthTuple, |
| 77 | + patch_db_session: Session, |
| 78 | +) -> None: |
| 79 | + """List returns an empty prompts array when the user has no saved prompts.""" |
| 80 | + _ = test_config |
| 81 | + _ = patch_db_session |
| 82 | + |
| 83 | + response = await list_saved_prompts_handler( |
| 84 | + auth=test_auth, |
| 85 | + request=test_request, |
| 86 | + ) |
| 87 | + |
| 88 | + assert response.prompts == [] |
| 89 | + |
| 90 | + |
| 91 | +@pytest.mark.asyncio |
| 92 | +async def test_create_saved_prompt_persists_and_is_listable( |
| 93 | + test_config: AppConfig, |
| 94 | + test_request: Request, |
| 95 | + test_auth: AuthTuple, |
| 96 | + patch_db_session: Session, |
| 97 | +) -> None: |
| 98 | + """Create returns prompt fields and the owning user can list it.""" |
| 99 | + _ = test_config |
| 100 | + _ = patch_db_session |
| 101 | + |
| 102 | + created = await create_prompt_via_handler( |
| 103 | + request=test_request, |
| 104 | + auth=test_auth, |
| 105 | + name="Deploy to staging", |
| 106 | + content="Help me write a deployment checklist", |
| 107 | + ) |
| 108 | + |
| 109 | + assert created.id |
| 110 | + assert created.name == "Deploy to staging" |
| 111 | + assert created.content == "Help me write a deployment checklist" |
| 112 | + assert created.created_at is not None |
| 113 | + assert created.updated_at is not None |
| 114 | + |
| 115 | + listed = await list_saved_prompts_handler( |
| 116 | + auth=test_auth, |
| 117 | + request=test_request, |
| 118 | + ) |
| 119 | + assert len(listed.prompts) == 1 |
| 120 | + assert listed.prompts[0].id == created.id |
| 121 | + assert listed.prompts[0].name == "Deploy to staging" |
| 122 | + |
| 123 | + |
| 124 | +@pytest.mark.asyncio |
| 125 | +async def test_list_saved_prompts_isolates_users( |
| 126 | + test_config: AppConfig, |
| 127 | + test_request: Request, |
| 128 | + test_auth: AuthTuple, |
| 129 | + other_auth: AuthTuple, |
| 130 | + patch_db_session: Session, |
| 131 | +) -> None: |
| 132 | + """List returns only the caller's prompts.""" |
| 133 | + _ = test_config |
| 134 | + _ = patch_db_session |
| 135 | + |
| 136 | + owned = await create_prompt_via_handler( |
| 137 | + request=test_request, |
| 138 | + auth=test_auth, |
| 139 | + name="owned-prompt", |
| 140 | + content="owned body", |
| 141 | + ) |
| 142 | + other = await create_prompt_via_handler( |
| 143 | + request=test_request, |
| 144 | + auth=other_auth, |
| 145 | + name="other-user-prompt", |
| 146 | + content="should not appear", |
| 147 | + ) |
| 148 | + |
| 149 | + listed = await list_saved_prompts_handler( |
| 150 | + auth=test_auth, |
| 151 | + request=test_request, |
| 152 | + ) |
| 153 | + |
| 154 | + ids = [p.id for p in listed.prompts] |
| 155 | + assert owned.id in ids |
| 156 | + assert other.id not in ids |
| 157 | + |
| 158 | + |
| 159 | +@pytest.mark.asyncio |
| 160 | +async def test_create_saved_prompt_returns_422_when_limit_exceeded( |
| 161 | + test_config: AppConfig, |
| 162 | + test_request: Request, |
| 163 | + test_auth: AuthTuple, |
| 164 | + other_auth: AuthTuple, |
| 165 | + patch_db_session: Session, |
| 166 | +) -> None: |
| 167 | + """Create returns 422 after the configured per-user maximum is reached.""" |
| 168 | + _ = patch_db_session |
| 169 | + test_config.configuration.saved_prompts.max_prompts_per_user = 1 |
| 170 | + |
| 171 | + await create_prompt_via_handler( |
| 172 | + request=test_request, |
| 173 | + auth=test_auth, |
| 174 | + name="one", |
| 175 | + content="body one", |
| 176 | + ) |
| 177 | + |
| 178 | + other_created = await create_prompt_via_handler( |
| 179 | + request=test_request, |
| 180 | + auth=other_auth, |
| 181 | + name="other-user-one", |
| 182 | + content="other user body", |
| 183 | + ) |
| 184 | + assert other_created.id |
| 185 | + |
| 186 | + with pytest.raises(HTTPException) as exc_info: |
| 187 | + await create_prompt_via_handler( |
| 188 | + request=test_request, |
| 189 | + auth=test_auth, |
| 190 | + name="two", |
| 191 | + content="body two", |
| 192 | + ) |
| 193 | + |
| 194 | + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT |
| 195 | + |
| 196 | + |
| 197 | +@pytest.mark.asyncio |
| 198 | +async def test_delete_own_saved_prompt_removes_it_from_list( |
| 199 | + test_config: AppConfig, |
| 200 | + test_request: Request, |
| 201 | + test_auth: AuthTuple, |
| 202 | + patch_db_session: Session, |
| 203 | +) -> None: |
| 204 | + """Deleting an owned prompt returns deleted=True and removes it from list.""" |
| 205 | + _ = test_config |
| 206 | + _ = patch_db_session |
| 207 | + |
| 208 | + created = await create_prompt_via_handler( |
| 209 | + request=test_request, |
| 210 | + auth=test_auth, |
| 211 | + name="to-delete", |
| 212 | + content="temporary", |
| 213 | + ) |
| 214 | + |
| 215 | + deleted = await delete_saved_prompts_handler( |
| 216 | + request=test_request, |
| 217 | + prompt_id=created.id, |
| 218 | + auth=test_auth, |
| 219 | + ) |
| 220 | + assert deleted.deleted is True |
| 221 | + assert deleted.prompt_id == created.id |
| 222 | + |
| 223 | + listed = await list_saved_prompts_handler( |
| 224 | + auth=test_auth, |
| 225 | + request=test_request, |
| 226 | + ) |
| 227 | + assert listed.prompts == [] |
| 228 | + |
| 229 | + |
| 230 | +@pytest.mark.asyncio |
| 231 | +async def test_delete_missing_saved_prompt_returns_deleted_false( |
| 232 | + test_config: AppConfig, |
| 233 | + test_request: Request, |
| 234 | + test_auth: AuthTuple, |
| 235 | + patch_db_session: Session, |
| 236 | +) -> None: |
| 237 | + """Deleting a non-existent valid id returns deleted=False (idempotent).""" |
| 238 | + _ = test_config |
| 239 | + _ = patch_db_session |
| 240 | + |
| 241 | + deleted = await delete_saved_prompts_handler( |
| 242 | + request=test_request, |
| 243 | + prompt_id=TEST_NON_EXISTENT_ID, |
| 244 | + auth=test_auth, |
| 245 | + ) |
| 246 | + |
| 247 | + assert deleted.deleted is False |
| 248 | + assert deleted.prompt_id == TEST_NON_EXISTENT_ID |
| 249 | + |
| 250 | + |
| 251 | +@pytest.mark.asyncio |
| 252 | +async def test_delete_other_users_saved_prompt_returns_403( |
| 253 | + test_config: AppConfig, |
| 254 | + test_request: Request, |
| 255 | + test_auth: AuthTuple, |
| 256 | + other_auth: AuthTuple, |
| 257 | + patch_db_session: Session, |
| 258 | +) -> None: |
| 259 | + """Deleting another user's prompt raises HTTP 403.""" |
| 260 | + _ = test_config |
| 261 | + _ = patch_db_session |
| 262 | + |
| 263 | + other_prompt = await create_prompt_via_handler( |
| 264 | + request=test_request, |
| 265 | + auth=other_auth, |
| 266 | + name="owned-by-other", |
| 267 | + content="secret", |
| 268 | + ) |
| 269 | + |
| 270 | + with pytest.raises(HTTPException) as exc_info: |
| 271 | + await delete_saved_prompts_handler( |
| 272 | + request=test_request, |
| 273 | + prompt_id=other_prompt.id, |
| 274 | + auth=test_auth, |
| 275 | + ) |
| 276 | + |
| 277 | + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN |
| 278 | + |
| 279 | + remaining = await list_saved_prompts_handler( |
| 280 | + auth=other_auth, |
| 281 | + request=test_request, |
| 282 | + ) |
| 283 | + assert any(prompt.id == other_prompt.id for prompt in remaining.prompts) |
0 commit comments