mirror of
https://github.com/rommapp/romm.git
synced 2026-05-04 00:01:30 +08:00
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>
567 lines
19 KiB
Python
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}
|