From 13b801b0389a6ad4d47d148b5d556767a26019bf Mon Sep 17 00:00:00 2001 From: Bee mines Date: Wed, 30 Sep 2026 16:00:41 +0800 Subject: [PATCH 1/5] feat: allow paired clients to query sessions across devices --- backend/endpoints/play_sessions.py | 10 ++- backend/tests/endpoints/test_play_sessions.py | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/backend/endpoints/play_sessions.py b/backend/endpoints/play_sessions.py index f833803df..cda121ecd 100644 --- a/backend/endpoints/play_sessions.py +++ b/backend/endpoints/play_sessions.py @@ -1,6 +1,6 @@ from datetime import datetime -from fastapi import HTTPException, Request, status +from fastapi import HTTPException, Query, Request, status from pydantic import BaseModel, Field, model_validator from decorators.auth import protected_route @@ -107,8 +107,14 @@ def get_play_sessions( end_before: datetime | None = None, limit: int = 50, offset: int = 0, + all_devices: bool = Query( + default=False, + description="Include this user's sessions from all devices unless device_id is provided.", + ), ) -> list[PlaySessionSchema]: - effective_device_id = device_id or token_device_id(request) + effective_device_id = device_id or ( + None if all_devices else token_device_id(request) + ) sessions = db_play_session_handler.get_sessions( user_id=request.user.id, rom_id=rom_id, diff --git a/backend/tests/endpoints/test_play_sessions.py b/backend/tests/endpoints/test_play_sessions.py index e40db45f3..a17cd8f5f 100644 --- a/backend/tests/endpoints/test_play_sessions.py +++ b/backend/tests/endpoints/test_play_sessions.py @@ -20,6 +20,7 @@ from handler.database.base_handler import sync_session from models.device import Device from models.permission import HiddenEntity, PermEntity from models.platform import Platform +from models.play_session import PlaySession from models.rom import Rom, RomUserStatus from models.user import User from utils.datetime import to_utc @@ -672,6 +673,71 @@ class TestPlaySessionRomUserUpdates: class TestPlaySessionQuery: + @pytest.mark.parametrize("all_devices", [False, True]) + def test_paired_client_all_devices( + self, + client, + admin_user, + editor_user, + device, + rom, + add_device_token, + all_devices, + ): + other_device = db_device_handler.add_device( + Device(id=str(uuid.uuid4()), user_id=admin_user.id, name="Other device") + ) + now = datetime.now(timezone.utc).replace(microsecond=0) + sessions = db_play_session_handler.add_sessions( + [ + PlaySession( + user_id=user_id, + device_id=device_id, + rom_id=rom.id, + start_time=now - timedelta(hours=index + 1), + end_time=now - timedelta(hours=index + 1, minutes=-30), + duration_ms=1800000, + ) + for index, (user_id, device_id) in enumerate( + [ + (admin_user.id, device.id), + (admin_user.id, other_device.id), + (admin_user.id, None), + (editor_user.id, None), + ] + ) + ] + ) + _, raw = add_device_token(admin_user, device.id, scopes="roms.user.read") + headers = {"Authorization": f"Bearer {raw}"} + response = client.get( + "/api/play-sessions", + params={"rom_id": rom.id, "all_devices": all_devices}, + headers=headers, + ) + assert response.status_code == status.HTTP_200_OK + expected = sessions[:3] if all_devices else sessions[:1] + assert {s["id"] for s in response.json()} == {s.id for s in expected} + + # An explicit device filter still narrows the current user's results. + response = client.get( + "/api/play-sessions", + params={"device_id": other_device.id, "all_devices": all_devices}, + headers=headers, + ) + assert response.status_code == status.HTTP_200_OK + assert [s["id"] for s in response.json()] == [sessions[1].id] + + def test_all_devices_requires_read_scope( + self, client, admin_user, device, add_device_token + ): + _, raw = add_device_token(admin_user, device.id, scopes="devices.read") + response = client.get( + "/api/play-sessions?all_devices=true", + headers={"Authorization": f"Bearer {raw}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + def test_filter_by_rom_id( self, client, access_token: str, rom: Rom, platform: Platform ): From 2ccfde2f92808b34b1bf9d49441a34b898e69849 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 30 Sep 2026 15:34:16 -0400 Subject: [PATCH 2/5] pr-ready: code-review fixes Bound limit/offset at 0 so negative paging returns 422 instead of a 500, use the Annotated Query style for all_devices, clarify that the flag only affects device-bound tokens, and cover the omitted-flag default. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/endpoints/play_sessions.py | 19 +++++++--- backend/tests/endpoints/test_play_sessions.py | 38 ++++++++++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/backend/endpoints/play_sessions.py b/backend/endpoints/play_sessions.py index cda121ecd..1d3dcbf0e 100644 --- a/backend/endpoints/play_sessions.py +++ b/backend/endpoints/play_sessions.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Annotated from fastapi import HTTPException, Query, Request, status from pydantic import BaseModel, Field, model_validator @@ -105,12 +106,18 @@ def get_play_sessions( device_id: str | None = None, start_after: datetime | None = None, end_before: datetime | None = None, - limit: int = 50, - offset: int = 0, - all_devices: bool = Query( - default=False, - description="Include this user's sessions from all devices unless device_id is provided.", - ), + limit: Annotated[int, Query(ge=0)] = 50, + offset: Annotated[int, Query(ge=0)] = 0, + all_devices: Annotated[ + bool, + Query( + description=( + "For a device-bound client token, include sessions from all of " + "this user's devices instead of only the token's. " + "Ignored when device_id is provided." + ) + ), + ] = False, ) -> list[PlaySessionSchema]: effective_device_id = device_id or ( None if all_devices else token_device_id(request) diff --git a/backend/tests/endpoints/test_play_sessions.py b/backend/tests/endpoints/test_play_sessions.py index a17cd8f5f..bc04a0be7 100644 --- a/backend/tests/endpoints/test_play_sessions.py +++ b/backend/tests/endpoints/test_play_sessions.py @@ -673,7 +673,7 @@ class TestPlaySessionRomUserUpdates: class TestPlaySessionQuery: - @pytest.mark.parametrize("all_devices", [False, True]) + @pytest.mark.parametrize("all_devices", [None, False, True]) def test_paired_client_all_devices( self, client, @@ -687,6 +687,12 @@ class TestPlaySessionQuery: other_device = db_device_handler.add_device( Device(id=str(uuid.uuid4()), user_id=admin_user.id, name="Other device") ) + owners = [ + (admin_user.id, device.id), + (admin_user.id, other_device.id), + (admin_user.id, None), + (editor_user.id, None), + ] now = datetime.now(timezone.utc).replace(microsecond=0) sessions = db_play_session_handler.add_sessions( [ @@ -694,25 +700,20 @@ class TestPlaySessionQuery: user_id=user_id, device_id=device_id, rom_id=rom.id, - start_time=now - timedelta(hours=index + 1), - end_time=now - timedelta(hours=index + 1, minutes=-30), - duration_ms=1800000, - ) - for index, (user_id, device_id) in enumerate( - [ - (admin_user.id, device.id), - (admin_user.id, other_device.id), - (admin_user.id, None), - (editor_user.id, None), - ] + start_time=now - timedelta(hours=hours), + end_time=now - timedelta(hours=hours) + timedelta(minutes=30), + duration_ms=30 * 60 * 1000, ) + for hours, (user_id, device_id) in enumerate(owners, start=1) ] ) _, raw = add_device_token(admin_user, device.id, scopes="roms.user.read") headers = {"Authorization": f"Bearer {raw}"} + # None leaves the flag off the query so the default is exercised. + flag = {} if all_devices is None else {"all_devices": all_devices} response = client.get( "/api/play-sessions", - params={"rom_id": rom.id, "all_devices": all_devices}, + params={"rom_id": rom.id, **flag}, headers=headers, ) assert response.status_code == status.HTTP_200_OK @@ -722,7 +723,7 @@ class TestPlaySessionQuery: # An explicit device filter still narrows the current user's results. response = client.get( "/api/play-sessions", - params={"device_id": other_device.id, "all_devices": all_devices}, + params={"device_id": other_device.id, **flag}, headers=headers, ) assert response.status_code == status.HTTP_200_OK @@ -738,6 +739,15 @@ class TestPlaySessionQuery: ) assert response.status_code == status.HTTP_403_FORBIDDEN + @pytest.mark.parametrize("param", ["limit", "offset"]) + def test_rejects_negative_paging(self, client, access_token: str, param: str): + response = client.get( + "/api/play-sessions", + params={param: -1}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + def test_filter_by_rom_id( self, client, access_token: str, rom: Rom, platform: Platform ): From 1a607bd2c582d3c2e686f5807f594872fed9a225 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 30 Sep 2026 15:37:23 -0400 Subject: [PATCH 3/5] pr-ready: simplify Reuse the shared PageParams for limit/offset, build device tokens through tests.factories.make_device_token (the add_device_token fixture is gone on master), and flatten the effective device_id expression. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/endpoints/play_sessions.py | 22 +++++++++---------- backend/tests/endpoints/test_play_sessions.py | 17 ++++++-------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/backend/endpoints/play_sessions.py b/backend/endpoints/play_sessions.py index 1d3dcbf0e..b815d6248 100644 --- a/backend/endpoints/play_sessions.py +++ b/backend/endpoints/play_sessions.py @@ -1,10 +1,11 @@ from datetime import datetime from typing import Annotated -from fastapi import HTTPException, Query, Request, status +from fastapi import Depends, HTTPException, Query, Request, status from pydantic import BaseModel, Field, model_validator from decorators.auth import protected_route +from endpoints.responses.base import PAGE_QUERY, PageParams from endpoints.responses.play_session import ( PlaySessionIngestResponse, PlaySessionIngestResult, @@ -102,34 +103,31 @@ def ingest_play_sessions( @protected_route(router.get, "", [Scope.ROMS_USER_READ]) def get_play_sessions( request: Request, + page: Annotated[PageParams, Depends(PAGE_QUERY)], rom_id: int | None = None, device_id: str | None = None, start_after: datetime | None = None, end_before: datetime | None = None, - limit: Annotated[int, Query(ge=0)] = 50, - offset: Annotated[int, Query(ge=0)] = 0, all_devices: Annotated[ bool, Query( description=( - "For a device-bound client token, include sessions from all of " - "this user's devices instead of only the token's. " - "Ignored when device_id is provided." + "Device-bound tokens: include every device's sessions, not only " + "the token's. Ignored when device_id is set." ) ), ] = False, ) -> list[PlaySessionSchema]: - effective_device_id = device_id or ( - None if all_devices else token_device_id(request) - ) + if not device_id: + device_id = None if all_devices else token_device_id(request) sessions = db_play_session_handler.get_sessions( user_id=request.user.id, rom_id=rom_id, - device_id=effective_device_id, + device_id=device_id, start_after=start_after, end_before=end_before, - limit=limit if start_after is None and end_before is None else None, - offset=offset, + limit=page.limit if start_after is None and end_before is None else None, + offset=page.offset, ) return [PlaySessionSchema.model_validate(s) for s in sessions] diff --git a/backend/tests/endpoints/test_play_sessions.py b/backend/tests/endpoints/test_play_sessions.py index 02361e4cd..0da8ad9f4 100644 --- a/backend/tests/endpoints/test_play_sessions.py +++ b/backend/tests/endpoints/test_play_sessions.py @@ -6,7 +6,7 @@ from fastapi import status from fastapi.testclient import TestClient from main import app from tests.audit_events import recorded_events -from tests.factories import make_rom +from tests.factories import make_device_token, make_rom from config import OAUTH_ACCESS_TOKEN_EXPIRE_SECONDS from handler.auth import oauth_handler @@ -671,7 +671,6 @@ class TestPlaySessionQuery: editor_user, device, rom, - add_device_token, all_devices, ): other_device = db_device_handler.add_device( @@ -697,7 +696,7 @@ class TestPlaySessionQuery: for hours, (user_id, device_id) in enumerate(owners, start=1) ] ) - _, raw = add_device_token(admin_user, device.id, scopes="roms.user.read") + _, raw = make_device_token(admin_user, device.id, scopes="roms.user.read") headers = {"Authorization": f"Bearer {raw}"} # None leaves the flag off the query so the default is exercised. flag = {} if all_devices is None else {"all_devices": all_devices} @@ -719,21 +718,19 @@ class TestPlaySessionQuery: assert response.status_code == status.HTTP_200_OK assert [s["id"] for s in response.json()] == [sessions[1].id] - def test_all_devices_requires_read_scope( - self, client, admin_user, device, add_device_token - ): - _, raw = add_device_token(admin_user, device.id, scopes="devices.read") + def test_all_devices_requires_read_scope(self, client, admin_user, device): + _, raw = make_device_token(admin_user, device.id, scopes="devices.read") response = client.get( "/api/play-sessions?all_devices=true", headers={"Authorization": f"Bearer {raw}"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN - @pytest.mark.parametrize("param", ["limit", "offset"]) - def test_rejects_negative_paging(self, client, access_token: str, param: str): + @pytest.mark.parametrize("params", [{"limit": 0}, {"limit": -1}, {"offset": -1}]) + def test_rejects_invalid_paging(self, client, access_token: str, params: dict): response = client.get( "/api/play-sessions", - params={param: -1}, + params=params, headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT From 71a1962f69c70b9033c29873bb1f827500f670e8 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 30 Sep 2026 15:38:29 -0400 Subject: [PATCH 4/5] pr-ready: review-polish Type the paging test parameter so mypy passes over backend/tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/tests/endpoints/test_play_sessions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/tests/endpoints/test_play_sessions.py b/backend/tests/endpoints/test_play_sessions.py index 0da8ad9f4..2635e37f3 100644 --- a/backend/tests/endpoints/test_play_sessions.py +++ b/backend/tests/endpoints/test_play_sessions.py @@ -727,7 +727,9 @@ class TestPlaySessionQuery: assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.parametrize("params", [{"limit": 0}, {"limit": -1}, {"offset": -1}]) - def test_rejects_invalid_paging(self, client, access_token: str, params: dict): + def test_rejects_invalid_paging( + self, client, access_token: str, params: dict[str, int] + ): response = client.get( "/api/play-sessions", params=params, From 76012dafca8978f1dbfe2b9044c0a7014cf93db2 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 30 Sep 2026 15:45:23 -0400 Subject: [PATCH 5/5] Read play sessions across devices without a token fallback GET /api/play-sessions no longer narrows to the token's device when device_id is omitted, so the all_devices flag is dropped. Reading anything beyond the token's own device now requires devices.read, matching the saves and device endpoints. GET /api/sync/sessions takes the shared PageParams and gains offset paging. Co-Authored-By: Claude Opus 5.5 (1M context) --- backend/endpoints/play_sessions.py | 18 ++---- backend/endpoints/sync/__init__.py | 10 ++-- .../handler/database/sync_sessions_handler.py | 7 ++- backend/tests/endpoints/device/test_auth.py | 2 +- backend/tests/endpoints/test_play_sessions.py | 55 +++++++++---------- backend/tests/endpoints/test_sync.py | 33 +++++++++++ 6 files changed, 79 insertions(+), 46 deletions(-) diff --git a/backend/endpoints/play_sessions.py b/backend/endpoints/play_sessions.py index b815d6248..b648f1e92 100644 --- a/backend/endpoints/play_sessions.py +++ b/backend/endpoints/play_sessions.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Annotated -from fastapi import Depends, HTTPException, Query, Request, status +from fastapi import Depends, HTTPException, Request, status from pydantic import BaseModel, Field, model_validator from decorators.auth import protected_route @@ -108,18 +108,12 @@ def get_play_sessions( device_id: str | None = None, start_after: datetime | None = None, end_before: datetime | None = None, - all_devices: Annotated[ - bool, - Query( - description=( - "Device-bound tokens: include every device's sessions, not only " - "the token's. Ignored when device_id is set." - ) - ), - ] = False, ) -> list[PlaySessionSchema]: - if not device_id: - device_id = None if all_devices else token_device_id(request) + # A device-bound client may read its own device with roms.user.read alone. + if ( + device_id is None or device_id != token_device_id(request) + ) and Scope.DEVICES_READ not in request.auth.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden") sessions = db_play_session_handler.get_sessions( user_id=request.user.id, rom_id=rom_id, diff --git a/backend/endpoints/sync/__init__.py b/backend/endpoints/sync/__init__.py index 2f049934f..4f042fe9f 100644 --- a/backend/endpoints/sync/__init__.py +++ b/backend/endpoints/sync/__init__.py @@ -1,13 +1,14 @@ import asyncio from collections import Counter from datetime import datetime +from typing import Annotated -from fastapi import BackgroundTasks, HTTPException, Request, status +from fastapi import BackgroundTasks, Depends, HTTPException, Request, status from pydantic import Field, model_validator from config import TASK_TIMEOUT from decorators.auth import protected_route -from endpoints.responses.base import BaseModel +from endpoints.responses.base import PAGE_QUERY, BaseModel, PageParams from endpoints.responses.play_session import ( PlaySessionIngestResponse, PlaySessionIngestResult, @@ -507,14 +508,15 @@ def complete_sync_session( @protected_route(router.get, "/sessions", [Scope.DEVICES_READ]) def get_sync_sessions( request: Request, + page: Annotated[PageParams, Depends(PAGE_QUERY)], device_id: str | None = None, - limit: int = 50, ) -> list[SyncSessionSchema]: """List sync sessions for the current user.""" sessions = db_sync_session_handler.get_sessions( user_id=request.user.id, device_id=device_id, - limit=limit, + limit=page.limit, + offset=page.offset, ) return [SyncSessionSchema.model_validate(s) for s in sessions] diff --git a/backend/handler/database/sync_sessions_handler.py b/backend/handler/database/sync_sessions_handler.py index a28c58457..bee8053b6 100644 --- a/backend/handler/database/sync_sessions_handler.py +++ b/backend/handler/database/sync_sessions_handler.py @@ -221,6 +221,7 @@ class DBSyncSessionsHandler(DBBaseHandler): device_id: str | None = None, status: SyncSessionStatus | None = None, limit: int = 50, + offset: int = 0, session: Session = INJECTED_SESSION, ) -> Sequence[SyncSession]: query = select(SyncSession).filter_by(user_id=user_id) @@ -231,5 +232,9 @@ class DBSyncSessionsHandler(DBBaseHandler): if status: query = query.filter_by(status=status) - query = query.order_by(SyncSession.initiated_at.desc()).limit(limit) + query = ( + query.order_by(SyncSession.initiated_at.desc(), SyncSession.id.desc()) + .limit(limit) + .offset(offset) + ) return session.scalars(query).all() diff --git a/backend/tests/endpoints/device/test_auth.py b/backend/tests/endpoints/device/test_auth.py index 8c92780ab..29d3ccd84 100644 --- a/backend/tests/endpoints/device/test_auth.py +++ b/backend/tests/endpoints/device/test_auth.py @@ -677,7 +677,7 @@ class TestBoundTokenInference: list_resp = client.get( f"/api/play-sessions?device_id={other.id}", - headers={"Authorization": f"Bearer {creds['access_token']}"}, + headers={"Authorization": f"Bearer {access_token}"}, ) assert list_resp.status_code == status.HTTP_200_OK assert len(list_resp.json()) >= 1 diff --git a/backend/tests/endpoints/test_play_sessions.py b/backend/tests/endpoints/test_play_sessions.py index 2635e37f3..f816a0038 100644 --- a/backend/tests/endpoints/test_play_sessions.py +++ b/backend/tests/endpoints/test_play_sessions.py @@ -663,15 +663,26 @@ class TestPlaySessionRomUserUpdates: class TestPlaySessionQuery: - @pytest.mark.parametrize("all_devices", [None, False, True]) - def test_paired_client_all_devices( + @pytest.mark.parametrize( + "scopes, target, expected", + [ + ("roms.user.read devices.read", None, [0, 1, 2]), + ("roms.user.read devices.read", "other", [1]), + ("roms.user.read", "own", [0]), + ("roms.user.read", None, None), + ("roms.user.read", "other", None), + ], + ) + def test_device_token_reads( self, client, admin_user, editor_user, device, rom, - all_devices, + scopes: str, + target: str | None, + expected: list[int] | None, ): other_device = db_device_handler.add_device( Device(id=str(uuid.uuid4()), user_id=admin_user.id, name="Other device") @@ -696,35 +707,23 @@ class TestPlaySessionQuery: for hours, (user_id, device_id) in enumerate(owners, start=1) ] ) - _, raw = make_device_token(admin_user, device.id, scopes="roms.user.read") - headers = {"Authorization": f"Bearer {raw}"} - # None leaves the flag off the query so the default is exercised. - flag = {} if all_devices is None else {"all_devices": all_devices} + _, raw = make_device_token(admin_user, device.id, scopes=scopes) + params = { + "own": {"device_id": device.id}, + "other": {"device_id": other_device.id}, + } + response = client.get( "/api/play-sessions", - params={"rom_id": rom.id, **flag}, - headers=headers, - ) - assert response.status_code == status.HTTP_200_OK - expected = sessions[:3] if all_devices else sessions[:1] - assert {s["id"] for s in response.json()} == {s.id for s in expected} - - # An explicit device filter still narrows the current user's results. - response = client.get( - "/api/play-sessions", - params={"device_id": other_device.id, **flag}, - headers=headers, - ) - assert response.status_code == status.HTTP_200_OK - assert [s["id"] for s in response.json()] == [sessions[1].id] - - def test_all_devices_requires_read_scope(self, client, admin_user, device): - _, raw = make_device_token(admin_user, device.id, scopes="devices.read") - response = client.get( - "/api/play-sessions?all_devices=true", + params=params.get(target or "", {}), headers={"Authorization": f"Bearer {raw}"}, ) - assert response.status_code == status.HTTP_403_FORBIDDEN + + if expected is None: + assert response.status_code == status.HTTP_403_FORBIDDEN + return + assert response.status_code == status.HTTP_200_OK + assert [s["id"] for s in response.json()] == [sessions[i].id for i in expected] @pytest.mark.parametrize("params", [{"limit": 0}, {"limit": -1}, {"offset": -1}]) def test_rejects_invalid_paging( diff --git a/backend/tests/endpoints/test_sync.py b/backend/tests/endpoints/test_sync.py index 0cdf8f730..83e2df882 100644 --- a/backend/tests/endpoints/test_sync.py +++ b/backend/tests/endpoints/test_sync.py @@ -551,6 +551,39 @@ class TestSyncSessions: assert len(data) == 1 assert data[0]["device_id"] == dev_a.id + def test_list_sessions_paginates(self, client, access_token: str, admin_user: User): + device = db_device_handler.add_device( + Device(id="session-dev-page", user_id=admin_user.id) + ) + created = [ + db_sync_session_handler.create_session( + device_id=device.id, user_id=admin_user.id + ) + for _ in range(3) + ] + headers = {"Authorization": f"Bearer {access_token}"} + + first = client.get("/api/sync/sessions?limit=2&offset=0", headers=headers) + rest = client.get("/api/sync/sessions?limit=2&offset=2", headers=headers) + + assert first.status_code == status.HTTP_200_OK + assert rest.status_code == status.HTTP_200_OK + ids = [s["id"] for s in first.json() + rest.json()] + assert sorted(ids) == sorted(s.id for s in created) + + @pytest.mark.parametrize( + "params", [{"limit": 0}, {"limit": 10_001}, {"offset": -1}] + ) + def test_list_sessions_rejects_invalid_paging( + self, client, access_token: str, params: dict[str, int] + ): + response = client.get( + "/api/sync/sessions", + params=params, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + def test_get_session(self, client, access_token: str, admin_user: User): device = db_device_handler.add_device( Device(id="session-dev-get", user_id=admin_user.id)