test_musehub_ui_stash.py
python
| 1 | """Tests for MuseHub stash UI endpoints. |
| 2 | |
| 3 | Covers GET /{owner}/{repo_slug}/stash: |
| 4 | - test_stash_list_page_auth_required — unauthenticated GET → 401 |
| 5 | - test_stash_list_page_returns_200_with_token — authenticated GET → 200 HTML |
| 6 | - test_stash_list_page_shows_ref_labels — HTML includes stash@{0} refs |
| 7 | - test_stash_list_page_action_buttons_present — Apply / Pop / Drop buttons present |
| 8 | - test_stash_list_page_drop_confirm_present — Drop form has hx-confirm attribute |
| 9 | - test_stash_list_page_json_response — ?format=json returns JSON with stashes key |
| 10 | - test_stash_list_page_json_fields — JSON stash items have required fields |
| 11 | - test_stash_list_page_empty_stash — empty stash returns 200 with 0 total |
| 12 | - test_stash_list_unknown_repo_404 — unknown owner/slug → 404 |
| 13 | - test_stash_list_isolates_by_user — only caller's stash is shown |
| 14 | - test_stash_list_pagination_query_params — page/page_size accepted without error |
| 15 | |
| 16 | Covers POST /{owner}/{repo_slug}/stash/{stash_ref}/apply: |
| 17 | - test_stash_apply_auth_required — unauthenticated POST → 401 |
| 18 | - test_stash_apply_redirects_to_stash_list — authenticated POST → 303 redirect |
| 19 | - test_stash_apply_preserves_stash_entry — stash entry still exists after apply |
| 20 | |
| 21 | Covers POST /{owner}/{repo_slug}/stash/{stash_ref}/pop: |
| 22 | - test_stash_pop_auth_required — unauthenticated POST → 401 |
| 23 | - test_stash_pop_redirects_to_stash_list — authenticated POST → 303 redirect |
| 24 | - test_stash_pop_deletes_stash_entry — stash entry removed after pop |
| 25 | |
| 26 | Covers POST /{owner}/{repo_slug}/stash/{stash_ref}/drop: |
| 27 | - test_stash_drop_auth_required — unauthenticated POST → 401 |
| 28 | - test_stash_drop_redirects_to_stash_list — authenticated POST → 303 redirect |
| 29 | - test_stash_drop_deletes_stash_entry — stash entry removed after drop |
| 30 | - test_stash_drop_wrong_user_404 — another user's stash → 404 |
| 31 | """ |
| 32 | from __future__ import annotations |
| 33 | |
| 34 | import uuid |
| 35 | |
| 36 | import pytest |
| 37 | from httpx import AsyncClient |
| 38 | from sqlalchemy import text |
| 39 | from sqlalchemy.ext.asyncio import AsyncSession |
| 40 | |
| 41 | from musehub.db.musehub_models import MusehubRepo |
| 42 | from musehub.db.musehub_stash_models import MusehubStash, MusehubStashEntry |
| 43 | |
| 44 | _OWNER = "artist" |
| 45 | _SLUG = "album-one" |
| 46 | _USER_ID = "550e8400-e29b-41d4-a716-446655440000" # matches test_user fixture |
| 47 | |
| 48 | |
| 49 | # --------------------------------------------------------------------------- |
| 50 | # Helpers |
| 51 | # --------------------------------------------------------------------------- |
| 52 | |
| 53 | |
| 54 | async def _make_repo(db: AsyncSession, owner: str = _OWNER, slug: str = _SLUG) -> str: |
| 55 | """Seed a public repo and return its repo_id string.""" |
| 56 | repo = MusehubRepo( |
| 57 | name=slug, |
| 58 | owner=owner, |
| 59 | slug=slug, |
| 60 | visibility="public", |
| 61 | owner_user_id=_USER_ID, |
| 62 | ) |
| 63 | db.add(repo) |
| 64 | await db.commit() |
| 65 | await db.refresh(repo) |
| 66 | return str(repo.repo_id) |
| 67 | |
| 68 | |
| 69 | async def _make_stash( |
| 70 | db: AsyncSession, |
| 71 | repo_id: str, |
| 72 | *, |
| 73 | user_id: str = _USER_ID, |
| 74 | branch: str = "main", |
| 75 | message: str | None = "WIP: brass arrangement", |
| 76 | num_entries: int = 2, |
| 77 | ) -> MusehubStash: |
| 78 | """Seed a stash entry with ``num_entries`` file entries and return it.""" |
| 79 | stash = MusehubStash( |
| 80 | repo_id=repo_id, |
| 81 | user_id=user_id, |
| 82 | branch=branch, |
| 83 | message=message, |
| 84 | ) |
| 85 | db.add(stash) |
| 86 | await db.flush() |
| 87 | |
| 88 | for i in range(num_entries): |
| 89 | entry = MusehubStashEntry( |
| 90 | stash_id=stash.id, |
| 91 | path=f"tracks/track_{i}.mid", |
| 92 | object_id=f"sha256:{'a' * 64}", |
| 93 | position=i, |
| 94 | ) |
| 95 | db.add(entry) |
| 96 | |
| 97 | await db.commit() |
| 98 | await db.refresh(stash) |
| 99 | return stash |
| 100 | |
| 101 | |
| 102 | # --------------------------------------------------------------------------- |
| 103 | # GET — stash list page |
| 104 | # --------------------------------------------------------------------------- |
| 105 | |
| 106 | |
| 107 | @pytest.mark.anyio |
| 108 | async def test_stash_list_page_auth_required( |
| 109 | client: AsyncClient, |
| 110 | db_session: AsyncSession, |
| 111 | ) -> None: |
| 112 | """Unauthenticated GET returns 401 — stash is always private.""" |
| 113 | await _make_repo(db_session) |
| 114 | response = await client.get(f"/{_OWNER}/{_SLUG}/stash") |
| 115 | assert response.status_code == 401 |
| 116 | |
| 117 | |
| 118 | @pytest.mark.anyio |
| 119 | async def test_stash_list_page_returns_200_with_token( |
| 120 | client: AsyncClient, |
| 121 | db_session: AsyncSession, |
| 122 | auth_headers: dict[str, str], |
| 123 | test_user: object, |
| 124 | ) -> None: |
| 125 | """Authenticated GET returns 200 HTML.""" |
| 126 | await _make_repo(db_session) |
| 127 | response = await client.get( |
| 128 | f"/{_OWNER}/{_SLUG}/stash", |
| 129 | headers=auth_headers, |
| 130 | ) |
| 131 | assert response.status_code == 200 |
| 132 | assert "text/html" in response.headers["content-type"] |
| 133 | |
| 134 | |
| 135 | @pytest.mark.anyio |
| 136 | async def test_stash_list_page_shows_ref_labels( |
| 137 | client: AsyncClient, |
| 138 | db_session: AsyncSession, |
| 139 | auth_headers: dict[str, str], |
| 140 | test_user: object, |
| 141 | ) -> None: |
| 142 | """HTML page contains stash@{0} ref label for the first stash entry.""" |
| 143 | repo_id = await _make_repo(db_session) |
| 144 | await _make_stash(db_session, repo_id) |
| 145 | response = await client.get( |
| 146 | f"/{_OWNER}/{_SLUG}/stash", |
| 147 | headers=auth_headers, |
| 148 | ) |
| 149 | assert response.status_code == 200 |
| 150 | assert "stash@{0}" in response.text |
| 151 | |
| 152 | |
| 153 | @pytest.mark.anyio |
| 154 | async def test_stash_list_page_action_buttons_present( |
| 155 | client: AsyncClient, |
| 156 | db_session: AsyncSession, |
| 157 | auth_headers: dict[str, str], |
| 158 | test_user: object, |
| 159 | ) -> None: |
| 160 | """HTML page exposes Apply, Pop, and Drop action buttons.""" |
| 161 | repo_id = await _make_repo(db_session) |
| 162 | await _make_stash(db_session, repo_id) |
| 163 | response = await client.get( |
| 164 | f"/{_OWNER}/{_SLUG}/stash", |
| 165 | headers=auth_headers, |
| 166 | ) |
| 167 | assert response.status_code == 200 |
| 168 | body = response.text |
| 169 | assert "Apply" in body |
| 170 | assert "Pop" in body |
| 171 | assert "Drop" in body |
| 172 | |
| 173 | |
| 174 | @pytest.mark.anyio |
| 175 | async def test_stash_list_page_drop_confirm_present( |
| 176 | client: AsyncClient, |
| 177 | db_session: AsyncSession, |
| 178 | auth_headers: dict[str, str], |
| 179 | test_user: object, |
| 180 | ) -> None: |
| 181 | """Drop form uses hx-confirm attribute for HTMX-native confirmation dialog.""" |
| 182 | repo_id = await _make_repo(db_session) |
| 183 | await _make_stash(db_session, repo_id) |
| 184 | response = await client.get( |
| 185 | f"/{_OWNER}/{_SLUG}/stash", |
| 186 | headers=auth_headers, |
| 187 | ) |
| 188 | assert response.status_code == 200 |
| 189 | assert "hx-confirm" in response.text |
| 190 | |
| 191 | |
| 192 | @pytest.mark.anyio |
| 193 | async def test_stash_list_page_json_response( |
| 194 | client: AsyncClient, |
| 195 | db_session: AsyncSession, |
| 196 | auth_headers: dict[str, str], |
| 197 | test_user: object, |
| 198 | ) -> None: |
| 199 | """?format=json returns JSON with HTTP 200.""" |
| 200 | repo_id = await _make_repo(db_session) |
| 201 | await _make_stash(db_session, repo_id) |
| 202 | headers = {**auth_headers, "Content-Type": "application/json"} |
| 203 | response = await client.get( |
| 204 | f"/{_OWNER}/{_SLUG}/stash?format=json", |
| 205 | headers=headers, |
| 206 | ) |
| 207 | assert response.status_code == 200 |
| 208 | assert response.headers["content-type"].startswith("application/json") |
| 209 | |
| 210 | |
| 211 | @pytest.mark.anyio |
| 212 | async def test_stash_list_page_json_fields( |
| 213 | client: AsyncClient, |
| 214 | db_session: AsyncSession, |
| 215 | auth_headers: dict[str, str], |
| 216 | test_user: object, |
| 217 | ) -> None: |
| 218 | """JSON stash items include ref, branch, message, createdAt, entryCount.""" |
| 219 | repo_id = await _make_repo(db_session) |
| 220 | await _make_stash(db_session, repo_id, branch="feat/bass", message="WIP brass", num_entries=3) |
| 221 | response = await client.get( |
| 222 | f"/{_OWNER}/{_SLUG}/stash?format=json", |
| 223 | headers=auth_headers, |
| 224 | ) |
| 225 | assert response.status_code == 200 |
| 226 | data = response.json() |
| 227 | assert "stashes" in data |
| 228 | assert "total" in data |
| 229 | assert data["total"] == 1 |
| 230 | item = data["stashes"][0] |
| 231 | assert item["ref"] == "stash@{0}" |
| 232 | assert item["branch"] == "feat/bass" |
| 233 | assert item["message"] == "WIP brass" |
| 234 | assert item["entryCount"] == 3 |
| 235 | assert "createdAt" in item |
| 236 | |
| 237 | |
| 238 | @pytest.mark.anyio |
| 239 | async def test_stash_list_page_empty_stash( |
| 240 | client: AsyncClient, |
| 241 | db_session: AsyncSession, |
| 242 | auth_headers: dict[str, str], |
| 243 | test_user: object, |
| 244 | ) -> None: |
| 245 | """Stash list page with no entries returns 200 and total=0.""" |
| 246 | await _make_repo(db_session) |
| 247 | response = await client.get( |
| 248 | f"/{_OWNER}/{_SLUG}/stash?format=json", |
| 249 | headers=auth_headers, |
| 250 | ) |
| 251 | assert response.status_code == 200 |
| 252 | data = response.json() |
| 253 | assert data["total"] == 0 |
| 254 | assert data["stashes"] == [] |
| 255 | |
| 256 | |
| 257 | @pytest.mark.anyio |
| 258 | async def test_stash_list_unknown_repo_404( |
| 259 | client: AsyncClient, |
| 260 | db_session: AsyncSession, |
| 261 | auth_headers: dict[str, str], |
| 262 | test_user: object, |
| 263 | ) -> None: |
| 264 | """Unknown owner/slug returns 404.""" |
| 265 | response = await client.get( |
| 266 | "/nobody/nonexistent/stash", |
| 267 | headers=auth_headers, |
| 268 | ) |
| 269 | assert response.status_code == 404 |
| 270 | |
| 271 | |
| 272 | @pytest.mark.anyio |
| 273 | async def test_stash_list_isolates_by_user( |
| 274 | client: AsyncClient, |
| 275 | db_session: AsyncSession, |
| 276 | auth_headers: dict[str, str], |
| 277 | test_user: object, |
| 278 | ) -> None: |
| 279 | """Stash list only returns entries owned by the authenticated user.""" |
| 280 | repo_id = await _make_repo(db_session) |
| 281 | other_user_id = str(uuid.uuid4()) |
| 282 | # Create a stash for a different (non-existent for FK purposes) user using raw SQL |
| 283 | # to avoid FK violation — we only care about the scoping logic, not DB integrity here. |
| 284 | await db_session.execute( |
| 285 | text( |
| 286 | "INSERT INTO musehub_stash (id, repo_id, user_id, branch, message, is_applied, created_at) " |
| 287 | "VALUES (:id, :repo_id, :user_id, :branch, :message, false, datetime('now'))" |
| 288 | ), |
| 289 | { |
| 290 | "id": str(uuid.uuid4()), |
| 291 | "repo_id": repo_id, |
| 292 | "user_id": other_user_id, |
| 293 | "branch": "main", |
| 294 | "message": "someone else's stash", |
| 295 | }, |
| 296 | ) |
| 297 | await db_session.commit() |
| 298 | |
| 299 | response = await client.get( |
| 300 | f"/{_OWNER}/{_SLUG}/stash?format=json", |
| 301 | headers=auth_headers, |
| 302 | ) |
| 303 | assert response.status_code == 200 |
| 304 | data = response.json() |
| 305 | # Our test user has no stash entries — the other user's stash is invisible |
| 306 | assert data["total"] == 0 |
| 307 | |
| 308 | |
| 309 | @pytest.mark.anyio |
| 310 | async def test_stash_list_pagination_query_params( |
| 311 | client: AsyncClient, |
| 312 | db_session: AsyncSession, |
| 313 | auth_headers: dict[str, str], |
| 314 | test_user: object, |
| 315 | ) -> None: |
| 316 | """page and page_size query params are accepted without error.""" |
| 317 | await _make_repo(db_session) |
| 318 | response = await client.get( |
| 319 | f"/{_OWNER}/{_SLUG}/stash?page=1&page_size=10", |
| 320 | headers=auth_headers, |
| 321 | ) |
| 322 | assert response.status_code == 200 |
| 323 | |
| 324 | |
| 325 | # --------------------------------------------------------------------------- |
| 326 | # POST — apply stash |
| 327 | # --------------------------------------------------------------------------- |
| 328 | |
| 329 | |
| 330 | @pytest.mark.anyio |
| 331 | async def test_stash_apply_auth_required( |
| 332 | client: AsyncClient, |
| 333 | db_session: AsyncSession, |
| 334 | ) -> None: |
| 335 | """Unauthenticated POST to /apply returns 401.""" |
| 336 | repo_id = await _make_repo(db_session) |
| 337 | stash = await _make_stash(db_session, repo_id) |
| 338 | response = await client.post( |
| 339 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/apply", |
| 340 | follow_redirects=False, |
| 341 | ) |
| 342 | assert response.status_code == 401 |
| 343 | |
| 344 | |
| 345 | @pytest.mark.anyio |
| 346 | async def test_stash_apply_redirects_to_stash_list( |
| 347 | client: AsyncClient, |
| 348 | db_session: AsyncSession, |
| 349 | auth_headers: dict[str, str], |
| 350 | test_user: object, |
| 351 | ) -> None: |
| 352 | """Authenticated POST to /apply returns 303 redirect to the stash list.""" |
| 353 | repo_id = await _make_repo(db_session) |
| 354 | stash = await _make_stash(db_session, repo_id) |
| 355 | response = await client.post( |
| 356 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/apply", |
| 357 | headers=auth_headers, |
| 358 | follow_redirects=False, |
| 359 | ) |
| 360 | assert response.status_code == 303 |
| 361 | assert response.headers["location"] == f"/{_OWNER}/{_SLUG}/stash" |
| 362 | |
| 363 | |
| 364 | @pytest.mark.anyio |
| 365 | async def test_stash_apply_preserves_stash_entry( |
| 366 | client: AsyncClient, |
| 367 | db_session: AsyncSession, |
| 368 | auth_headers: dict[str, str], |
| 369 | test_user: object, |
| 370 | ) -> None: |
| 371 | """Apply does NOT delete the stash entry — it stays on the stack.""" |
| 372 | repo_id = await _make_repo(db_session) |
| 373 | stash = await _make_stash(db_session, repo_id) |
| 374 | stash_id = stash.id |
| 375 | |
| 376 | await client.post( |
| 377 | f"/{_OWNER}/{_SLUG}/stash/{stash_id}/apply", |
| 378 | headers=auth_headers, |
| 379 | follow_redirects=False, |
| 380 | ) |
| 381 | |
| 382 | # Verify the stash entry still exists |
| 383 | result = await db_session.execute( |
| 384 | text("SELECT id FROM musehub_stash WHERE id = :id"), |
| 385 | {"id": stash_id}, |
| 386 | ) |
| 387 | row = result.mappings().first() |
| 388 | assert row is not None, "Stash entry should NOT be deleted after apply" |
| 389 | |
| 390 | |
| 391 | # --------------------------------------------------------------------------- |
| 392 | # POST — pop stash |
| 393 | # --------------------------------------------------------------------------- |
| 394 | |
| 395 | |
| 396 | @pytest.mark.anyio |
| 397 | async def test_stash_pop_auth_required( |
| 398 | client: AsyncClient, |
| 399 | db_session: AsyncSession, |
| 400 | ) -> None: |
| 401 | """Unauthenticated POST to /pop returns 401.""" |
| 402 | repo_id = await _make_repo(db_session) |
| 403 | stash = await _make_stash(db_session, repo_id) |
| 404 | response = await client.post( |
| 405 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/pop", |
| 406 | follow_redirects=False, |
| 407 | ) |
| 408 | assert response.status_code == 401 |
| 409 | |
| 410 | |
| 411 | @pytest.mark.anyio |
| 412 | async def test_stash_pop_redirects_to_stash_list( |
| 413 | client: AsyncClient, |
| 414 | db_session: AsyncSession, |
| 415 | auth_headers: dict[str, str], |
| 416 | test_user: object, |
| 417 | ) -> None: |
| 418 | """Authenticated POST to /pop returns 303 redirect to the stash list.""" |
| 419 | repo_id = await _make_repo(db_session) |
| 420 | stash = await _make_stash(db_session, repo_id) |
| 421 | response = await client.post( |
| 422 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/pop", |
| 423 | headers=auth_headers, |
| 424 | follow_redirects=False, |
| 425 | ) |
| 426 | assert response.status_code == 303 |
| 427 | assert response.headers["location"] == f"/{_OWNER}/{_SLUG}/stash" |
| 428 | |
| 429 | |
| 430 | @pytest.mark.anyio |
| 431 | async def test_stash_pop_deletes_stash_entry( |
| 432 | client: AsyncClient, |
| 433 | db_session: AsyncSession, |
| 434 | auth_headers: dict[str, str], |
| 435 | test_user: object, |
| 436 | ) -> None: |
| 437 | """Pop deletes the stash entry from the stack.""" |
| 438 | repo_id = await _make_repo(db_session) |
| 439 | stash = await _make_stash(db_session, repo_id) |
| 440 | stash_id = stash.id |
| 441 | |
| 442 | await client.post( |
| 443 | f"/{_OWNER}/{_SLUG}/stash/{stash_id}/pop", |
| 444 | headers=auth_headers, |
| 445 | follow_redirects=False, |
| 446 | ) |
| 447 | |
| 448 | # Verify the stash entry is gone |
| 449 | db_session.expire_all() |
| 450 | result = await db_session.execute( |
| 451 | text("SELECT id FROM musehub_stash WHERE id = :id"), |
| 452 | {"id": stash_id}, |
| 453 | ) |
| 454 | row = result.mappings().first() |
| 455 | assert row is None, "Stash entry SHOULD be deleted after pop" |
| 456 | |
| 457 | |
| 458 | # --------------------------------------------------------------------------- |
| 459 | # POST — drop stash |
| 460 | # --------------------------------------------------------------------------- |
| 461 | |
| 462 | |
| 463 | @pytest.mark.anyio |
| 464 | async def test_stash_drop_auth_required( |
| 465 | client: AsyncClient, |
| 466 | db_session: AsyncSession, |
| 467 | ) -> None: |
| 468 | """Unauthenticated POST to /drop returns 401.""" |
| 469 | repo_id = await _make_repo(db_session) |
| 470 | stash = await _make_stash(db_session, repo_id) |
| 471 | response = await client.post( |
| 472 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/drop", |
| 473 | follow_redirects=False, |
| 474 | ) |
| 475 | assert response.status_code == 401 |
| 476 | |
| 477 | |
| 478 | @pytest.mark.anyio |
| 479 | async def test_stash_drop_redirects_to_stash_list( |
| 480 | client: AsyncClient, |
| 481 | db_session: AsyncSession, |
| 482 | auth_headers: dict[str, str], |
| 483 | test_user: object, |
| 484 | ) -> None: |
| 485 | """Authenticated POST to /drop returns 303 redirect to the stash list.""" |
| 486 | repo_id = await _make_repo(db_session) |
| 487 | stash = await _make_stash(db_session, repo_id) |
| 488 | response = await client.post( |
| 489 | f"/{_OWNER}/{_SLUG}/stash/{stash.id}/drop", |
| 490 | headers=auth_headers, |
| 491 | follow_redirects=False, |
| 492 | ) |
| 493 | assert response.status_code == 303 |
| 494 | assert response.headers["location"] == f"/{_OWNER}/{_SLUG}/stash" |
| 495 | |
| 496 | |
| 497 | @pytest.mark.anyio |
| 498 | async def test_stash_drop_deletes_stash_entry( |
| 499 | client: AsyncClient, |
| 500 | db_session: AsyncSession, |
| 501 | auth_headers: dict[str, str], |
| 502 | test_user: object, |
| 503 | ) -> None: |
| 504 | """Drop permanently deletes the stash entry without applying it.""" |
| 505 | repo_id = await _make_repo(db_session) |
| 506 | stash = await _make_stash(db_session, repo_id) |
| 507 | stash_id = stash.id |
| 508 | |
| 509 | await client.post( |
| 510 | f"/{_OWNER}/{_SLUG}/stash/{stash_id}/drop", |
| 511 | headers=auth_headers, |
| 512 | follow_redirects=False, |
| 513 | ) |
| 514 | |
| 515 | db_session.expire_all() |
| 516 | result = await db_session.execute( |
| 517 | text("SELECT id FROM musehub_stash WHERE id = :id"), |
| 518 | {"id": stash_id}, |
| 519 | ) |
| 520 | row = result.mappings().first() |
| 521 | assert row is None, "Stash entry SHOULD be deleted after drop" |
| 522 | |
| 523 | |
| 524 | @pytest.mark.anyio |
| 525 | async def test_stash_drop_wrong_user_404( |
| 526 | client: AsyncClient, |
| 527 | db_session: AsyncSession, |
| 528 | auth_headers: dict[str, str], |
| 529 | test_user: object, |
| 530 | ) -> None: |
| 531 | """Attempting to drop another user's stash returns 404.""" |
| 532 | repo_id = await _make_repo(db_session) |
| 533 | other_stash_id = str(uuid.uuid4()) |
| 534 | other_user_id = str(uuid.uuid4()) |
| 535 | |
| 536 | # Insert a stash owned by a different user using raw SQL |
| 537 | await db_session.execute( |
| 538 | text( |
| 539 | "INSERT INTO musehub_stash (id, repo_id, user_id, branch, message, is_applied, created_at) " |
| 540 | "VALUES (:id, :repo_id, :user_id, :branch, :message, false, datetime('now'))" |
| 541 | ), |
| 542 | { |
| 543 | "id": other_stash_id, |
| 544 | "repo_id": repo_id, |
| 545 | "user_id": other_user_id, |
| 546 | "branch": "main", |
| 547 | "message": "other user stash", |
| 548 | }, |
| 549 | ) |
| 550 | await db_session.commit() |
| 551 | |
| 552 | response = await client.post( |
| 553 | f"/{_OWNER}/{_SLUG}/stash/{other_stash_id}/drop", |
| 554 | headers=auth_headers, |
| 555 | follow_redirects=False, |
| 556 | ) |
| 557 | assert response.status_code == 404 |