rommapp_romm/backend/tests/endpoints/test_collection.py
Spinnich 14342b5726 fix(tests): use client.request(\"DELETE\") to send JSON body in tests
Starlette's TestClient (httpx-based) does not expose body kwargs on the
delete() convenience method; client.request(\"DELETE\", ..., json=...) is
the correct approach. Also switch datetime.utcnow() to
datetime.now(timezone.utc) to silence Python 3.13 deprecation warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 21:02:26 -04:00

567 lines
19 KiB
Python

from datetime import datetime, timedelta, timezone
import pytest
from fastapi import status
from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS
from handler.auth import oauth_handler
from handler.database import db_collection_handler, db_rom_handler
from models.collection import Collection
from models.platform import Platform
from models.rom import Rom
from models.user import User
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def collection(admin_user: User) -> Collection:
return db_collection_handler.add_collection(
Collection(
name="Test Collection",
description="A test collection",
is_public=False,
is_favorite=False,
user_id=admin_user.id,
)
)
@pytest.fixture
def favorite_collection(admin_user: User) -> Collection:
return db_collection_handler.add_collection(
Collection(
name="Favorites",
description="",
is_public=False,
is_favorite=True,
user_id=admin_user.id,
)
)
@pytest.fixture
def second_rom(admin_user: User, platform: Platform) -> Rom:
rom = Rom(
platform_id=platform.id,
name="test_rom_2",
slug="test_rom_slug_2",
fs_name="test_rom_2.zip",
fs_name_no_tags="test_rom_2",
fs_name_no_ext="test_rom_2",
fs_extension="zip",
fs_path=f"{platform.slug}/roms",
)
return db_rom_handler.add_rom(rom)
@pytest.fixture
def other_user_token(editor_user: User) -> str:
"""Access token for a second user — used to test ownership checks."""
return oauth_handler.create_access_token(
data={
"sub": editor_user.username,
"iss": "romm:oauth",
"scopes": " ".join(editor_user.oauth_scopes),
},
expires_delta=timedelta(seconds=OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS),
)
@pytest.fixture
def other_user_collection(editor_user: User) -> Collection:
"""A collection owned by the editor user, not the admin user."""
return db_collection_handler.add_collection(
Collection(
name="Editor Collection",
description="",
is_public=False,
is_favorite=False,
user_id=editor_user.id,
)
)
# ---------------------------------------------------------------------------
# Collection CRUD
# ---------------------------------------------------------------------------
class TestCreateCollection:
def test_creates_collection(self, client, access_token: str):
response = client.post(
"/api/collections",
data={"name": "My Games", "description": "Classic games"},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "My Games"
assert data["description"] == "Classic games"
assert data["is_favorite"] is False
assert data["is_public"] is False
assert data["rom_ids"] == []
def test_creates_favorite_collection(self, client, access_token: str):
response = client.post(
"/api/collections",
data={"name": "Favorites"},
params={"is_favorite": True},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["is_favorite"] is True
def test_duplicate_name_returns_conflict(
self, client, access_token: str, collection: Collection
):
response = client.post(
"/api/collections",
data={"name": collection.name},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
def test_requires_auth(self, client):
response = client.post("/api/collections", data={"name": "No Auth"})
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)
class TestGetCollections:
def test_returns_own_collections(
self, client, access_token: str, collection: Collection
):
response = client.get(
"/api/collections",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) == 1
assert data[0]["id"] == collection.id
assert data[0]["name"] == collection.name
def test_returns_public_collections_from_other_users(
self,
client,
access_token: str,
admin_user: User,
editor_user: User,
):
public_col = db_collection_handler.add_collection(
Collection(
name="Public Editor Collection",
description="",
is_public=True,
is_favorite=False,
user_id=editor_user.id,
)
)
db_collection_handler.add_collection(
Collection(
name="Private Editor Collection",
description="",
is_public=False,
is_favorite=False,
user_id=editor_user.id,
)
)
response = client.get(
"/api/collections",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
ids = [c["id"] for c in response.json()]
assert public_col.id in ids
def test_empty_when_no_collections(self, client, access_token: str):
response = client.get(
"/api/collections",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == []
class TestDeleteCollection:
def test_deletes_own_collection(
self, client, access_token: str, collection: Collection
):
response = client.delete(
f"/api/collections/{collection.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert db_collection_handler.get_collection(collection.id) is None
def test_cannot_delete_other_users_collection(
self,
client,
access_token: str,
other_user_collection: Collection,
):
response = client.delete(
f"/api/collections/{other_user_collection.id}",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_returns_404_for_missing_collection(self, client, access_token: str):
response = client.delete(
"/api/collections/999999",
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# ---------------------------------------------------------------------------
# POST /collections/{id}/roms — atomic add
# ---------------------------------------------------------------------------
class TestAddRomsToCollection:
def test_adds_roms_to_empty_collection(
self, client, access_token: str, collection: Collection, rom: Rom
):
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert rom.id in data["rom_ids"]
assert data["rom_count"] == 1
def test_adds_multiple_roms(
self,
client,
access_token: str,
collection: Collection,
rom: Rom,
second_rom: Rom,
):
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id, second_rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert set(data["rom_ids"]) == {rom.id, second_rom.id}
assert data["rom_count"] == 2
def test_is_idempotent_no_duplicates(
self, client, access_token: str, collection: Collection, rom: Rom
):
"""Adding a ROM that is already in the collection should not duplicate it."""
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["rom_ids"].count(rom.id) == 1
assert data["rom_count"] == 1
def test_preserves_existing_roms_when_adding_new(
self,
client,
access_token: str,
collection: Collection,
rom: Rom,
second_rom: Rom,
):
"""Adding a new ROM must not remove previously added ROMs."""
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [second_rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert set(data["rom_ids"]) == {rom.id, second_rom.id}
def test_silently_ignores_nonexistent_rom_ids(
self, client, access_token: str, collection: Collection
):
"""Invalid ROM IDs should be filtered out without raising an error."""
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [999999]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["rom_count"] == 0
def test_returns_404_for_missing_collection(
self, client, access_token: str, rom: Rom
):
response = client.post(
"/api/collections/999999/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_returns_403_for_other_users_collection(
self,
client,
access_token: str,
other_user_collection: Collection,
rom: Rom,
):
response = client.post(
f"/api/collections/{other_user_collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_requires_auth(self, client, collection: Collection, rom: Rom):
response = client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
)
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)
def test_bumps_updated_at(
self, client, access_token: str, collection: Collection, rom: Rom
):
# Record time before call (truncated to seconds to match MariaDB precision)
before_call = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None)
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
refreshed = db_collection_handler.get_collection(collection.id)
assert refreshed is not None
assert refreshed.updated_at.replace(tzinfo=None) >= before_call
# ---------------------------------------------------------------------------
# DELETE /collections/{id}/roms — atomic remove
# ---------------------------------------------------------------------------
class TestRemoveRomsFromCollection:
def _seed(self, client, access_token, collection_id, rom_ids):
"""Helper: add ROMs to a collection before testing removal."""
client.post(
f"/api/collections/{collection_id}/roms",
json={"rom_ids": rom_ids},
headers={"Authorization": f"Bearer {access_token}"},
)
def test_removes_rom_from_collection(
self, client, access_token: str, collection: Collection, rom: Rom
):
self._seed(client, access_token, collection.id, [rom.id])
response = client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert rom.id not in data["rom_ids"]
assert data["rom_count"] == 0
def test_removes_only_specified_roms(
self,
client,
access_token: str,
collection: Collection,
rom: Rom,
second_rom: Rom,
):
self._seed(client, access_token, collection.id, [rom.id, second_rom.id])
response = client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert rom.id not in data["rom_ids"]
assert second_rom.id in data["rom_ids"]
assert data["rom_count"] == 1
def test_removing_absent_rom_is_a_noop(
self, client, access_token: str, collection: Collection, rom: Rom
):
"""Removing a ROM that isn't in the collection should not raise an error."""
response = client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["rom_count"] == 0
def test_returns_404_for_missing_collection(
self, client, access_token: str, rom: Rom
):
response = client.request(
"DELETE",
"/api/collections/999999/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_returns_403_for_other_users_collection(
self,
client,
access_token: str,
other_user_collection: Collection,
rom: Rom,
):
response = client.request(
"DELETE",
f"/api/collections/{other_user_collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_requires_auth(self, client, collection: Collection, rom: Rom):
response = client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
)
assert response.status_code in (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
)
def test_bumps_updated_at(
self, client, access_token: str, collection: Collection, rom: Rom
):
self._seed(client, access_token, collection.id, [rom.id])
# Record time before the remove call (truncated to seconds for MariaDB precision)
before_remove = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None)
client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
refreshed = db_collection_handler.get_collection(collection.id)
assert refreshed is not None
assert refreshed.updated_at.replace(tzinfo=None) >= before_remove
# ---------------------------------------------------------------------------
# Race condition regression: concurrent adds must not lose data
# ---------------------------------------------------------------------------
class TestAtomicBehavior:
def test_sequential_adds_accumulate(
self,
client,
access_token: str,
collection: Collection,
rom: Rom,
second_rom: Rom,
):
"""
Simulates the corrected behavior: two separate add calls, each with a
single ROM, should result in both ROMs being present — even if they
arrive close together. This would previously fail under the full-replace
approach when requests arrived out of order.
"""
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [second_rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
refreshed = db_collection_handler.get_collection(collection.id)
assert refreshed is not None
assert set(refreshed.rom_ids) == {rom.id, second_rom.id}
def test_interleaved_add_remove_stays_consistent(
self,
client,
access_token: str,
collection: Collection,
rom: Rom,
second_rom: Rom,
):
"""
Add both ROMs, remove one, add it back — final state should reflect
only the last operation per ROM.
"""
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id, second_rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.request(
"DELETE",
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
client.post(
f"/api/collections/{collection.id}/roms",
json={"rom_ids": [rom.id]},
headers={"Authorization": f"Bearer {access_token}"},
)
refreshed = db_collection_handler.get_collection(collection.id)
assert refreshed is not None
assert set(refreshed.rom_ids) == {rom.id, second_rom.id}