From 9b883c915500632e08420f403011e8a4d9b9ed91 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 10 Oct 2026 02:27:15 +0300 Subject: [PATCH] refactor: apply reviewed server coding style --- AGENTS.md | 16 +- papyrus/api/deps.py | 9 +- papyrus/api/routes/acquisition.py | 36 +++- papyrus/api/routes/goals.py | 14 +- papyrus/api/routes/media.py | 25 ++- papyrus/api/routes/opds.py | 2 + papyrus/api/routes/progress.py | 16 +- papyrus/api/routes/sync.py | 1 + papyrus/api/routes/users.py | 8 +- papyrus/core/dev_pages.py | 9 +- papyrus/core/exceptions.py | 50 +++++- papyrus/core/security.py | 43 ++++- papyrus/models/acquisition.py | 43 ++++- papyrus/models/auth.py | 14 +- papyrus/models/library.py | 2 + papyrus/models/media.py | 6 +- papyrus/models/powersync_demo.py | 6 +- papyrus/models/sync.py | 24 ++- papyrus/models/user.py | 4 + papyrus/schemas/acquisition.py | 5 + papyrus/schemas/auth.py | 9 +- papyrus/schemas/common.py | 9 +- papyrus/schemas/goal.py | 15 +- papyrus/schemas/opds.py | 8 +- papyrus/schemas/sync.py | 3 + papyrus/schemas/tracking.py | 24 ++- papyrus/services/acquisition/jobs.py | 68 ++++++-- papyrus/services/acquisition/providers.py | 93 +++++++++- papyrus/services/acquisition/transport.py | 16 +- papyrus/services/acquisition_monitor.py | 19 ++- papyrus/services/auth/_core.py | 4 + papyrus/services/auth/google.py | 1 + papyrus/services/goal_history.py | 35 +++- papyrus/services/goal_progress.py | 38 ++++- papyrus/services/goals.py | 46 ++++- papyrus/services/library_sync.py | 118 +++++++++++-- papyrus/services/media.py | 33 +++- papyrus/services/opds.py | 49 +++++- papyrus/services/progress.py | 31 +++- papyrus/services/tracking_validation.py | 66 +++++++- papyrus/services/users.py | 1 + tests/api/routes/test_acquisition.py | 169 ++++++++++++++++--- tests/api/routes/test_auth.py | 67 ++++++++ tests/api/routes/test_auth_sandbox.py | 5 +- tests/api/routes/test_bookmark_sync.py | 104 ++++++++++-- tests/api/routes/test_bookmarks.py | 2 + tests/api/routes/test_books.py | 14 ++ tests/api/routes/test_goals.py | 22 ++- tests/api/routes/test_library_sync.py | 114 +++++++++++-- tests/api/routes/test_media.py | 18 +- tests/api/routes/test_notes.py | 2 + tests/api/routes/test_opds.py | 2 + tests/api/routes/test_powersync_sandbox.py | 16 +- tests/api/routes/test_progress.py | 12 ++ tests/api/routes/test_reading_profiles.py | 3 + tests/api/routes/test_saved_filters.py | 3 + tests/api/routes/test_series.py | 2 + tests/api/routes/test_shelves.py | 6 + tests/api/routes/test_storage.py | 5 + tests/api/routes/test_sync.py | 49 +++++- tests/api/routes/test_tags.py | 2 + tests/api/routes/test_users.py | 5 + tests/conftest.py | 4 + tests/core/test_security.py | 4 + tests/integration/test_auth_smoke.py | 13 +- tests/services/test_acquisition.py | 49 ++++-- tests/services/test_acquisition_monitor.py | 187 +++++++++++++++++++-- tests/services/test_auth.py | 28 ++- tests/services/test_library_validation.py | 1 + tests/services/test_opds.py | 40 ++++- tests/services/test_powersync_demo.py | 12 +- tests/services/test_sync.py | 3 + tests/services/test_tracking.py | 80 +++++++-- tests/services/test_users.py | 18 +- tests/test_acquisition_migration.py | 2 +- tests/test_bookmark_migration.py | 4 + tests/test_env_files.py | 2 - tests/test_library_migration.py | 8 + tests/test_models.py | 9 +- tests/test_openapi_export.py | 3 +- tests/test_powersync_sync_config.py | 3 +- tests/test_tracking_migration.py | 8 + 82 files changed, 1866 insertions(+), 253 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d180067..6b2734f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,19 +15,23 @@ ## Coding Conventions +When working in the Papyrus workspace, read `../CODING_STYLE.md` for the +maintainer's reviewed examples. The rules below also apply in standalone checkouts. + +- Use meaningful local and callback names; avoid single-letter operands and redundant qualifiers when a clear role name is available. +- Prefer clear branches or named predicates to nested conditional expressions and substantial inline validation conditions. Preserve predicate polarity and null handling when extracting helpers. +- Extract meaningful constants and focused helpers for distracting literals or construction details. Preserve types, scope, return values, and behavior. +- Keep Ruff's 120-character line length. Use trailing commas for deliberate multiline argument layouts, and give multiline model/schema fields breathing room while keeping related simple fields together. +- Remove documentation that merely repeats a declaration and decorative section banners. Retain substantive contracts and constraints in docstrings. - Keep route handlers thin. They should parse input, enforce dependencies/auth, call a service, translate errors, and return schemas. - Put business rules, query orchestration, and transaction-aware logic in `papyrus/services`, not in route modules. - Reuse or extend existing schema modules before creating new top-level packages. - Follow existing async patterns with `AsyncSession`, explicit return types, and Pydantic schemas. -- Use vertical spacing to separate logical steps inside functions. Keep related statements together, but add a blank line when moving between setup, validation, branching, and side effects. -- Prefer readable spacing over dense blocks. Short guard clauses, temporary assignments, and context-manager branches should usually be visually separated the way `papyrus/services/email.py` and `papyrus/services/auth/` are structured. +- Keep consecutive single-line statements together. A change in purpose does not require a blank line, including query execution, result extraction, assignments, persistence calls, and returns. - Treat any multiline statement or block as a visual boundary inside a function. If one adjacent statement is multiline and both sides are real code, separate the two statements with a blank line. - Apply that rule to multiline conditionals, loops, `with` blocks, `try` blocks, multiline calls, multiline literals, and multiline return values. Treat `if` / `elif` / `else` as one block. - Do not add a blank line just because a docstring appears above the first statement in a function. The multiline-block rule applies between code statements, not between a docstring and the first line of code. -- Add a blank line between query execution and result extraction, especially around `session.execute(...)` and subsequent `scalar*()` reads. -- Add a blank line before persistence and side-effect boundaries such as `session.add(...)`, `send_email(...)`, `commit()`, redirects, and returned result objects when they start a new phase of the function. -- Keep tightly coupled short sequences together when they are clearly one step. Do not add spacing mechanically when it makes a two-line operation harder to read. -- Prefer extra spacing around state transitions and persistence boundaries rather than packing setup, branching, and writes into a single block. +- Do not pad the start or end of a block. Spacing follows blocks and multiline statements, not inferred logical phases or side effects. - When adding a new router module, register it in `papyrus/api/routes/__init__.py`. - Avoid adding dependencies unless the user explicitly asks for them. - Keep changes scoped. Do not refactor unrelated areas as part of a focused fix. diff --git a/papyrus/api/deps.py b/papyrus/api/deps.py index 18fe8f0..91a14db 100644 --- a/papyrus/api/deps.py +++ b/papyrus/api/deps.py @@ -114,7 +114,14 @@ class PaginationParams: def __init__( self, page: Annotated[int, Query(ge=1, description="Page number")] = 1, - limit: Annotated[int, Query(ge=1, le=100, description="Items per page")] = 20, + limit: Annotated[ + int, + Query( + ge=1, + le=100, + description="Items per page", + ), + ] = 20, sort: Annotated[ str | None, Query(description="Sort field with optional - prefix for descending"), diff --git a/papyrus/api/routes/acquisition.py b/papyrus/api/routes/acquisition.py index fa37de6..563bdc3 100644 --- a/papyrus/api/routes/acquisition.py +++ b/papyrus/api/routes/acquisition.py @@ -70,17 +70,22 @@ def _credentials(api_key: str | None, username: str | None, password: str | None values = { key: value for key, value in {"api_key": api_key, "username": username, "password": password}.items() if value } + if not values: return None + return {"encrypted": encrypt_secret_payload(values)} def _merge_credentials(endpoint: AcquisitionEndpointModel, replacement: dict[str, str] | None) -> None: if replacement is None: return + current = endpoint.credentials or {} + if encrypted := current.get("encrypted"): current = decrypt_secret_payload(encrypted) + endpoint.credentials = { "encrypted": encrypt_secret_payload({**current, **decrypt_secret_payload(replacement["encrypted"])}) } @@ -133,6 +138,7 @@ async def list_endpoints(user_id: CurrentUserId, db: DbSession) -> list[Acquisit .where(AcquisitionEndpointModel.owner_user_id == user_id) .order_by(AcquisitionEndpointModel.name) ) + return list(result.scalars()) @@ -153,6 +159,7 @@ async def create_endpoint( ), settings=request.settings, ) + db.add(endpoint) await db.commit() await db.refresh(endpoint) @@ -165,13 +172,16 @@ async def update_endpoint( ) -> AcquisitionEndpointModel: endpoint = await owned_endpoint(db, user_id, endpoint_id) updates = request.model_dump(exclude_unset=True, exclude={"api_key", "username", "password"}) + for field, value in updates.items(): setattr(endpoint, field, str(value) if field == "base_url" else value) + replacement = _credentials( request.api_key.get_secret_value() if request.api_key else None, request.username.get_secret_value() if request.username else None, request.password.get_secret_value() if request.password else None, ) + _merge_credentials(endpoint, replacement) await db.commit() await db.refresh(endpoint) @@ -190,9 +200,7 @@ async def test_connection( db: DbSession, ) -> AcquisitionEndpointTestResult: endpoint = await build_test_endpoint(db, user_id, request) - await test_endpoint_connection(endpoint) - return AcquisitionEndpointTestResult(ok=True) @@ -203,10 +211,13 @@ async def search_releases(user_id: CurrentUserId, request: SearchRequest, db: Db AcquisitionEndpointModel.enabled.is_(True), AcquisitionEndpointModel.kind.in_(("prowlarr", "torznab")), ) + if request.endpoint_ids: statement = statement.where(AcquisitionEndpointModel.endpoint_id.in_(request.endpoint_ids)) + result = await db.execute(statement) releases: list[Release] = [] + for endpoint in result.scalars(): for candidate in await search_endpoint(endpoint, request.query): releases.append( @@ -221,6 +232,7 @@ async def search_releases(user_id: CurrentUserId, request: SearchRequest, db: Db format_hints=candidate.format_hints, ) ) + return releases @@ -274,26 +286,32 @@ async def submit_release_batch_route( @protected_router.post( - "/arr/{endpoint_id}/commands", response_model=AcquisitionJob, status_code=status.HTTP_201_CREATED + "/arr/{endpoint_id}/commands", + response_model=AcquisitionJob, + status_code=status.HTTP_201_CREATED, ) async def run_arr_command( user_id: CurrentUserId, endpoint_id: UUID, request: ArrCommandRequest, db: DbSession ) -> AcquisitionJobModel: """Delegate a managed search/acquisition to Readarr or another Arr app.""" endpoint = await owned_endpoint(db, user_id, endpoint_id) + job = AcquisitionJobModel( owner_user_id=user_id, endpoint_id=endpoint.endpoint_id, title=request.command, download_url=f"arr-command:{request.command}", ) + db.add(job) + try: job.client_reference = await dispatch_arr_command(endpoint, request.command, request.ids) job.status = "submitted" except HTTPException as exc: job.status = "failed" job.error = str(exc.detail) + await db.commit() await db.refresh(job) return job @@ -343,7 +361,12 @@ async def choose_job_file( request: AcquisitionFileSelectionRequest, db: DbSession, ) -> AcquisitionJobModel: - return await select_job_file(db, user_id, job_id, request.file_index) + return await select_job_file( + db, + user_id, + job_id, + request.file_index, + ) @protected_router.post("/jobs/{job_id}/cancel", response_model=AcquisitionJob) @@ -382,6 +405,7 @@ async def list_rules(user_id: CurrentUserId, db: DbSession) -> list[AcquisitionR @protected_router.post("/rules", response_model=AcquisitionRule, status_code=status.HTTP_201_CREATED) async def create_rule(user_id: CurrentUserId, request: AcquisitionRuleCreate, db: DbSession) -> AcquisitionRuleModel: await owned_endpoint(db, user_id, request.download_client_id) + rule = AcquisitionRuleModel( owner_user_id=user_id, name=request.name, @@ -391,6 +415,7 @@ async def create_rule(user_id: CurrentUserId, request: AcquisitionRuleCreate, db filters=request.filters, enabled=request.enabled, ) + db.add(rule) await db.commit() await db.refresh(rule) @@ -404,9 +429,12 @@ async def run_acquisition_rule(user_id: CurrentUserId, rule_id: UUID, db: DbSess AcquisitionRuleModel.rule_id == rule_id, AcquisitionRuleModel.owner_user_id == user_id ) ) + rule = result.scalar_one_or_none() + if rule is None: raise HTTPException(status_code=404, detail="Acquisition rule not found") + return await run_rule(db, rule) diff --git a/papyrus/api/routes/goals.py b/papyrus/api/routes/goals.py index eb4e814..ba37a9a 100644 --- a/papyrus/api/routes/goals.py +++ b/papyrus/api/routes/goals.py @@ -20,7 +20,12 @@ async def list_goals(user_id: CurrentUserId, db: DBSession, is_active: bool | No return GoalList(goals=await service.list_goals(db, user_id, is_active)) -@router.post("", response_model=Goal, status_code=status.HTTP_201_CREATED, summary="Create a new goal") +@router.post( + "", + response_model=Goal, + status_code=status.HTTP_201_CREATED, + summary="Create a new goal", +) async def create_goal(user_id: CurrentUserId, request: CreateGoalRequest, db: DBSession) -> Goal: return await service.create_goal(db, user_id, request) @@ -32,7 +37,12 @@ async def get_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Goal @router.patch("/{goal_id}", response_model=Goal, summary="Update goal") async def update_goal(user_id: CurrentUserId, goal_id: UUID, request: UpdateGoalRequest, db: DBSession) -> Goal: - return await service.update_goal(db, user_id, goal_id, request) + return await service.update_goal( + db, + user_id, + goal_id, + request, + ) @router.delete("/{goal_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete goal") diff --git a/papyrus/api/routes/media.py b/papyrus/api/routes/media.py index 093496a..52617b2 100644 --- a/papyrus/api/routes/media.py +++ b/papyrus/api/routes/media.py @@ -19,7 +19,12 @@ DBSession = Annotated[AsyncSession, Depends(get_db)] -@router.post("", response_model=MediaAssetResponse, status_code=status.HTTP_201_CREATED, summary="Upload private media") +@router.post( + "", + response_model=MediaAssetResponse, + status_code=status.HTTP_201_CREATED, + summary="Upload private media", +) @limiter.limit(lambda: f"{get_settings().rate_limit_upload}/minute") async def upload_media( request: Request, @@ -30,7 +35,14 @@ async def upload_media( file: Annotated[UploadFile, File()], ) -> MediaAssetResponse: """Upload a book file or cover image for the authenticated user.""" - asset = await media_service.upload_media(db, user_id, book_id=book_id, kind=kind, file=file) + asset = await media_service.upload_media( + db, + user_id, + book_id=book_id, + kind=kind, + file=file, + ) + return MediaAssetResponse.model_validate(asset) @@ -38,7 +50,12 @@ async def upload_media( async def get_media_usage(user_id: CurrentUserId, db: DBSession) -> MediaUsageResponse: """Return authenticated user's file storage usage.""" used_bytes, quota_bytes, available_bytes = await media_service.usage(db, user_id) - return MediaUsageResponse(used_bytes=used_bytes, quota_bytes=quota_bytes, available_bytes=available_bytes) + + return MediaUsageResponse( + used_bytes=used_bytes, + quota_bytes=quota_bytes, + available_bytes=available_bytes, + ) @router.get("/{asset_id}", summary="Download private media") @@ -46,8 +63,10 @@ async def download_media(user_id: CurrentUserId, db: DBSession, asset_id: UUID) """Download an owned media asset.""" asset = await media_service.get_owned_asset(db, user_id, asset_id) path = media_service.asset_path(asset) + if not path.exists(): raise NotFoundError("Media asset was not found") + return FileResponse(path, media_type=asset.content_type, filename=asset.original_filename) diff --git a/papyrus/api/routes/opds.py b/papyrus/api/routes/opds.py index 6a08148..86179b6 100644 --- a/papyrus/api/routes/opds.py +++ b/papyrus/api/routes/opds.py @@ -19,6 +19,7 @@ class RelayResponse(StreamingResponse): def __init__(self, resource: RelayResource) -> None: self.resource = resource + headers = { "X-OPDS-URL": resource.url, "Content-Type": resource.content_type, @@ -27,6 +28,7 @@ def __init__(self, resource: RelayResource) -> None: "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "sandbox; default-src 'none'", } + if resource.length is not None: headers["Content-Length"] = str(resource.length) diff --git a/papyrus/api/routes/progress.py b/papyrus/api/routes/progress.py index 114789f..6027740 100644 --- a/papyrus/api/routes/progress.py +++ b/papyrus/api/routes/progress.py @@ -25,7 +25,14 @@ async def list_reading_sessions( start_date: date | None = None, end_date: date | None = None, ) -> ReadingSessionList: - return await service.list_sessions(db, user_id, pagination, book_id, start_date, end_date) + return await service.list_sessions( + db, + user_id, + pagination, + book_id, + start_date, + end_date, + ) @router.post("/sessions", response_model=ReadingSession, summary="Record reading session") @@ -39,4 +46,9 @@ async def create_reading_session( async def get_reading_statistics( user_id: CurrentUserId, db: DBSession, start_date: date | None = None, end_date: date | None = None ) -> ReadingStatistics: - return await service.statistics(db, user_id, start_date, end_date) + return await service.statistics( + db, + user_id, + start_date, + end_date, + ) diff --git a/papyrus/api/routes/sync.py b/papyrus/api/routes/sync.py index 682fd1c..b08893e 100644 --- a/papyrus/api/routes/sync.py +++ b/papyrus/api/routes/sync.py @@ -29,6 +29,7 @@ async def get_data_sync_settings() -> DataSyncSettingsResponse: """Return public sync capabilities for client custom-server setup.""" settings = get_settings() + return DataSyncSettingsResponse( data_sync_url=settings.powersync_service_url, file_storage=FileStorageSettings( diff --git a/papyrus/api/routes/users.py b/papyrus/api/routes/users.py index 1b86fc7..9ca5263 100644 --- a/papyrus/api/routes/users.py +++ b/papyrus/api/routes/users.py @@ -96,5 +96,11 @@ async def change_password( db: DBSession, ) -> MessageResponse: """Change the user's password and revoke existing sessions.""" - await user_service.change_user_password(db, user_id, request.current_password, request.new_password) + await user_service.change_user_password( + db, + user_id, + request.current_password, + request.new_password, + ) + return MessageResponse(message="Password changed successfully") diff --git a/papyrus/core/dev_pages.py b/papyrus/core/dev_pages.py index 2b4aeda..967a996 100644 --- a/papyrus/core/dev_pages.py +++ b/papyrus/core/dev_pages.py @@ -130,6 +130,7 @@ def get_dev_page_assets(entry_module: str, settings: Settings | None = None) -> if _should_use_vite(settings): vite_url = settings.dev_pages_vite_url + return DevPageAssets( script_urls=[f"{vite_url}/@vite/client", f"{vite_url}/{entry_module}"], css_urls=[], @@ -158,6 +159,7 @@ def render_dev_page( """Render a development page shell with either Vite or built assets.""" settings = get_settings() assets = get_dev_page_assets(entry_module, settings) + context = { "request": request, "page_title": page_title, @@ -166,4 +168,9 @@ def render_dev_page( "page_config_json": json.dumps(page_config), "dev_page_assets": assets, } - return templates.TemplateResponse(request=request, name=template_name, context=context) + + return templates.TemplateResponse( + request=request, + name=template_name, + context=context, + ) diff --git a/papyrus/core/exceptions.py b/papyrus/core/exceptions.py index 30bce36..13dd415 100644 --- a/papyrus/core/exceptions.py +++ b/papyrus/core/exceptions.py @@ -24,35 +24,60 @@ class NotFoundError(AppError): """Resource not found.""" def __init__(self, message: str = "Resource not found", details: dict[str, Any] | None = None) -> None: - super().__init__(message=message, code="NOT_FOUND", status_code=404, details=details) + super().__init__( + message=message, + code="NOT_FOUND", + status_code=404, + details=details, + ) class ValidationError(AppError): """Validation error.""" def __init__(self, message: str = "Validation error", details: dict[str, Any] | None = None) -> None: - super().__init__(message=message, code="VALIDATION_ERROR", status_code=400, details=details) + super().__init__( + message=message, + code="VALIDATION_ERROR", + status_code=400, + details=details, + ) class UnauthorizedError(AppError): """Authentication required.""" def __init__(self, message: str = "Authentication required", details: dict[str, Any] | None = None) -> None: - super().__init__(message=message, code="UNAUTHORIZED", status_code=401, details=details) + super().__init__( + message=message, + code="UNAUTHORIZED", + status_code=401, + details=details, + ) class ForbiddenError(AppError): """Access denied.""" def __init__(self, message: str = "Access denied", details: dict[str, Any] | None = None) -> None: - super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details) + super().__init__( + message=message, + code="FORBIDDEN", + status_code=403, + details=details, + ) class ConflictError(AppError): """Resource conflict.""" def __init__(self, message: str = "Resource already exists", details: dict[str, Any] | None = None) -> None: - super().__init__(message=message, code="CONFLICT", status_code=409, details=details) + super().__init__( + message=message, + code="CONFLICT", + status_code=409, + details=details, + ) class ServiceUnavailableError(AppError): @@ -63,7 +88,12 @@ def __init__( message: str = "Service temporarily unavailable", details: dict[str, Any] | None = None, ) -> None: - super().__init__(message=message, code="SERVICE_UNAVAILABLE", status_code=503, details=details) + super().__init__( + message=message, + code="SERVICE_UNAVAILABLE", + status_code=503, + details=details, + ) class RateLimitError(AppError): @@ -77,4 +107,10 @@ def __init__( ) -> None: details = details or {} details["retry_after"] = retry_after - super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429, details=details) + + super().__init__( + message=message, + code="RATE_LIMIT_EXCEEDED", + status_code=429, + details=details, + ) diff --git a/papyrus/core/security.py b/papyrus/core/security.py index b35cd48..19fda41 100644 --- a/papyrus/core/security.py +++ b/papyrus/core/security.py @@ -39,6 +39,7 @@ def _load_pem_configured_value(value: str | None, file_path: Path | None) -> str @lru_cache def _get_powersync_private_key() -> Any: settings = get_settings() + private_key_pem = _load_pem_configured_value( settings.powersync_jwt_private_key, settings.powersync_jwt_private_key_path, @@ -56,6 +57,7 @@ def _get_powersync_private_key() -> Any: @lru_cache def _get_powersync_public_key() -> Any: settings = get_settings() + public_key_pem = _load_pem_configured_value( settings.powersync_jwt_public_key, settings.powersync_jwt_public_key_path, @@ -70,6 +72,7 @@ def _get_powersync_public_key() -> Any: @lru_cache def _get_powersync_previous_public_key() -> Any | None: settings = get_settings() + public_key_pem = _load_pem_configured_value( settings.powersync_jwt_previous_public_key, settings.powersync_jwt_previous_public_key_path, @@ -91,6 +94,7 @@ def _public_key_to_jwk(public_key: Any, key_id: str) -> dict[str, Any]: "use": "sig", } ) + return jwk @@ -103,6 +107,7 @@ def _create_signed_token( ) -> str: issued_at = datetime.now(UTC) payload = data.copy() + payload.update( { "iat": issued_at, @@ -110,6 +115,7 @@ def _create_signed_token( "type": token_type, } ) + return jwt.encode(payload, secret, algorithm=algorithm) @@ -144,31 +150,53 @@ def decrypt_secret_payload(token: str) -> dict[str, str]: decoded = _get_credentials_cipher().decrypt(token.encode("utf-8")).decode("utf-8") except InvalidToken as exc: raise ValueError("Encrypted credential payload is invalid") from exc + payload = json_loads(decoded) + if not isinstance(payload, dict) or not all( isinstance(key, str) and isinstance(value, str) for key, value in payload.items() ): raise ValueError("Encrypted credential payload has an invalid shape") + return payload def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: settings = get_settings() ttl = expires_delta or timedelta(minutes=settings.access_token_expire_minutes) - return _create_signed_token(data, "access", ttl, settings.secret_key, settings.algorithm) + + return _create_signed_token( + data, + "access", + ttl, + settings.secret_key, + settings.algorithm, + ) def create_state_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: settings = get_settings() ttl = expires_delta or timedelta(minutes=settings.oauth_state_expire_minutes) - return _create_signed_token(data, "oauth_state", ttl, settings.secret_key, settings.algorithm) + + return _create_signed_token( + data, + "oauth_state", + ttl, + settings.secret_key, + settings.algorithm, + ) def decode_token(token: str) -> dict[str, Any] | None: settings = get_settings() try: - return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm], options={"verify_aud": False}) + return jwt.decode( + token, + settings.secret_key, + algorithms=[settings.algorithm], + options={"verify_aud": False}, + ) except jwt.PyJWTError: return None @@ -192,6 +220,7 @@ def create_powersync_token(user_id: str, expires_delta: timedelta | None = None) issued_at = datetime.now(UTC) expires_at = issued_at + ttl headers = {"kid": settings.powersync_jwt_key_id} + payload = { "sub": user_id, "aud": settings.powersync_jwt_audience, @@ -200,7 +229,13 @@ def create_powersync_token(user_id: str, expires_delta: timedelta | None = None) "type": "powersync", } - token = jwt.encode(payload, _get_powersync_private_key(), algorithm="RS256", headers=headers) + token = jwt.encode( + payload, + _get_powersync_private_key(), + algorithm="RS256", + headers=headers, + ) + return token, int(ttl.total_seconds()) diff --git a/papyrus/models/acquisition.py b/papyrus/models/acquisition.py index 0fa33c6..0e3660d 100644 --- a/papyrus/models/acquisition.py +++ b/papyrus/models/acquisition.py @@ -25,7 +25,14 @@ class AcquisitionEndpoint(Base): download_root: Mapped[str | None] = mapped_column(String(2048), nullable=True) credentials: Mapped[dict[str, str] | None] = mapped_column(JSONB, nullable=True) settings: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + + enabled: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + server_default="true", + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) @@ -40,13 +47,22 @@ class AcquisitionRule(Base): name: Mapped[str] = mapped_column(String(120), nullable=False) query: Mapped[str] = mapped_column(String(500), nullable=False) endpoint_ids: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True) + download_client_id: Mapped[UUID | None] = mapped_column( Uuid, ForeignKey("acquisition_endpoints.endpoint_id", ondelete="SET NULL"), nullable=True, ) + filters: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + + enabled: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=True, + server_default="true", + ) + last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) @@ -55,6 +71,7 @@ class AcquisitionJob(Base): """An auditable submission to a download client.""" __tablename__ = "acquisition_jobs" + __table_args__ = ( Index("ix_acquisition_jobs_owner_status", "owner_user_id", "status"), Index("ix_acquisition_jobs_next_poll_at", "next_poll_at"), @@ -62,20 +79,31 @@ class AcquisitionJob(Base): job_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) owner_user_id: Mapped[UUID] = mapped_column(ForeignKey("users.user_id", ondelete="CASCADE"), index=True) + endpoint_id: Mapped[UUID | None] = mapped_column( Uuid, ForeignKey("acquisition_endpoints.endpoint_id", ondelete="SET NULL"), nullable=True, ) + rule_id: Mapped[UUID | None] = mapped_column(Uuid, ForeignKey("acquisition_rules.rule_id", ondelete="SET NULL")) + book_id: Mapped[UUID | None] = mapped_column( Uuid, ForeignKey("books.book_id", name="fk_acquisition_jobs_book_id_books", ondelete="SET NULL"), nullable=True, ) + title: Mapped[str] = mapped_column(String(500), nullable=False) download_url: Mapped[str | None] = mapped_column(Text, nullable=True) - status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", server_default="queued") + + status: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="queued", + server_default="queued", + ) + client_reference: Mapped[str | None] = mapped_column(String(255), nullable=True) client_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) client_state: Mapped[str | None] = mapped_column(String(64), nullable=True) @@ -85,7 +113,14 @@ class AcquisitionJob(Base): download_speed_bytes_per_second: Mapped[int | None] = mapped_column(BigInteger, nullable=True) eta_seconds: Mapped[int | None] = mapped_column(BigInteger, nullable=True) selected_file_path: Mapped[str | None] = mapped_column(Text, nullable=True) - retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + + retry_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default="0", + ) + error: Mapped[str | None] = mapped_column(Text, nullable=True) next_poll_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) lease_owner: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/papyrus/models/auth.py b/papyrus/models/auth.py index 652fdb0..8f6f6bb 100644 --- a/papyrus/models/auth.py +++ b/papyrus/models/auth.py @@ -38,9 +38,13 @@ class PasswordCredential(Base): ForeignKey("users.user_id", ondelete="CASCADE"), primary_key=True, ) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + password_changed_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now() + DateTime(timezone=True), + nullable=False, + server_default=func.now(), ) user: Mapped[User] = relationship(back_populates="password_credential") @@ -54,12 +58,14 @@ class AuthSession(Base): session_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) user_id: Mapped[UUID] = mapped_column(ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True) refresh_token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + client_type: Mapped[str] = mapped_column( String(32), nullable=False, default="unknown", server_default=text("'unknown'"), ) + device_label: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) @@ -77,9 +83,13 @@ class AuthExchangeCode(Base): code_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) code_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) purpose: Mapped[str] = mapped_column(String(32), nullable=False) + user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("users.user_id", ondelete="CASCADE"), nullable=True, index=True + ForeignKey("users.user_id", ondelete="CASCADE"), + nullable=True, + index=True, ) + provider: Mapped[str | None] = mapped_column(String(32), nullable=True) provider_subject: Mapped[str | None] = mapped_column(String(255), nullable=True) email_at_provider: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/papyrus/models/library.py b/papyrus/models/library.py index 890f362..1aea6cf 100644 --- a/papyrus/models/library.py +++ b/papyrus/models/library.py @@ -94,6 +94,7 @@ class SyncBookmark(LibraryEntity, Base): class SyncBookShelf(OwnedLibraryRow, Base): __tablename__ = "book_shelves" + __table_args__ = ( UniqueConstraint("book_id", "shelf_id"), CheckConstraint("id = book_id::text || ':' || shelf_id::text", name="ck_book_shelves_pair_id"), @@ -108,6 +109,7 @@ class SyncBookShelf(OwnedLibraryRow, Base): class SyncBookTag(OwnedLibraryRow, Base): __tablename__ = "book_tags" + __table_args__ = ( UniqueConstraint("book_id", "tag_id"), CheckConstraint("id = book_id::text || ':' || tag_id::text", name="ck_book_tags_pair_id"), diff --git a/papyrus/models/media.py b/papyrus/models/media.py index d9d3e46..1dc2c77 100644 --- a/papyrus/models/media.py +++ b/papyrus/models/media.py @@ -18,9 +18,13 @@ class MediaAsset(Base): __table_args__ = (UniqueConstraint("book_id", "kind", name="uq_media_assets_book_kind"),) asset_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + owner_user_id: Mapped[UUID] = mapped_column( - ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True + ForeignKey("users.user_id", ondelete="CASCADE"), + nullable=False, + index=True, ) + book_id: Mapped[UUID] = mapped_column(ForeignKey("books.book_id", ondelete="CASCADE"), nullable=False, index=True) kind: Mapped[str] = mapped_column(String(32), nullable=False) original_filename: Mapped[str] = mapped_column(String(512), nullable=False) diff --git a/papyrus/models/powersync_demo.py b/papyrus/models/powersync_demo.py index fb636f9..8eeff74 100644 --- a/papyrus/models/powersync_demo.py +++ b/papyrus/models/powersync_demo.py @@ -17,9 +17,13 @@ class PowerSyncDemoItem(Base): __tablename__ = "powersync_demo_items" item_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + owner_user_id: Mapped[UUID] = mapped_column( - ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True + ForeignKey("users.user_id", ondelete="CASCADE"), + nullable=False, + index=True, ) + title: Mapped[str] = mapped_column(String(255), nullable=False) notes: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/papyrus/models/sync.py b/papyrus/models/sync.py index b1ce833..6fa0a8d 100644 --- a/papyrus/models/sync.py +++ b/papyrus/models/sync.py @@ -18,9 +18,13 @@ class SyncBook(Base): __tablename__ = "books" book_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + owner_user_id: Mapped[UUID] = mapped_column( - ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True + ForeignKey("users.user_id", ondelete="CASCADE"), + nullable=False, + index=True, ) + title: Mapped[str] = mapped_column(String(500), nullable=False) subtitle: Mapped[str | None] = mapped_column(String(500), nullable=True) author: Mapped[str | None] = mapped_column(String(255), nullable=True) @@ -38,7 +42,14 @@ class SyncBook(Base): current_page: Mapped[int | None] = mapped_column(Integer, nullable=True) current_position: Mapped[float | None] = mapped_column(Float, nullable=True) current_cfi: Mapped[str | None] = mapped_column(Text, nullable=True) - is_favorite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + + is_favorite: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false", + ) + rating: Mapped[int | None] = mapped_column(Integer, nullable=True) custom_metadata: Mapped[dict[str, object] | None] = mapped_column(JSONB, nullable=True) added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) @@ -47,7 +58,14 @@ class SyncBook(Base): file_format: Mapped[str | None] = mapped_column(Text, nullable=True) file_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) file_hash: Mapped[str | None] = mapped_column(Text, nullable=True) - is_physical: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + + is_physical: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false", + ) + physical_location: Mapped[str | None] = mapped_column(Text, nullable=True) lent_to: Mapped[str | None] = mapped_column(Text, nullable=True) lent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/papyrus/models/user.py b/papyrus/models/user.py index 4b3bf4a..f13b041 100644 --- a/papyrus/models/user.py +++ b/papyrus/models/user.py @@ -20,12 +20,14 @@ class User(Base): display_name: Mapped[str] = mapped_column(String(100), nullable=False) avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) primary_email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True) + primary_email_verified: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false"), ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -34,11 +36,13 @@ class User(Base): back_populates="user", cascade="all, delete-orphan", ) + password_credential: Mapped[PasswordCredential | None] = relationship( back_populates="user", cascade="all, delete-orphan", uselist=False, ) + sessions: Mapped[list[AuthSession]] = relationship( back_populates="user", cascade="all, delete-orphan", diff --git a/papyrus/schemas/acquisition.py b/papyrus/schemas/acquisition.py index 701f747..f07f497 100644 --- a/papyrus/schemas/acquisition.py +++ b/papyrus/schemas/acquisition.py @@ -62,6 +62,7 @@ class AcquisitionEndpointUpdate(BaseModel): def validate_endpoint_url(cls, value: HttpUrl | None) -> HttpUrl | None: if value is None: return None + return validate_endpoint_url(value) @@ -78,12 +79,14 @@ class AcquisitionEndpointTest(BaseModel): def validate_endpoint_url(cls, value: HttpUrl | None) -> HttpUrl | None: if value is None: return None + return validate_endpoint_url(value) @model_validator(mode="after") def validate_endpoint_target(self) -> Self: if self.endpoint_id is None and (self.kind is None or self.base_url is None): raise ValueError("kind and base_url are required for an unsaved endpoint") + return self @@ -218,6 +221,8 @@ class AcquisitionFileSelectionRequest(BaseModel): def validate_endpoint_url(value: HttpUrl) -> HttpUrl: if value.username or value.password: raise ValueError("Endpoint URL must not include credentials") + if value.scheme not in {"http", "https"}: raise ValueError("Endpoint URL must use HTTP or HTTPS") + return value diff --git a/papyrus/schemas/auth.py b/papyrus/schemas/auth.py index 796cf07..e9f091d 100644 --- a/papyrus/schemas/auth.py +++ b/papyrus/schemas/auth.py @@ -12,7 +12,14 @@ class RegisterRequest(BaseModel): email: EmailStr = Field(..., examples=["user@example.com"]) password: str = Field(..., min_length=8, examples=["SecureP@ss123"]) - display_name: str = Field(..., min_length=1, max_length=100, examples=["John Doe"]) + + display_name: str = Field( + ..., + min_length=1, + max_length=100, + examples=["John Doe"], + ) + client_type: ClientType = "unknown" device_label: str | None = Field(default=None, max_length=255) diff --git a/papyrus/schemas/common.py b/papyrus/schemas/common.py index 889f81b..ebe9e5d 100644 --- a/papyrus/schemas/common.py +++ b/papyrus/schemas/common.py @@ -23,7 +23,14 @@ class Pagination(BaseModel): """Pagination metadata.""" page: int = Field(..., ge=1, examples=[1]) - limit: int = Field(..., ge=1, le=100, examples=[20]) + + limit: int = Field( + ..., + ge=1, + le=100, + examples=[20], + ) + total: int = Field(..., ge=0, examples=[150]) total_pages: int = Field(..., ge=0, examples=[8]) has_next: bool = Field(..., examples=[True]) diff --git a/papyrus/schemas/goal.py b/papyrus/schemas/goal.py index fdaf9bd..a1ecbaa 100644 --- a/papyrus/schemas/goal.py +++ b/papyrus/schemas/goal.py @@ -70,7 +70,13 @@ class CreateGoalRequest(BaseModel): scope: Literal["library", "book", "shelf"] = "library" scope_id: UUID | None = None book_ids: list[UUID] = Field(default_factory=list, max_length=1000) - minimum_minutes: int = Field(default=5, ge=1, le=1440) + + minimum_minutes: int = Field( + default=5, + ge=1, + le=1440, + ) + title: str = Field(..., max_length=255) description: str | None = None goal_type: GoalType @@ -90,7 +96,12 @@ class UpdateGoalRequest(BaseModel): is_active: bool | None = None is_archived: bool | None = None - @field_validator("title", "target_value", "is_active", "is_archived") + @field_validator( + "title", + "target_value", + "is_active", + "is_archived", + ) @classmethod def reject_null_settings(cls, value: str | int | bool | None) -> str | int | bool: """Omitted settings are unchanged; supplied settings cannot be null.""" diff --git a/papyrus/schemas/opds.py b/papyrus/schemas/opds.py index 685fcda..7cd8a5e 100644 --- a/papyrus/schemas/opds.py +++ b/papyrus/schemas/opds.py @@ -15,5 +15,11 @@ class OpdsRelayRequest(BaseModel): url: str = Field(min_length=1, max_length=8192) catalog_url: str = Field(min_length=1, max_length=8192) - max_bytes: int = Field(default=8 * 1024 * 1024, ge=1, le=256 * 1024 * 1024) + + max_bytes: int = Field( + default=8 * 1024 * 1024, + ge=1, + le=256 * 1024 * 1024, + ) + credentials: OpdsCredentials | None = None diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 3b417d0..d61320a 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -51,7 +51,9 @@ "last_read_at", } ) + ENTITY_FIELDS = frozenset({"owner_user_id", "created_at", "updated_at"}) + UPLOAD_FIELDS = { "reading_goals": ENTITY_FIELDS | {"payload"}, "reading_activities": ENTITY_FIELDS | {"payload"}, @@ -98,6 +100,7 @@ class PowerSyncCrudMutation(BaseModel): "reading_activities", "goal_periods", ] = Field(alias="type") + op: Literal["PUT", "PATCH", "DELETE", "put", "patch", "delete"] id: str op_id: int | None = Field(default=None, alias="op_id") diff --git a/papyrus/schemas/tracking.py b/papyrus/schemas/tracking.py index fa1fc8b..d07f659 100644 --- a/papyrus/schemas/tracking.py +++ b/papyrus/schemas/tracking.py @@ -36,7 +36,13 @@ class GoalDefinition(TrackingPayload): scope: Literal["library", "book", "shelf"] = "library" scope_id: UUID | None = None book_ids: list[UUID] = Field(default_factory=list, max_length=1000) - minimum_minutes: int = Field(default=5, ge=1, le=1440) + + minimum_minutes: int = Field( + default=5, + ge=1, + le=1440, + ) + rules: list[GoalRule] = Field(default_factory=list, max_length=10000) is_active: bool = True is_recurring: bool = True @@ -128,9 +134,21 @@ class Activity(TrackingPayload): created_at: AwareDatetime source: Literal["reader", "manual"] = "manual" kind: Literal["reading", "completion", "reversal"] = "reading" - device_id: str = Field(default="manual", min_length=1, max_length=255) + + device_id: str = Field( + default="manual", + min_length=1, + max_length=255, + ) + shelf_ids: list[UUID] = Field(default_factory=list, max_length=1000) - pages: int = Field(default=0, ge=0, le=100000) + + pages: int = Field( + default=0, + ge=0, + le=100000, + ) + coverage: list[PageCoverage] = Field(default_factory=list, max_length=1000) note: str | None = Field(default=None, max_length=10000) correction_of: UUID | None = None diff --git a/papyrus/services/acquisition/jobs.py b/papyrus/services/acquisition/jobs.py index 815452d..a2b4ec5 100644 --- a/papyrus/services/acquisition/jobs.py +++ b/papyrus/services/acquisition/jobs.py @@ -32,9 +32,12 @@ async def owned_endpoint(session: AsyncSession, owner_user_id: Any, endpoint_id: AcquisitionEndpoint.endpoint_id == endpoint_id, AcquisitionEndpoint.owner_user_id == owner_user_id ) ) + endpoint = result.scalar_one_or_none() + if endpoint is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Acquisition endpoint not found") + return endpoint @@ -72,6 +75,7 @@ async def paginated_jobs( total = await session.scalar( select(func.count()).select_from(AcquisitionJob).where(AcquisitionJob.owner_user_id == owner_user_id) ) + result = await session.execute( select(AcquisitionJob) .where(AcquisitionJob.owner_user_id == owner_user_id) @@ -160,7 +164,6 @@ async def select_job_file( job.error = None job.next_poll_at = datetime.now(UTC) job.updated_at = datetime.now(UTC) - await session.commit() await session.refresh(job) return job @@ -207,7 +210,6 @@ async def cancel_job( job.updated_at = now job.next_poll_at = None job.error = None - await session.commit() await session.refresh(job) return job @@ -234,6 +236,7 @@ async def delete_terminal_job( ) .with_for_update() ) + book = result.scalar_one_or_none() if book is not None and book.file_media_id is not None: @@ -275,7 +278,6 @@ async def retry_job_import( job.error = None job.next_poll_at = now job.updated_at = now - await session.commit() await session.refresh(job) return job @@ -307,15 +309,30 @@ async def submit_release_batch( try: release = decode_release_token(token, owner_user_id) except HTTPException as exc: - results.append(BatchSubmissionResult(index=index, job=None, error=str(exc.detail))) + results.append( + BatchSubmissionResult( + index=index, + job=None, + error=str(exc.detail), + ) + ) + continue if release.protocol != "torrent" or not release.download_url.startswith(("magnet:", "http://", "https://")): - results.append(BatchSubmissionResult(index=index, job=None, error="Release token is invalid or expired")) + results.append( + BatchSubmissionResult( + index=index, + job=None, + error="Release token is invalid or expired", + ) + ) + continue book_id = uuid4() job_id = uuid4() + book = SyncBook( book_id=book_id, owner_user_id=owner_user_id, @@ -327,6 +344,7 @@ async def submit_release_batch( } }, ) + job = AcquisitionJob( job_id=job_id, owner_user_id=owner_user_id, @@ -342,10 +360,8 @@ async def submit_release_batch( session.add(book) await session.flush() - session.add(job) await session.flush() - await session.commit() await session.refresh(job) @@ -357,6 +373,7 @@ async def submit_release_batch( _managed_download_path(endpoint.download_root, owner_user_id, job_id), tags=[f"papyrus:{job_id}"], ) + job.status = "submitted" job.submitted_at = datetime.now(UTC) job.next_poll_at = datetime.now(UTC) @@ -367,10 +384,16 @@ async def submit_release_batch( job.lease_owner = None job.lease_until = None - await session.commit() await session.refresh(job) - results.append(BatchSubmissionResult(index=index, job=job, error=None)) + + results.append( + BatchSubmissionResult( + index=index, + job=job, + error=None, + ) + ) return results @@ -383,6 +406,7 @@ def _managed_download_path(download_root: str, owner_user_id: UUID, job_id: UUID async def delete_acquisition_endpoint(session: AsyncSession, owner_user_id: Any, endpoint_id: Any) -> None: endpoint = await owned_endpoint(session, owner_user_id, endpoint_id) + active_job_id = await session.scalar( select(AcquisitionJob.job_id) .where( @@ -416,7 +440,6 @@ async def delete_acquisition_endpoint(session: AsyncSession, owner_user_id: Any, rule.enabled = False await session.flush() - await session.delete(endpoint) await session.commit() @@ -434,8 +457,10 @@ async def build_test_endpoint( stored_credentials = _credentials(stored_endpoint) credentials = dict(stored_credentials) + for field in ("api_key", "username", "password"): value = getattr(request, field) + if value is not None: credentials[field] = value.get_secret_value() @@ -468,10 +493,12 @@ async def build_test_endpoint( async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[AcquisitionJob]: """Run one rule once; callers may schedule this from their worker/cron service.""" client = await owned_endpoint(session, rule.owner_user_id, rule.download_client_id) + if client.kind in {"readarr", "sonarr", "radarr", "lidarr", "whisparr"}: filters = rule.filters or {} command = filters.get("arr_command") ids = filters.get("arr_ids", []) + if ( not isinstance(command, str) or not isinstance(ids, list) @@ -481,6 +508,7 @@ async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[Acquisi status_code=422, detail="Arr rules require filters.arr_command and filters.arr_ids", ) + job = AcquisitionJob( owner_user_id=rule.owner_user_id, endpoint_id=client.endpoint_id, @@ -488,18 +516,22 @@ async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[Acquisi title=command, download_url=f"arr-command:{command}", ) + session.add(job) + try: job.client_reference = await dispatch_arr_command(client, command, ids) job.status = "submitted" except HTTPException as exc: job.status = "failed" job.error = str(exc.detail) + rule.last_run_at = datetime.now(UTC) await session.commit() return [job] endpoint_ids = rule.endpoint_ids or [] + result = await session.execute( select(AcquisitionEndpoint).where( AcquisitionEndpoint.owner_user_id == rule.owner_user_id, @@ -507,12 +539,16 @@ async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[Acquisi AcquisitionEndpoint.enabled.is_(True), ) ) + releases = [release for endpoint in result.scalars() for release in await search_endpoint(endpoint, rule.query)] + if not releases: rule.last_run_at = datetime.now(UTC) await session.commit() return [] + selected = sorted(releases, key=lambda release: (release.seeders or 0, release.size_bytes or 0), reverse=True)[0] + job = AcquisitionJob( owner_user_id=rule.owner_user_id, endpoint_id=client.endpoint_id, @@ -520,13 +556,22 @@ async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[Acquisi title=selected.title, download_url=selected.download_url, ) + session.add(job) + try: - job.client_reference = await submit_to_client(client, selected.download_url, None, None) + job.client_reference = await submit_to_client( + client, + selected.download_url, + None, + None, + ) + job.status = "submitted" except HTTPException as exc: job.status = "failed" job.error = str(exc.detail) + rule.last_run_at = datetime.now(UTC) await session.commit() return [job] @@ -535,6 +580,7 @@ async def run_rule(session: AsyncSession, rule: AcquisitionRule) -> list[Acquisi async def run_enabled_rules(session: AsyncSession) -> None: """Run enabled rules, isolating a failed remote integration from the others.""" result = await session.execute(select(AcquisitionRule).where(AcquisitionRule.enabled.is_(True))) + for rule in result.scalars(): try: await run_rule(session, rule) diff --git a/papyrus/services/acquisition/providers.py b/papyrus/services/acquisition/providers.py index 1aa3dfd..8b89ccd 100644 --- a/papyrus/services/acquisition/providers.py +++ b/papyrus/services/acquisition/providers.py @@ -38,12 +38,14 @@ async def connect(cls, endpoint: AcquisitionEndpoint) -> Self: raise HTTPException(status_code=422, detail="Endpoint is not qBittorrent") credentials = _credentials(endpoint) + login = urlencode( { "username": credentials.get("username", ""), "password": credentials.get("password", ""), } ).encode() + response_status, headers, response_payload = await _request( _url(endpoint, "api/v2/auth/login"), method="POST", @@ -100,6 +102,7 @@ async def find_torrent( async def files(self, torrent_hash: str) -> list[QbittorrentFile]: payload = await self._get_json_array("api/v2/torrents/files", {"hash": torrent_hash}) + return [ QbittorrentFile( index=_required_int(item, "index", "qBittorrent file"), @@ -141,6 +144,7 @@ async def select_file(self, torrent_hash: str, *, selected_index: int, file_indi "priority": "1", }, ) + await self._post_form( "api/v2/torrents/resume", {"hashes": torrent_hash}, @@ -234,11 +238,14 @@ def _progress_basis_points(value: object) -> int: async def search_endpoint(endpoint: AcquisitionEndpoint, query: str) -> list[ReleaseCandidate]: """Search Prowlarr or a Torznab-compatible torrent indexer.""" credentials = _credentials(endpoint) + if endpoint.kind == "prowlarr": request_url = _url(endpoint, f"api/v1/search?{urlencode({'query': query})}") response_status, _, payload = await _request(request_url, headers={"X-Api-Key": credentials.get("api_key", "")}) + if response_status >= 400: raise HTTPException(status_code=502, detail="Prowlarr search failed") + data = _json_array(payload, "Prowlarr") releases: list[ReleaseCandidate] = [] @@ -271,8 +278,10 @@ async def search_endpoint(endpoint: AcquisitionEndpoint, query: str) -> list[Rel params = urlencode({"t": "search", "q": query, "apikey": credentials.get("api_key", "")}) response_status, _, payload = await _request(_url(endpoint, f"api?{params}")) + if response_status >= 400: raise HTTPException(status_code=502, detail=f"{endpoint.kind.title()} search failed") + return _parse_torznab(payload, endpoint.name) @@ -281,15 +290,20 @@ def _parse_torznab(payload: bytes, indexer: str) -> list[ReleaseCandidate]: root = ElementTree.fromstring(payload) except ElementTree.ParseError as exc: raise HTTPException(status_code=502, detail="Indexer returned invalid XML") from exc + releases: list[ReleaseCandidate] = [] + for item in root.findall(".//item"): enclosure = item.find("enclosure") link = (enclosure.get("url") if enclosure is not None else None) or item.findtext("link") + if not link: continue + attrs = {child.attrib.get("name"): child.attrib.get("value") for child in item if child.tag.endswith("attr")} size = attrs.get("size") seeders = attrs.get("seeders") + releases.append( ReleaseCandidate( title=item.findtext("title") or "Untitled", @@ -302,11 +316,13 @@ def _parse_torznab(payload: bytes, indexer: str) -> list[ReleaseCandidate]: format_hints=_format_hints(item.findtext("title") or "", link), ) ) + return releases def _format_hints(title: str, download_url: str) -> list[str]: searchable = f"{title} {download_url}".lower() + return [ extension for extension in ("epub", "pdf", "mobi", "azw3", "txt", "cbr", "cbz") @@ -328,11 +344,20 @@ async def submit_to_client( ) -> str | None: """Submit a magnet or torrent URL to a supported BitTorrent client.""" if endpoint.kind == "qbittorrent": - return await _submit_qbittorrent(endpoint, download_url, category, save_path, tags=tags) + return await _submit_qbittorrent( + endpoint, + download_url, + category, + save_path, + tags=tags, + ) + if endpoint.kind == "transmission": return await _submit_transmission(endpoint, download_url, save_path) + if endpoint.kind == "deluge": return await _submit_deluge(endpoint, download_url, save_path) + raise HTTPException(status_code=422, detail="Endpoint is not a download client") @@ -352,6 +377,7 @@ async def dispatch_arr_command(endpoint: AcquisitionEndpoint, command: str, ids: "lidarr": {"ArtistSearch", "AlbumSearch", "MissingAlbumSearch"}, "whisparr": {"SeriesSearch", "EpisodeSearch", "MissingEpisodeSearch"}, } + if command not in allowed_commands[endpoint.kind]: raise HTTPException(status_code=422, detail="Command is not supported by this Servarr application") @@ -367,7 +393,9 @@ async def dispatch_arr_command(endpoint: AcquisitionEndpoint, command: str, ids: "AlbumSearch": "albumIds", "MissingAlbumSearch": "artistIds", }[command] + payload: dict[str, object] = {"name": command} + if ids: payload[id_field] = ids[0] if id_field in {"seriesId"} else ids @@ -377,8 +405,10 @@ async def dispatch_arr_command(endpoint: AcquisitionEndpoint, command: str, ids: headers={"Content-Type": "application/json", "X-Api-Key": _credentials(endpoint).get("api_key", "")}, body=json.dumps(payload).encode(), ) + if response_status >= 400: raise HTTPException(status_code=502, detail=f"{endpoint.kind.title()} command failed") + response = _json_object(response_payload, endpoint.kind.title()) return str(response.get("id")) if response.get("id") is not None else None @@ -392,24 +422,32 @@ async def _submit_qbittorrent( tags: list[str] | None = None, ) -> str | None: credentials = _credentials(endpoint) + login = urlencode( {"username": credentials.get("username", ""), "password": credentials.get("password", "")} ).encode() + response_status, headers, response_payload = await _request( _url(endpoint, "api/v2/auth/login"), method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, body=login, ) + if not _qbittorrent_login_succeeded(response_status, response_payload): raise HTTPException(status_code=502, detail="qBittorrent authentication failed") + payload = {"urls": download_url} + if category: payload["category"] = category + if save_path: payload["savepath"] = save_path + if tags: payload["tags"] = ",".join(tags) + response_status, _, _ = await _request( _url(endpoint, "api/v2/torrents/add"), method="POST", @@ -419,41 +457,59 @@ async def _submit_qbittorrent( }, body=urlencode(payload).encode(), ) + if response_status >= 400: raise HTTPException(status_code=502, detail="qBittorrent rejected the release") + return None async def _submit_transmission(endpoint: AcquisitionEndpoint, download_url: str, save_path: str | None) -> str | None: credentials = _credentials(endpoint) arguments: dict[str, str] = {"filename": download_url} + if save_path: arguments["download-dir"] = save_path + body = json.dumps({"method": "torrent-add", "arguments": arguments}).encode() headers = {"Content-Type": "application/json"} + if credentials.get("username"): token = base64.b64encode(f"{credentials['username']}:{credentials.get('password', '')}".encode()).decode() headers["Authorization"] = f"Basic {token}" + response_status, response_headers, payload = await _request( - _url(endpoint, "transmission/rpc"), method="POST", headers=headers, body=body + _url(endpoint, "transmission/rpc"), + method="POST", + headers=headers, + body=body, ) + if response_status == 409: headers["X-Transmission-Session-Id"] = response_headers.get("X-Transmission-Session-Id", "") + response_status, _, payload = await _request( - _url(endpoint, "transmission/rpc"), method="POST", headers=headers, body=body + _url(endpoint, "transmission/rpc"), + method="POST", + headers=headers, + body=body, ) + if response_status >= 400: raise HTTPException(status_code=502, detail="Transmission rejected the release") response = _json_object(payload, "Transmission") + if response.get("result") != "success": raise HTTPException(status_code=502, detail="Transmission rejected the release") response_arguments = response.get("arguments") + if not isinstance(response_arguments, dict): raise HTTPException(status_code=502, detail="Transmission returned an invalid response") torrent = response_arguments.get("torrent-added") or response_arguments.get("torrent-duplicate") + if not isinstance(torrent, dict): return None @@ -465,9 +521,14 @@ async def _submit_deluge(endpoint: AcquisitionEndpoint, download_url: str, save_ credentials = _credentials(endpoint) headers = {"Content-Type": "application/json"} login = json.dumps({"method": "auth.login", "params": [credentials.get("password", "")], "id": 1}).encode() + response_status, response_headers, login_payload = await _request( - _url(endpoint, "json"), method="POST", headers=headers, body=login + _url(endpoint, "json"), + method="POST", + headers=headers, + body=login, ) + if response_status >= 400: raise HTTPException(status_code=502, detail="Deluge authentication failed") @@ -480,7 +541,14 @@ async def _submit_deluge(endpoint: AcquisitionEndpoint, download_url: str, save_ method = "core.add_torrent_magnet" if download_url.startswith("magnet:") else "core.add_torrent_url" body = json.dumps({"method": method, "params": [download_url, options], "id": 2}).encode() headers["Cookie"] = response_headers.get("Set-Cookie", "").split(";", 1)[0] - response_status, _, payload = await _request(_url(endpoint, "json"), method="POST", headers=headers, body=body) + + response_status, _, payload = await _request( + _url(endpoint, "json"), + method="POST", + headers=headers, + body=body, + ) + if response_status >= 400: raise HTTPException(status_code=502, detail="Deluge rejected the release") @@ -500,6 +568,7 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: _url(endpoint, "api/v1/system/status"), headers={"X-Api-Key": credentials.get("api_key", "")}, ) + if response_status >= 400: raise HTTPException(status_code=502, detail="Prowlarr connection test failed") @@ -509,6 +578,7 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: if endpoint.kind == "torznab": params = urlencode({"t": "caps", "apikey": credentials.get("api_key", "")}) response_status, _, payload = await _request(_url(endpoint, f"api?{params}")) + if response_status >= 400: raise HTTPException(status_code=502, detail="Torznab connection test failed") @@ -516,6 +586,7 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: ElementTree.fromstring(payload) except ElementTree.ParseError as exc: raise HTTPException(status_code=502, detail="Torznab returned invalid XML") from exc + return if endpoint.kind in {"readarr", "sonarr", "radarr", "lidarr", "whisparr"}: @@ -523,6 +594,7 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: _url(endpoint, "api/v3/system/status"), headers={"X-Api-Key": credentials.get("api_key", "")}, ) + if response_status >= 400: raise HTTPException(status_code=502, detail=f"{endpoint.kind.title()} connection test failed") @@ -533,19 +605,23 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: login = urlencode( {"username": credentials.get("username", ""), "password": credentials.get("password", "")} ).encode() + response_status, _, payload = await _request( _url(endpoint, "api/v2/auth/login"), method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, body=login, ) + if not _qbittorrent_login_succeeded(response_status, payload): raise HTTPException(status_code=502, detail="qBittorrent authentication failed") + return if endpoint.kind == "transmission": body = json.dumps({"method": "session-get", "arguments": {}}).encode() headers = {"Content-Type": "application/json"} + if credentials.get("username"): token = base64.b64encode(f"{credentials['username']}:{credentials.get('password', '')}".encode()).decode() headers["Authorization"] = f"Basic {token}" @@ -556,26 +632,32 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: headers=headers, body=body, ) + if response_status == 409: headers["X-Transmission-Session-Id"] = response_headers.get("X-Transmission-Session-Id", "") + response_status, _, payload = await _request( _url(endpoint, "transmission/rpc"), method="POST", headers=headers, body=body, ) + if response_status >= 400 or _json_object(payload, "Transmission").get("result") != "success": raise HTTPException(status_code=502, detail="Transmission connection test failed") + return if endpoint.kind == "deluge": login = json.dumps({"method": "auth.login", "params": [credentials.get("password", "")], "id": 1}).encode() + response_status, _, payload = await _request( _url(endpoint, "json"), method="POST", headers={"Content-Type": "application/json"}, body=login, ) + if response_status >= 400: raise HTTPException(status_code=502, detail="Deluge authentication failed") @@ -583,6 +665,7 @@ async def test_endpoint_connection(endpoint: AcquisitionEndpoint) -> None: _require_deluge_result(payload) except HTTPException as exc: raise HTTPException(status_code=502, detail="Deluge authentication failed") from exc + return raise HTTPException(status_code=422, detail="Endpoint kind is not supported") diff --git a/papyrus/services/acquisition/transport.py b/papyrus/services/acquisition/transport.py index 4759a7b..5b041d7 100644 --- a/papyrus/services/acquisition/transport.py +++ b/papyrus/services/acquisition/transport.py @@ -24,7 +24,13 @@ async def _request( """Perform a bounded blocking HTTP request off the event loop.""" def send() -> tuple[int, dict[str, str], bytes]: - request = Request(url, data=body, headers=headers or {}, method=method) + request = Request( + url, + data=body, + headers=headers or {}, + method=method, + ) + try: with urlopen(request, timeout=15) as response: # noqa: S310 - user-owned self-hosted integrations return response.status, dict(response.headers.items()), response.read(5_000_000) @@ -39,8 +45,10 @@ def send() -> tuple[int, dict[str, str], bytes]: def _credentials(endpoint: AcquisitionEndpoint) -> dict[str, str]: credentials = endpoint.credentials or {} encrypted = credentials.get("encrypted") + if encrypted is None: return credentials + try: return decrypt_secret_payload(encrypted) except ValueError as exc: @@ -56,23 +64,29 @@ def _json_value(payload: bytes, integration: str) -> object: def _json_object(payload: bytes, integration: str) -> dict[str, object]: value = _json_value(payload, integration) + if not isinstance(value, dict): raise HTTPException(status_code=502, detail=f"{integration} returned an invalid response") + return value def _json_array(payload: bytes, integration: str) -> list[dict[str, object]]: value = _json_value(payload, integration) + if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): raise HTTPException(status_code=502, detail=f"{integration} returned an invalid response") + return value def _require_deluge_result(payload: bytes) -> object: response = _json_object(payload, "Deluge") result = response.get("result") + if response.get("error") is not None or result is None or result is False: raise HTTPException(status_code=502, detail="Deluge rejected the request") + return result diff --git a/papyrus/services/acquisition_monitor.py b/papyrus/services/acquisition_monitor.py index 1d81e6a..b265efa 100644 --- a/papyrus/services/acquisition_monitor.py +++ b/papyrus/services/acquisition_monitor.py @@ -42,6 +42,7 @@ async def pause(self, torrent_hash: str) -> None: ... [AcquisitionEndpoint], Awaitable[QbittorrentMonitorClient], ] + Sleep = Callable[[float], Awaitable[None]] @@ -54,6 +55,7 @@ async def claim_due_jobs( ) -> list[UUID]: claimed_at = now or datetime.now(UTC) lease_until = claimed_at + timedelta(seconds=get_settings().acquisition_monitor_lease_seconds) + result = await session.execute( select(AcquisitionJob) .join( @@ -82,6 +84,7 @@ async def claim_due_jobs( .with_for_update(skip_locked=True) .limit(limit) ) + jobs = result.scalars().all() for job in jobs: @@ -89,7 +92,6 @@ async def claim_due_jobs( job.lease_until = lease_until await session.commit() - return [job.job_id for job in jobs] @@ -123,6 +125,7 @@ async def run_monitor( logger.exception("Acquisition monitor cycle failed") settings = get_settings() + delay = ( settings.acquisition_monitor_active_interval_seconds if job_ids @@ -147,6 +150,7 @@ async def process_claimed_jobs( result = await session.execute( select(AcquisitionJob.job_id, AcquisitionJob.endpoint_id).where(AcquisitionJob.job_id.in_(job_ids)) ) + jobs_by_endpoint: dict[UUID, list[UUID]] = defaultdict(list) jobs_without_endpoint: list[UUID] = [] @@ -188,6 +192,7 @@ async def process_claimed_jobs( client = await connect(endpoint) except Exception as exc: await session.rollback() + for job_id in endpoint_job_ids: await _reschedule_job( session, @@ -257,10 +262,12 @@ async def process_job( return observed_at = now or datetime.now(UTC) + torrent = await client.find_torrent( tag=f"papyrus:{job.job_id}", torrent_hash=job.client_hash, ) + job.client_hash = torrent.hash job.client_state = torrent.state job.progress_basis_points = torrent.progress_basis_points @@ -296,7 +303,6 @@ async def process_job( job.status = "downloading" job.next_poll_at = _next_active_poll(observed_at) _release_lease(job) - await session.commit() return @@ -311,11 +317,9 @@ async def process_job( if len(candidates) > 1: await client.pause(torrent.hash) - job.status = "needs_file_selection" job.next_poll_at = None _release_lease(job) - await session.commit() return @@ -326,7 +330,6 @@ async def process_job( job.selected_file_path = selected.name job.next_poll_at = _next_active_poll(observed_at) _release_lease(job) - await session.commit() return @@ -336,6 +339,7 @@ async def process_job( job.job_id, selected.name, ) + job.status = "importing" job.selected_file_path = selected.name job.next_poll_at = None @@ -383,7 +387,6 @@ async def _mark_failed_job( job.next_poll_at = None job.updated_at = observed_at _release_lease(job) - await session.commit() @@ -409,7 +412,6 @@ async def _reschedule_job( job.next_poll_at = observed_at + _retry_delay(job.retry_count) job.updated_at = observed_at _release_lease(job) - await session.commit() @@ -441,7 +443,6 @@ async def _reschedule_missing_torrent( job.next_poll_at = observed_at + _retry_delay(job.retry_count) _release_lease(job) - await session.commit() @@ -457,6 +458,7 @@ async def _locked_claimed_job( .with_for_update() .execution_options(populate_existing=True) ) + job = result.scalar_one_or_none() if job is None or job.status not in ACTIVE_JOB_STATUSES: @@ -498,6 +500,7 @@ def _retry_delay(attempt: int) -> timedelta: get_settings().acquisition_monitor_active_interval_seconds, 1, ) + seconds = min(300, base_seconds * (2 ** min(max(attempt - 1, 0), 8))) return timedelta(seconds=seconds) diff --git a/papyrus/services/auth/_core.py b/papyrus/services/auth/_core.py index c8ef003..ffcf86a 100644 --- a/papyrus/services/auth/_core.py +++ b/papyrus/services/auth/_core.py @@ -151,6 +151,7 @@ async def _consume_exchange_code(session: AsyncSession, code: str, purpose: str) AuthExchangeCode.used_at.is_(None), ) ) + exchange_code = result.scalar_one_or_none() if exchange_code is None or exchange_code.expires_at <= _now(): @@ -190,6 +191,7 @@ async def _consume_email_action_token(session: AsyncSession, token: str, action_ EmailActionToken.used_at.is_(None), ) ) + email_token = result.scalar_one_or_none() if email_token is None or email_token.expires_at <= _now(): @@ -239,6 +241,7 @@ def _build_app_url(path: str, query: dict[str, str] | None = None) -> str | None def _verification_email_body(token: str) -> str: verify_url = _build_api_url("/auth/verify-email") + lines = [ "Verify your Papyrus email address.", "", @@ -259,6 +262,7 @@ def _verification_email_body(token: str) -> str: def _password_reset_email_body(token: str) -> str: settings = get_settings() reset_url = _build_app_url("/reset-password", {"token": token}) + lines = [ "Reset your Papyrus password.", "", diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py index dba3bd0..5c6b0f2 100644 --- a/papyrus/services/auth/google.py +++ b/papyrus/services/auth/google.py @@ -124,6 +124,7 @@ def _configured_base_hosts() -> set[str]: continue hostname = urlsplit(base_url).hostname + if hostname is not None: hosts.add(hostname.lower()) diff --git a/papyrus/services/goal_history.py b/papyrus/services/goal_history.py index df9d8f3..1365073 100644 --- a/papyrus/services/goal_history.py +++ b/papyrus/services/goal_history.py @@ -20,7 +20,14 @@ async def preserve_goal_history(session: AsyncSession, user_id: UUID, row: SyncR stop = min(end, boundary) at = stop - timedelta(microseconds=1) rule = rule_at(goal, at) - since_epoch = start - datetime(1970, 1, 1, tzinfo=UTC) + + since_epoch = start - datetime( + 1970, + 1, + 1, + tzinfo=UTC, + ) + microseconds = (since_epoch.days * 86400 + since_epoch.seconds) * 1000000 + since_epoch.microseconds identifier = uuid5(NAMESPACE_URL, f"papyrus:goal-period:{goal.id}:{microseconds}") existing = await session.get(SyncGoalPeriod, identifier) @@ -30,7 +37,15 @@ async def preserve_goal_history(session: AsyncSession, user_id: UUID, row: SyncR archived = end > boundary if archived: - revisions.append(GoalRule(at=stop, target=rule.target, title=rule.title, active=False, archived=True)) + revisions.append( + GoalRule( + at=stop, + target=rule.target, + title=rule.title, + active=False, + archived=True, + ) + ) definition = goal.model_copy( update={ @@ -44,9 +59,21 @@ async def preserve_goal_history(session: AsyncSession, user_id: UUID, row: SyncR "rules": revisions, } ) - record = PeriodRecord(id=identifier, goal_id=goal.id, definition=definition) - session.add(SyncGoalPeriod(id=identifier, owner_user_id=user_id, payload=record.model_dump(mode="json"))) + record = PeriodRecord( + id=identifier, + goal_id=goal.id, + definition=definition, + ) + + session.add( + SyncGoalPeriod( + id=identifier, + owner_user_id=user_id, + payload=record.model_dump(mode="json"), + ) + ) + await session.flush() if not goal.is_recurring or end >= boundary: diff --git a/papyrus/services/goal_progress.py b/papyrus/services/goal_progress.py index f0fd589..90e8aa0 100644 --- a/papyrus/services/goal_progress.py +++ b/papyrus/services/goal_progress.py @@ -13,7 +13,14 @@ def calendar_period(goal: GoalDefinition, at: datetime) -> tuple[datetime, datet at = min(at, goal.rules[-1].at) local = at.astimezone(ZoneInfo(goal.timezone)) - day = local.replace(hour=0, minute=0, second=0, microsecond=0) + + day = local.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + period = goal.time_period if period == "daily": @@ -35,7 +42,11 @@ def calendar_period(goal: GoalDefinition, at: datetime) -> tuple[datetime, datet def rule_at(goal: GoalDefinition, at: datetime) -> GoalRule: rule = GoalRule( - at=goal.created_at, target=goal.target_value, title=goal.title, active=goal.is_active, archived=goal.is_archived + at=goal.created_at, + target=goal.target_value, + title=goal.title, + active=goal.is_active, + archived=goal.is_archived, ) for revision in goal.rules: @@ -75,6 +86,7 @@ def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> start, end = ( calendar_period(goal, now) if goal.is_recurring and now >= goal.start_date else (goal.start_date, goal.end_date) ) + cutoff, limit = max(start, goal.created_at), min(end, now) rules = [revision.at for revision in goal.rules if cutoff < revision.at < limit] zone = ZoneInfo(goal.timezone) @@ -94,11 +106,13 @@ def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> continue contributed = False + point = ( activity.end_time - timedelta(microseconds=1) if activity.kind == "reading" and activity.end_time > activity.start_time else activity.end_time ) + point_rule = rule_at(goal, point) if cutoff <= point < end and point <= now and point_rule.active and not point_rule.archived: @@ -122,7 +136,14 @@ def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> while cursor < stop_at: local = cursor.astimezone(zone) - midnight = local.replace(hour=0, minute=0, second=0, microsecond=0) + + midnight = local.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + stop = min((midnight + timedelta(days=1)).astimezone(UTC), stop_at) stop = min([stop, *(boundary for boundary in rules if cursor < boundary < stop)]) active = rule_at(goal, cursor) @@ -141,15 +162,20 @@ def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> pages = manual_pages + sum(union_length(intervals) * scales[key] for key, intervals in coverage.items()) seconds = int(sum(daily_seconds.values())) rule = rule_at(goal, end - timedelta(microseconds=1) if end <= now else now) + value = { "books_count": len(finished), "pages_count": int(pages), "reading_time": seconds // 60, "reading_days": len(qualified), }[goal.goal_type] - fractional = ( - seconds / 60 if goal.goal_type == "reading_time" else pages if goal.goal_type == "pages_count" else value - ) + + if goal.goal_type == "reading_time": + fractional = seconds / 60 + elif goal.goal_type == "pages_count": + fractional = pages + else: + fractional = value return { "current_value": value, diff --git a/papyrus/services/goals.py b/papyrus/services/goals.py index d38cb2d..730b5a2 100644 --- a/papyrus/services/goals.py +++ b/papyrus/services/goals.py @@ -60,7 +60,12 @@ async def list_goals(session: AsyncSession, user_id: UUID, active: bool | None) async def get_definition(session: AsyncSession, user_id: UUID, goal_id: UUID) -> GoalDefinition: - row = await owned_row(session, user_id, "reading_goals", goal_id) + row = await owned_row( + session, + user_id, + "reading_goals", + goal_id, + ) if row is None: raise NotFoundError("Goal was not found") @@ -77,6 +82,7 @@ async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalR try: zone = ZoneInfo(request.timezone) + definition = GoalDefinition( id=uuid4(), title=request.title, @@ -94,7 +100,13 @@ async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalR book_ids=request.book_ids, minimum_minutes=request.minimum_minutes, is_recurring=request.is_recurring and request.time_period != "custom", - rules=[GoalRule(at=now, title=request.title, target=request.target_value)], + rules=[ + GoalRule( + at=now, + title=request.title, + target=request.target_value, + ) + ], ) except (PayloadError, ZoneInfoNotFoundError) as exc: raise ValidationError(str(exc)) from exc @@ -114,6 +126,7 @@ async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalR ) ], ) + return goal_response(definition, []) @@ -129,13 +142,22 @@ async def update_goal(session: AsyncSession, user_id: UUID, goal_id: UUID, reque if definition.rules else definition.created_at + timedelta(microseconds=1), ) + changes = request.model_dump(exclude_unset=True, exclude={"end_date"}) + if changes.get("is_archived"): changes["is_active"] = False + updated = definition.model_copy(update=changes) + rule = GoalRule( - at=now, title=updated.title, target=updated.target_value, active=updated.is_active, archived=updated.is_archived + at=now, + title=updated.title, + target=updated.target_value, + active=updated.is_active, + archived=updated.is_archived, ) + initial = definition.rules or [ GoalRule( at=definition.created_at, @@ -145,6 +167,7 @@ async def update_goal(session: AsyncSession, user_id: UUID, goal_id: UUID, reque archived=definition.is_archived, ) ] + updated.rules = [*initial, rule] await apply_powersync_upload_batch( @@ -152,15 +175,28 @@ async def update_goal(session: AsyncSession, user_id: UUID, goal_id: UUID, reque user_id, [ PowerSyncCrudMutation( - table="reading_goals", op="PATCH", id=str(goal_id), op_data={"payload": updated.model_dump(mode="json")} + table="reading_goals", + op="PATCH", + id=str(goal_id), + op_data={"payload": updated.model_dump(mode="json")}, ) ], ) + return goal_response(updated, await activities(session, user_id)) async def delete_goal(session: AsyncSession, user_id: UUID, goal_id: UUID) -> None: await get_definition(session, user_id, goal_id) + await apply_powersync_upload_batch( - session, user_id, [PowerSyncCrudMutation(table="reading_goals", op="DELETE", id=str(goal_id))] + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_goals", + op="DELETE", + id=str(goal_id), + ) + ], ) diff --git a/papyrus/services/library_sync.py b/papyrus/services/library_sync.py index a3a33cb..b8ece19 100644 --- a/papyrus/services/library_sync.py +++ b/papyrus/services/library_sync.py @@ -40,6 +40,7 @@ "book_shelves": SyncBookShelf, "book_tags": SyncBookTag, } + PRIMARY_KEYS = { "reading_goals": "id", "reading_activities": "id", @@ -53,6 +54,7 @@ "book_shelves": "id", "book_tags": "id", } + REFERENCES = {"book_id": "books", "shelf_id": "shelves", "tag_id": "tags", "parent_shelf_id": "shelves"} MEMBERSHIPS = {"book_shelves", "book_tags"} @@ -76,14 +78,32 @@ async def tombstoned(session: AsyncSession, user_id: UUID, table: str, row_id: U async def mark_deleted(session: AsyncSession, user_id: UUID, table: str, row_id: UUID) -> None: - if not await tombstoned(session, user_id, table, row_id): - session.add(SyncTombstone(table_name=table, entity_id=row_id, owner_user_id=user_id)) + if not await tombstoned( + session, + user_id, + table, + row_id, + ): + session.add( + SyncTombstone( + table_name=table, + entity_id=row_id, + owner_user_id=user_id, + ) + ) + await session.flush() async def delete_entity(session: AsyncSession, user_id: UUID, table: str, row_id: UUID, row: Any) -> list[Path]: """Record deletion before cascading so delayed entity writes cannot revive it.""" - await mark_deleted(session, user_id, table, row_id) + await mark_deleted( + session, + user_id, + table, + row_id, + ) + paths: list[Path] = [] if row is None: @@ -95,7 +115,12 @@ async def delete_entity(session: AsyncSession, user_id: UUID, table: str, row_id result = await session.execute(select(model).where(model.book_id == row_id)) for child in result.scalars(): - await mark_deleted(session, user_id, child_table, getattr(child, PRIMARY_KEYS[child_table])) + await mark_deleted( + session, + user_id, + child_table, + getattr(child, PRIMARY_KEYS[child_table]), + ) paths = await media_service.delete_book_media(session, user_id, row_id) @@ -133,8 +158,20 @@ async def validate_references( if ref is None: continue - parent = await owned_row(session, user_id, target, ref) - deleted_parent = await tombstoned(session, user_id, target, ref) + parent = await owned_row( + session, + user_id, + target, + ref, + ) + + deleted_parent = await tombstoned( + session, + user_id, + target, + ref, + ) + stale |= deleted_parent if parent is None and not deleted_parent and not deleting: @@ -148,8 +185,14 @@ async def validate_references( raise ValidationError("Shelf hierarchy cannot contain cycles") visited.add(parent.shelf_id) + parent = ( - await owned_row(session, user_id, "shelves", parent.parent_shelf_id) + await owned_row( + session, + user_id, + "shelves", + parent.parent_shelf_id, + ) if parent.parent_shelf_id is not None else None ) @@ -196,9 +239,19 @@ async def apply_library_mutation( if is_membership: payload = membership_values(table, mutation.id, payload) - row = await owned_row(session, user_id, table, row_id) - - if not is_membership and await tombstoned(session, user_id, table, uuid_value(row_id, "id")): + row = await owned_row( + session, + user_id, + table, + row_id, + ) + + if not is_membership and await tombstoned( + session, + user_id, + table, + uuid_value(row_id, "id"), + ): return 0, [] if mutation.op.upper() == "PATCH" and row is None: @@ -214,23 +267,53 @@ async def apply_library_mutation( await preserve_goal_history(session, user_id, row) if is_membership: - await validate_references(session, user_id, table, row_id, payload, row, deleting=True) + await validate_references( + session, + user_id, + table, + row_id, + payload, + row, + deleting=True, + ) if row is not None: await session.execute(delete(model).where(model.id == row_id)) return int(row is not None), [] - paths = await delete_entity(session, user_id, table, uuid_value(row_id, "id"), row) + paths = await delete_entity( + session, + user_id, + table, + uuid_value(row_id, "id"), + row, + ) + return int(row is not None), paths if table in {"reading_goals", "reading_activities", "goal_periods"}: from papyrus.services.tracking_validation import validate_tracking_mutation - payload = await validate_tracking_mutation(session, user_id, table, row_id, payload, row) + payload = await validate_tracking_mutation( + session, + user_id, + table, + row_id, + payload, + row, + ) values = {key: convert_value(model.__table__.columns[key], value) for key, value in payload.items()} - stale_parent = await validate_references(session, user_id, table, row_id, values, row) + + stale_parent = await validate_references( + session, + user_id, + table, + row_id, + values, + row, + ) if stale_parent: return 0, [] @@ -252,7 +335,12 @@ async def apply_library_mutation( for key, kind in (("file_media_id", "book_file"), ("cover_media_id", "cover_image")): if key in values: values[key] = await media_service.validate_media_reference( - session, user_id, uuid_value(row_id, "id"), values[key], field_name=key, expected_kind=kind + session, + user_id, + uuid_value(row_id, "id"), + values[key], + field_name=key, + expected_kind=kind, ) if row is None: diff --git a/papyrus/services/media.py b/papyrus/services/media.py index 53f0f60..e1ed681 100644 --- a/papyrus/services/media.py +++ b/papyrus/services/media.py @@ -114,22 +114,23 @@ async def _persist_media( raise ValidationError("Unsupported media kind") await _lock_user_uploads(session, user_id) + book = ( await session.execute(select(SyncBook).where(SyncBook.book_id == book_id).with_for_update()) ).scalar_one_or_none() + if book is None: raise NotFoundError("Book was not found") + if book.owner_user_id != user_id: raise ForbiddenError("Cannot access another user's book") extension = _extension(filename) _validate_media_type(kind, extension, content_type) - existing = await _existing_asset_for_kind(session, book, kind) used = await _used_bytes(session, user_id) used_without_existing = used - (existing.size_bytes if existing is not None else 0) quota = get_settings().file_storage_quota_bytes - asset_id = uuid4() storage_path = f"{user_id}/{book_id}/{asset_id}.{extension}" absolute_path = media_root() / storage_path @@ -154,11 +155,13 @@ async def _persist_media( sha256=sha256_hex, storage_path=storage_path, ) + if existing is not None: await session.delete(existing) await session.flush() session.add(asset) + if kind == "book_file": book.file_media_id = asset.asset_id else: @@ -168,36 +171,46 @@ async def _persist_media( before_commit(asset) await session.commit() + if existing is not None: delete_physical_file(existing) + await session.refresh(asset) return asset except Exception: await session.rollback() _delete_path(temp_path) + if final_file_written: _delete_path(absolute_path) + _delete_empty_parent_dirs(absolute_path.parent, stop_at=media_root()) raise async def get_owned_asset(session: AsyncSession, user_id: UUID, asset_id: UUID) -> MediaAsset: asset = await session.get(MediaAsset, asset_id) + if asset is None: raise NotFoundError("Media asset was not found") + if asset.owner_user_id != user_id: raise NotFoundError("Media asset was not found") + return asset async def delete_media(session: AsyncSession, user_id: UUID, asset_id: UUID) -> None: asset = await get_owned_asset(session, user_id, asset_id) book = await session.get(SyncBook, asset.book_id) + if book is not None: if book.file_media_id == asset.asset_id: book.file_media_id = None + if book.cover_media_id == asset.asset_id: book.cover_media_id = None + await session.delete(asset) await session.commit() delete_physical_file(asset) @@ -207,10 +220,13 @@ async def delete_book_media(session: AsyncSession, user_id: UUID, book_id: UUID) result = await session.execute( select(MediaAsset).where(MediaAsset.owner_user_id == user_id, MediaAsset.book_id == book_id) ) + deleted_paths: list[Path] = [] + for asset in result.scalars(): deleted_paths.append(asset_path(asset)) await session.delete(asset) + return deleted_paths @@ -225,13 +241,18 @@ async def validate_media_reference( ) -> UUID | None: if asset_id is None: return None + asset = await session.get(MediaAsset, asset_id) + if asset is None: raise ValidationError(f"{field_name} was not found") + if asset.owner_user_id != user_id or asset.book_id != book_id: raise ForbiddenError(f"{field_name} does not belong to this book") + if asset.kind != expected_kind: raise ValidationError(f"{field_name} has the wrong media kind") + return asset.asset_id @@ -243,6 +264,7 @@ async def _used_bytes(session: AsyncSession, user_id: UUID) -> int: result = await session.execute( select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(MediaAsset.owner_user_id == user_id) ) + return int(result.scalar_one()) @@ -263,8 +285,10 @@ async def _write_upload_to_temp_file( with temp_path.open("wb") as output: while chunk := await file.read(UPLOAD_CHUNK_SIZE): size_bytes += len(chunk) + if size_bytes > quota_remaining: raise ConflictError("Storage quota exceeded") + hasher.update(chunk) output.write(chunk) @@ -307,14 +331,17 @@ async def _existing_asset_for_kind(session: AsyncSession, book: SyncBook, kind: def _extension(filename: str) -> str: extension = Path(filename).suffix.lower().lstrip(".") + if not extension: raise ValidationError("Uploaded file must include a file extension") + return extension def _validate_media_type(kind: str, extension: str, content_type: str) -> None: if kind == "book_file" and extension not in BOOK_EXTENSIONS: raise ValidationError("Unsupported book file type") + if kind == "cover_image" and (extension not in COVER_EXTENSIONS or not content_type.startswith("image/")): raise ValidationError("Unsupported cover image type") @@ -336,9 +363,11 @@ def _delete_path(path: Path) -> None: def _delete_empty_parent_dirs(path: Path, *, stop_at: Path) -> None: current = path stop = stop_at.resolve() + while current.resolve() != stop: try: current.rmdir() except OSError: return + current = current.parent diff --git a/papyrus/services/opds.py b/papyrus/services/opds.py index 3d1ada1..8471ef0 100644 --- a/papyrus/services/opds.py +++ b/papyrus/services/opds.py @@ -28,7 +28,12 @@ class OpdsRelayError(AppError): def __init__(self, message: str, status_code: int = 400, *, retryable: bool = False) -> None: - super().__init__(message, code="OPDS_RELAY_ERROR", status_code=status_code, details={"retryable": retryable}) + super().__init__( + message, + code="OPDS_RELAY_ERROR", + status_code=status_code, + details={"retryable": retryable}, + ) def _parse_url(value: str) -> SplitResult: @@ -37,6 +42,7 @@ def _parse_url(value: str) -> SplitResult: raise ValueError parts = urlsplit(value) + if ( parts.scheme not in {"http", "https"} or not parts.hostname @@ -59,14 +65,22 @@ def _origin(parts: SplitResult) -> tuple[str, str | None, int]: def _resolve_public(parts: SplitResult, deadline: float) -> str: host = parts.hostname or "" allowed = get_settings().opds_relay_allowed_hosts + if allowed and host.lower() not in {item.lower() for item in allowed}: raise OpdsRelayError("This catalog host is not enabled on the Papyrus server.", 403) if not _dns_slots.acquire(blocking=False): raise OpdsRelayError("The catalog relay is busy. Please retry shortly.", 503, retryable=True) - future = _dns_pool.submit(socket.getaddrinfo, host, _origin(parts)[2], type=socket.SOCK_STREAM) + future = _dns_pool.submit( + socket.getaddrinfo, + host, + _origin(parts)[2], + type=socket.SOCK_STREAM, + ) + future.add_done_callback(lambda _: _dns_slots.release()) + try: addresses = future.result(timeout=max(0, min(_TIMEOUT, deadline - time.monotonic()))) except TimeoutError: @@ -78,6 +92,7 @@ def _resolve_public(parts: SplitResult, deadline: float) -> str: for address in addresses: ip = ipaddress.ip_address(address[4][0]) + if not ip.is_global or ip.is_multicast or (isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped): raise OpdsRelayError("The relay can only access public catalog addresses.") @@ -97,6 +112,7 @@ def __init__(self, parts: SplitResult, address: str) -> None: def connect(self) -> None: self.sock = socket.create_connection((self._address, self.port), timeout=_TIMEOUT) self._network_socket = self.sock + if self._aborted: self.abort() raise TimeoutError @@ -104,9 +120,13 @@ def connect(self) -> None: if self._tls: try: self.sock = ssl.create_default_context().wrap_socket( - self.sock, server_hostname=self.host, do_handshake_on_connect=False + self.sock, + server_hostname=self.host, + do_handshake_on_connect=False, ) + self._network_socket = self.sock + if self._aborted: self.abort() raise TimeoutError @@ -119,6 +139,7 @@ def connect(self) -> None: def abort(self) -> None: """Interrupt blocking headers, TLS, or chunk framing when the watchdog fires.""" self._aborted = True + if self._network_socket is not None: with suppress(OSError): self._network_socket.shutdown(socket.SHUT_RDWR) @@ -149,6 +170,7 @@ def close(self) -> None: self.timer.cancel() self.connection.abort() + try: with self._read_lock: self.response.close() @@ -158,6 +180,7 @@ def close(self) -> None: def chunks(self) -> Iterator[bytes]: received = 0 + try: while True: if time.monotonic() > self.deadline: @@ -176,6 +199,7 @@ def chunks(self) -> Iterator[bytes]: break received += len(chunk) + if received > self.max_bytes: raise OpdsRelayError("This resource is too large to load.", 413) @@ -194,10 +218,12 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: connection: _PinnedConnection | None = None response: http.client.HTTPResponse | None = None + try: catalog_origin = _origin(_parse_url(payload.catalog_url)) current = payload.url deadline = time.monotonic() + _TOTAL_TIMEOUT + for _ in range(6): if time.monotonic() >= deadline: raise TimeoutError @@ -206,6 +232,7 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: address = _resolve_public(parts, deadline) connection = _PinnedConnection(parts, address) headers = {"Accept-Encoding": "identity", "User-Agent": "Papyrus-OPDS/1.0", "Connection": "close"} + if payload.credentials is not None and _origin(parts) == catalog_origin: credentials = payload.credentials token = f"{credentials.username}:{credentials.password.get_secret_value()}".encode() @@ -215,6 +242,7 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: header_timer = threading.Timer(max(0, min(_TIMEOUT, deadline - time.monotonic())), connection.abort) header_timer.daemon = True header_timer.start() + try: connection.request("GET", path, headers=headers) response = connection.getresponse() @@ -233,10 +261,12 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: location = response.getheader("Location") response.close() connection.close() + if not location: raise OpdsRelayError("The catalog returned an invalid redirect.", 502) next_url = urljoin(urlunsplit(parts), location) + if parts.scheme == "https" and _parse_url(next_url).scheme != "https": raise OpdsRelayError("The catalog redirected to an insecure connection.") @@ -259,20 +289,30 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: raise OpdsRelayError("The catalog returned an unsupported content encoding.", 502) raw_length = response.getheader("Content-Length") + if raw_length is not None and not raw_length.isdecimal(): raise OpdsRelayError("The catalog returned an invalid content length.", 502) length = int(raw_length) if raw_length is not None else None + if length is not None and length > payload.max_bytes: raise OpdsRelayError("This resource is too large to load.", 413) content_type = response.getheader("Content-Type", "application/octet-stream") + if "\r" in content_type or "\n" in content_type: raise OpdsRelayError("The catalog returned an invalid content type.", 502) resource = RelayResource( - urlunsplit(parts), content_type, length, response, connection, payload.max_bytes, deadline + urlunsplit(parts), + content_type, + length, + response, + connection, + payload.max_bytes, + deadline, ) + resource.timer = threading.Timer(max(0, deadline - time.monotonic()), resource.close) resource.timer.daemon = True resource.timer.start() @@ -287,6 +327,7 @@ def open_resource(payload: OpdsRelayRequest) -> RelayResource: connection.close() _slots.release() + if isinstance(exc, TimeoutError): raise OpdsRelayError("The catalog did not respond in time. Please retry.", 504) from exc diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py index 644a8c3..8b87706 100644 --- a/papyrus/services/progress.py +++ b/papyrus/services/progress.py @@ -58,12 +58,17 @@ def grouped_sessions(entries: list[Activity]) -> list[ReadingSession]: end = max(entry.end_time for entry in group) result = session_response(first) result.end_time = end + result.duration_minutes = ( int(union_length([(entry.start_time.timestamp(), entry.end_time.timestamp()) for entry in group])) // 60 ) + projection = project_goal( - totals_definition(first.start_time, end + timedelta(microseconds=1)), group, end + timedelta(microseconds=1) + totals_definition(first.start_time, end + timedelta(microseconds=1)), + group, + end + timedelta(microseconds=1), ) + result.pages_read = int(projection["pages"]) sessions.append(result) @@ -79,6 +84,7 @@ async def list_sessions( end_date: date | None, ) -> ReadingSessionList: ledger = effective_activities(await activities(session, user_id)) + entries = sorted( ( entry @@ -91,6 +97,7 @@ async def list_sessions( key=lambda entry: entry.start_time, reverse=True, ) + sessions = grouped_sessions(entries) total = len(sessions) pages = (total + pagination.limit - 1) // pagination.limit @@ -109,13 +116,23 @@ async def list_sessions( async def create_session(session: AsyncSession, user_id: UUID, request: CreateReadingSessionRequest) -> ReadingSession: - book = await owned_row(session, user_id, "books", request.book_id) + book = await owned_row( + session, + user_id, + "books", + request.book_id, + ) if book is None: raise NotFoundError("Book was not found") if request.session_id is not None: - existing = await owned_row(session, user_id, "reading_activities", request.session_id) + existing = await owned_row( + session, + user_id, + "reading_activities", + request.session_id, + ) if existing is not None: original = Activity.model_validate(existing.payload) @@ -141,12 +158,14 @@ async def create_session(session: AsyncSession, user_id: UUID, request: CreateRe SyncBookShelf.owner_user_id == user_id, SyncBookShelf.book_id == request.book_id ) ) + shelves = list(result.scalars()) try: start = request.start_time.replace(tzinfo=UTC) if request.start_time.tzinfo is None else request.start_time end = request.end_time or datetime.now(UTC) end = end.replace(tzinfo=UTC) if end.tzinfo is None else end + activity = Activity( id=request.session_id or uuid4(), book_id=request.book_id, @@ -173,6 +192,7 @@ async def create_session(session: AsyncSession, user_id: UUID, request: CreateRe ) ], ) + return session_response(activity) @@ -206,9 +226,11 @@ async def statistics( now = datetime.now(UTC) total = project_goal(totals_definition(start, end), ledger, now) real = effective_activities(ledger) + counted = [ entry for entry in real if entry.kind == "reading" and entry.start_time < end and entry.end_time >= start ] + daily = [] reading_dates = [] longest = streak = 0 @@ -217,9 +239,11 @@ async def statistics( day = start + timedelta(days=index) next_day = day + timedelta(days=1) values = project_goal(totals_definition(day, next_day), ledger, now) + count = len( {entry.session_id or entry.id for entry in counted if entry.start_time < next_day and entry.end_time > day} ) + daily.append( DailyBreakdown( date=day.date(), @@ -248,6 +272,7 @@ async def statistics( for book_id, title in books.items(): values = project_goal(totals_definition(start, end, book_id), ledger, now) + breakdown.append( BookBreakdown( book_id=book_id, diff --git a/papyrus/services/tracking_validation.py b/papyrus/services/tracking_validation.py index 1730bdb..c4643b3 100644 --- a/papyrus/services/tracking_validation.py +++ b/papyrus/services/tracking_validation.py @@ -25,6 +25,7 @@ async def validate_tracking_mutation( "reading_activities": Activity, "goal_periods": PeriodRecord, } + schema = schemas[table] raw = values.get("payload", row.payload if row is not None else None) @@ -68,6 +69,7 @@ async def validate_tracking_mutation( if row is not None: old = GoalDefinition.model_validate(row.payload) + fixed = ( "goal_type", "time_period", @@ -103,22 +105,47 @@ async def validate_tracking_mutation( if parsed.scope == "book": for book_id in parsed.selected_book_ids: - await check_reference(session, user_id, "books", book_id) + await check_reference( + session, + user_id, + "books", + book_id, + ) if parsed.scope_id is not None and parsed.scope != "book": - await check_reference(session, user_id, "shelves", parsed.scope_id) + await check_reference( + session, + user_id, + "shelves", + parsed.scope_id, + ) if isinstance(parsed, Activity): if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): raise ValidationError("Activity creation cannot be in the future") - await check_reference(session, user_id, "books", parsed.book_id) + await check_reference( + session, + user_id, + "books", + parsed.book_id, + ) for shelf_id in parsed.shelf_ids: - await check_reference(session, user_id, "shelves", shelf_id) + await check_reference( + session, + user_id, + "shelves", + shelf_id, + ) if parsed.correction_of is not None: - original = await owned_row(session, user_id, "reading_activities", parsed.correction_of) + original = await owned_row( + session, + user_id, + "reading_activities", + parsed.correction_of, + ) if ( original is None @@ -134,17 +161,38 @@ async def validate_tracking_mutation( if existing.goal_id != parsed.goal_id or existing.definition.start_date != parsed.definition.start_date: raise ValidationError("Period identity cannot change") - await check_reference(session, user_id, "reading_goals", parsed.goal_id) + await check_reference( + session, + user_id, + "reading_goals", + parsed.goal_id, + ) for book_id in parsed.definition.selected_book_ids: - await check_reference(session, user_id, "books", book_id) + await check_reference( + session, + user_id, + "books", + book_id, + ) return {**values, "payload": payload} async def check_reference(session: AsyncSession, user_id: UUID, table: str, row_id: UUID) -> None: - parent = await owned_row(session, user_id, table, row_id) - deleted = await tombstoned(session, user_id, table, row_id) + parent = await owned_row( + session, + user_id, + table, + row_id, + ) + + deleted = await tombstoned( + session, + user_id, + table, + row_id, + ) if parent is None and not deleted: raise ValidationError(f"Tracking reference {table} was not found") diff --git a/papyrus/services/users.py b/papyrus/services/users.py index c376d50..17b8f4d 100644 --- a/papyrus/services/users.py +++ b/papyrus/services/users.py @@ -34,6 +34,7 @@ async def _revoke_user_sessions(session: AsyncSession, user_id: UUID) -> None: result = await session.execute( select(AuthSession).where(AuthSession.user_id == user_id, AuthSession.revoked_at.is_(None)) ) + for auth_session in result.scalars(): auth_session.revoked_at = _now() diff --git a/tests/api/routes/test_acquisition.py b/tests/api/routes/test_acquisition.py index 66af845..f5f45ce 100644 --- a/tests/api/routes/test_acquisition.py +++ b/tests/api/routes/test_acquisition.py @@ -23,15 +23,19 @@ @pytest.fixture(autouse=True) def enable_acquisition(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(app_settings, "acquisition_enabled", True, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_enabled", + True, + raising=False, + ) async def test_disabled_capabilities_hide_acquisition_scope(client: AsyncClient) -> None: app_settings.acquisition_enabled = False - response = await client.get("/v1/acquisition/capabilities") - assert response.status_code == 200 + assert response.json() == { "enabled": False, "managed_downloads_ready": False, @@ -48,9 +52,7 @@ async def test_disabled_acquisition_routes_are_not_found( auth_headers: dict[str, str], ) -> None: app_settings.acquisition_enabled = False - response = await client.get("/v1/acquisition/endpoints", headers=auth_headers) - assert response.status_code == 404 @@ -59,9 +61,14 @@ async def test_capabilities_advertise_torrent_only_scope( auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_import_root", "/imports", raising=False) - response = await client.get("/v1/acquisition/capabilities", headers=auth_headers) + monkeypatch.setattr( + app_settings, + "acquisition_import_root", + "/imports", + raising=False, + ) + response = await client.get("/v1/acquisition/capabilities", headers=auth_headers) assert response.status_code == 200 body = response.json() assert body["indexer_kinds"] == ["prowlarr", "torznab"] @@ -76,7 +83,12 @@ async def test_capabilities_report_managed_downloads_not_ready_without_import_ro auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_import_root", None, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_import_root", + None, + raising=False, + ) response = await client.get( "/v1/acquisition/capabilities", @@ -110,14 +122,13 @@ async def test_create_and_list_endpoint_hides_and_encrypts_credentials( assert "password" not in response.text assert "username" not in response.text endpoint = response.json() - response = await client.get("/v1/acquisition/endpoints", headers=auth_headers) assert response.status_code == 200 assert response.json() == [endpoint] - stored = (await db_session.execute(select(AcquisitionEndpoint))).scalar_one() assert stored.credentials is not None assert "password" not in stored.credentials + assert decrypt_secret_payload(stored.credentials["encrypted"]) == { "username": "admin", "password": "secret", @@ -239,6 +250,7 @@ async def search_endpoint( release["release_token"], UUID(auth_user["user_id"]), ) + assert payload.download_url == "https://prowlarr.local/download?apikey=private-key" @@ -251,7 +263,13 @@ async def test_batch_submission_creates_a_linked_placeholder_without_persisting_ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_import_root", str(tmp_path), raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_import_root", + str(tmp_path), + raising=False, + ) + endpoint_response = await client.post( "/v1/acquisition/endpoints", headers=auth_headers, @@ -264,18 +282,21 @@ async def test_batch_submission_creates_a_linked_placeholder_without_persisting_ "download_root": "/downloads", }, ) + endpoint_id = UUID(endpoint_response.json()["endpoint_id"]) owner_user_id = UUID(auth_user["user_id"]) + indexer = AcquisitionEndpoint( owner_user_id=owner_user_id, name="Prowlarr", kind="prowlarr", base_url="http://prowlarr.local:9696", ) + db_session.add(indexer) await db_session.commit() - release_url = "https://prowlarr.local/download?apikey=private-key" + token = acquisition_service.create_release_token( acquisition_service.ReleaseCandidate( title="A Test Book", @@ -289,6 +310,7 @@ async def test_batch_submission_creates_a_linked_placeholder_without_persisting_ ), indexer, ) + submissions: list[tuple[str, str | None, str | None, list[str] | None]] = [] async def submit_to_client( @@ -328,12 +350,12 @@ async def submit_to_client( assert "download_url" not in item["job"] assert item["job"]["status"] == "submitted" assert item["job"]["book_id"] is not None - job = (await db_session.execute(select(AcquisitionJob))).scalar_one() book = (await db_session.execute(select(SyncBook))).scalar_one() assert job.book_id == book.book_id == UUID(item["job"]["book_id"]) assert job.download_url is None assert book.title == "A Test Book" + assert submissions == [ ( release_url, @@ -352,12 +374,14 @@ async def test_single_submission_wraps_the_token_only_batch_service( monkeypatch: pytest.MonkeyPatch, ) -> None: endpoint_id = uuid4() + job = AcquisitionJob( owner_user_id=UUID(auth_user["user_id"]), endpoint_id=None, title="A Test Book", status="submitted", ) + db_session.add(job) await db_session.flush() @@ -404,8 +428,15 @@ async def test_batch_submission_isolates_invalid_release_tokens( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_import_root", str(tmp_path), raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_import_root", + str(tmp_path), + raising=False, + ) + owner_user_id = UUID(auth_user["user_id"]) + download_client = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -413,14 +444,17 @@ async def test_batch_submission_isolates_invalid_release_tokens( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + indexer = AcquisitionEndpoint( owner_user_id=owner_user_id, name="Prowlarr", kind="prowlarr", base_url="http://prowlarr.local:9696", ) + db_session.add_all([download_client, indexer]) await db_session.commit() + valid_token = acquisition_service.create_release_token( acquisition_service.ReleaseCandidate( title="Valid Book", @@ -453,11 +487,13 @@ async def submit_to_client(*args: object, **kwargs: object) -> str | None: items = response.json()["items"] assert items[0]["job"]["title"] == "Valid Book" assert items[0]["error"] is None + assert items[1] == { "index": 1, "job": None, "error": "Release token is invalid or expired", } + assert await db_session.scalar(select(func.count()).select_from(AcquisitionJob)) == 1 assert await db_session.scalar(select(func.count()).select_from(SyncBook)) == 1 @@ -472,21 +508,25 @@ async def test_job_list_is_paginated_and_job_detail_is_owner_scoped( other_user = User(display_name="Other User") db_session.add(other_user) await db_session.flush() + first_job = AcquisitionJob( owner_user_id=owner_user_id, title="First", status="submitted", ) + second_job = AcquisitionJob( owner_user_id=owner_user_id, title="Second", status="failed", ) + other_job = AcquisitionJob( owner_user_id=other_user.user_id, title="Private", status="submitted", ) + db_session.add_all([first_job, second_job, other_job]) await db_session.commit() @@ -528,6 +568,7 @@ async def test_job_files_marks_supported_book_candidates( monkeypatch: pytest.MonkeyPatch, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -535,8 +576,10 @@ async def test_job_files_marks_supported_book_candidates( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -544,16 +587,36 @@ async def test_job_files_marks_supported_book_candidates( status="needs_file_selection", client_hash="abc123", ) + db_session.add(job) await db_session.commit() class FakeQbittorrentClient: async def files(self, torrent_hash: str) -> list[acquisition_service.QbittorrentFile]: assert torrent_hash == "abc123" + return [ - acquisition_service.QbittorrentFile(0, "cover.jpg", 100, 10_000, 1), - acquisition_service.QbittorrentFile(1, "Book.EPUB", 1024, 10_000, 1), - acquisition_service.QbittorrentFile(2, "extras/book.pdf", 2048, 5000, 1), + acquisition_service.QbittorrentFile( + 0, + "cover.jpg", + 100, + 10_000, + 1, + ), + acquisition_service.QbittorrentFile( + 1, + "Book.EPUB", + 1024, + 10_000, + 1, + ), + acquisition_service.QbittorrentFile( + 2, + "extras/book.pdf", + 2048, + 5000, + 1, + ), ] async def connect(endpoint: AcquisitionEndpoint) -> FakeQbittorrentClient: @@ -571,6 +634,7 @@ async def connect(endpoint: AcquisitionEndpoint) -> FakeQbittorrentClient: ) assert response.status_code == 200 + assert response.json() == [ { "index": 0, @@ -607,6 +671,7 @@ async def test_file_selection_prioritizes_candidate_and_resumes_job( monkeypatch: pytest.MonkeyPatch, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -614,8 +679,10 @@ async def test_file_selection_prioritizes_candidate_and_resumes_job( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -623,6 +690,7 @@ async def test_file_selection_prioritizes_candidate_and_resumes_job( status="needs_file_selection", client_hash="abc123", ) + db_session.add(job) await db_session.commit() selections: list[tuple[str, int, list[int]]] = [] @@ -630,9 +698,27 @@ async def test_file_selection_prioritizes_candidate_and_resumes_job( class FakeQbittorrentClient: async def files(self, torrent_hash: str) -> list[acquisition_service.QbittorrentFile]: return [ - acquisition_service.QbittorrentFile(0, "cover.jpg", 100, 10_000, 1), - acquisition_service.QbittorrentFile(1, "book.epub", 1024, 10_000, 1), - acquisition_service.QbittorrentFile(2, "extras/book.pdf", 2048, 5000, 1), + acquisition_service.QbittorrentFile( + 0, + "cover.jpg", + 100, + 10_000, + 1, + ), + acquisition_service.QbittorrentFile( + 1, + "book.epub", + 1024, + 10_000, + 1, + ), + acquisition_service.QbittorrentFile( + 2, + "extras/book.pdf", + 2048, + 5000, + 1, + ), ] async def select_file( @@ -663,7 +749,6 @@ async def connect(endpoint: AcquisitionEndpoint) -> FakeQbittorrentClient: assert response.json()["status"] == "downloading" assert response.json()["selected_file_path"] == "extras/book.pdf" assert selections == [("abc123", 2, [0, 1, 2])] - await db_session.refresh(job) assert job.status == "downloading" assert job.selected_file_path == "extras/book.pdf" @@ -679,6 +764,7 @@ async def test_cancel_job_deletes_partial_data_and_retains_cancelled_placeholder monkeypatch: pytest.MonkeyPatch, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -686,9 +772,11 @@ async def test_cancel_job_deletes_partial_data_and_retains_cancelled_placeholder base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Downloading Book") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -697,6 +785,7 @@ async def test_cancel_job_deletes_partial_data_and_retains_cancelled_placeholder status="downloading", client_hash="abc123", ) + db_session.add(job) await db_session.commit() deleted_hashes: list[str] = [] @@ -750,6 +839,7 @@ async def test_cancel_job_waits_for_monitor_transition_before_deleting_torrent( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + setup_session.add(endpoint) await setup_session.flush() @@ -760,6 +850,7 @@ async def test_cancel_job_waits_for_monitor_transition_before_deleting_torrent( status="downloading", client_hash="abc123", ) + setup_session.add(job) await setup_session.commit() job_id = job.job_id @@ -782,6 +873,7 @@ async def connect(endpoint: AcquisitionEndpoint) -> None: select(AcquisitionJob).where(AcquisitionJob.job_id == job_id).with_for_update() ) ).scalar_one() + locked_job.status = "completed" cancel_task = asyncio.create_task( @@ -791,10 +883,9 @@ async def connect(endpoint: AcquisitionEndpoint) -> None: job_id, ) ) - await asyncio.sleep(0.05) + await asyncio.sleep(0.05) assert not cancel_task.done() - await monitor_session.commit() with pytest.raises(HTTPException) as exc_info: @@ -813,6 +904,7 @@ async def test_delete_failed_job_removes_its_unimported_placeholder( book = SyncBook(owner_user_id=owner_user_id, title="Failed Book") db_session.add(book) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, book_id=book.book_id, @@ -820,6 +912,7 @@ async def test_delete_failed_job_removes_its_unimported_placeholder( status="failed", error="Import failed", ) + db_session.add(job) await db_session.commit() job_id = job.job_id @@ -843,6 +936,7 @@ async def test_retry_import_requeues_failed_job_without_resubmitting_download( db_session: AsyncSession, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -850,8 +944,10 @@ async def test_retry_import_requeues_failed_job_without_resubmitting_download( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -861,6 +957,7 @@ async def test_retry_import_requeues_failed_job_without_resubmitting_download( submitted_at=datetime.now(UTC), error="Import quota exceeded", ) + db_session.add(job) await db_session.commit() @@ -883,6 +980,7 @@ async def test_retry_import_rejects_job_that_never_reached_qbittorrent( db_session: AsyncSession, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -890,6 +988,7 @@ async def test_retry_import_rejects_job_that_never_reached_qbittorrent( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() @@ -900,6 +999,7 @@ async def test_retry_import_rejects_job_that_never_reached_qbittorrent( status="failed", error="qBittorrent rejected the release", ) + db_session.add(job) await db_session.commit() @@ -944,8 +1044,10 @@ async def test_delete_endpoint_is_blocked_while_jobs_are_active( "base_url": "http://qbittorrent.local:8080", }, ) + endpoint_id = UUID(endpoint_response.json()["endpoint_id"]) owner_user_id = UUID(auth_user["user_id"]) + rule = AcquisitionRule( owner_user_id=owner_user_id, name="Affected rule", @@ -954,6 +1056,7 @@ async def test_delete_endpoint_is_blocked_while_jobs_are_active( download_client_id=endpoint_id, enabled=True, ) + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint_id, @@ -964,9 +1067,7 @@ async def test_delete_endpoint_is_blocked_while_jobs_are_active( db_session.add_all([rule, job]) await db_session.commit() - response = await client.delete(f"/v1/acquisition/endpoints/{endpoint_id}", headers=auth_headers) - assert response.status_code == 409 await db_session.refresh(job) await db_session.refresh(rule) @@ -987,7 +1088,13 @@ async def test_connection_checks_unsaved_endpoint_without_persisting( async def test_connection(endpoint: AcquisitionEndpoint) -> None: captured.append(endpoint) - monkeypatch.setattr(acquisition_routes, "test_endpoint_connection", test_connection, raising=False) + monkeypatch.setattr( + acquisition_routes, + "test_endpoint_connection", + test_connection, + raising=False, + ) + before_count = await db_session.scalar(select(func.count()).select_from(AcquisitionEndpoint)) response = await client.post( @@ -1024,12 +1131,19 @@ async def test_connection_merges_owned_endpoint_overrides_without_persisting( "api_key": "saved-key", }, ) + captured: list[AcquisitionEndpoint] = [] async def test_connection(endpoint: AcquisitionEndpoint) -> None: captured.append(endpoint) - monkeypatch.setattr(acquisition_routes, "test_endpoint_connection", test_connection, raising=False) + monkeypatch.setattr( + acquisition_routes, + "test_endpoint_connection", + test_connection, + raising=False, + ) + before_count = await db_session.scalar(select(func.count()).select_from(AcquisitionEndpoint)) response = await client.post( @@ -1065,6 +1179,7 @@ async def test_connection_rejects_another_users_endpoint( kind="prowlarr", base_url="http://other-prowlarr.local:9696", ) + db_session.add(endpoint) await db_session.commit() diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py index e4c6afa..4d37d0b 100644 --- a/tests/api/routes/test_auth.py +++ b/tests/api/routes/test_auth.py @@ -39,11 +39,13 @@ def unconfigured_google(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture def configured_powersync(monkeypatch: pytest.MonkeyPatch) -> bytes: private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ).decode("utf-8") + public_pem = private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, @@ -112,6 +114,7 @@ async def test_register_user_returns_tokens(client: AsyncClient): "client_type": "mobile", }, ) + assert response.status_code == 201 data = response.json() assert "access_token" in data @@ -127,6 +130,7 @@ async def test_register_duplicate_email_returns_conflict(client: AsyncClient): "password": "SecureP@ss123", "display_name": "Test User", } + first_response = await client.post("/v1/auth/register", json=payload) assert first_response.status_code == 201 second_response = await client.post("/v1/auth/register", json=payload) @@ -143,6 +147,7 @@ async def test_login_user(client: AsyncClient): "display_name": "Test User", }, ) + assert register_response.status_code == 201 response = await client.post( @@ -153,6 +158,7 @@ async def test_login_user(client: AsyncClient): "client_type": "desktop", }, ) + assert response.status_code == 200 data = response.json() assert "access_token" in data @@ -171,6 +177,7 @@ async def test_login_user_rejects_invalid_password(client: AsyncClient): "display_name": "Test User", }, ) + assert register_response.status_code == 201 response = await client.post( @@ -180,6 +187,7 @@ async def test_login_user_rejects_invalid_password(client: AsyncClient): "password": "WrongPassword123", }, ) + assert response.status_code == 401 @@ -193,6 +201,7 @@ async def test_refresh_token_rotates_and_invalidates_previous_token(client: Asyn "display_name": "Test User", }, ) + assert register_response.status_code == 201 refresh_token = register_response.json()["refresh_token"] refresh_response = await client.post("/v1/auth/refresh", json={"refresh_token": refresh_token}) @@ -213,6 +222,7 @@ async def test_logout_current_session_revokes_refresh_token(client: AsyncClient) "display_name": "Test User", }, ) + assert register_response.status_code == 201 auth_payload = register_response.json() @@ -221,6 +231,7 @@ async def test_logout_current_session_revokes_refresh_token(client: AsyncClient) headers={"Authorization": f"Bearer {auth_payload['access_token']}"}, json={"all_devices": False}, ) + assert logout_response.status_code == 204 refresh_response = await client.post("/v1/auth/refresh", json={"refresh_token": auth_payload["refresh_token"]}) assert refresh_response.status_code == 401 @@ -229,12 +240,14 @@ async def test_logout_current_session_revokes_refresh_token(client: AsyncClient) "/v1/users/me", headers={"Authorization": f"Bearer {auth_payload['access_token']}"}, ) + assert protected_response.status_code == 401 async def test_login_is_rate_limited(client: AsyncClient): """Credential endpoints enforce the configured per-IP auth limit.""" limiter._storage.reset() + register_response = await client.post( "/v1/auth/register", json={ @@ -243,6 +256,7 @@ async def test_login_is_rate_limited(client: AsyncClient): "display_name": "Rate Limited", }, ) + assert register_response.status_code == 201 responses = [ @@ -267,6 +281,7 @@ async def test_logout_all_revokes_other_sessions(client: AsyncClient): "display_name": "Test User", }, ) + assert register_response.status_code == 201 first_auth = register_response.json() @@ -274,6 +289,7 @@ async def test_logout_all_revokes_other_sessions(client: AsyncClient): "/v1/auth/login", json={"email": "test@example.com", "password": "SecureP@ss123"}, ) + assert login_response.status_code == 200 second_auth = login_response.json() @@ -281,11 +297,14 @@ async def test_logout_all_revokes_other_sessions(client: AsyncClient): "/v1/auth/logout-all", headers={"Authorization": f"Bearer {first_auth['access_token']}"}, ) + assert logout_all_response.status_code == 204 first_refresh_response = await client.post("/v1/auth/refresh", json={"refresh_token": first_auth["refresh_token"]}) + second_refresh_response = await client.post( "/v1/auth/refresh", json={"refresh_token": second_auth["refresh_token"]} ) + assert first_refresh_response.status_code == 401 assert second_refresh_response.status_code == 401 @@ -293,10 +312,12 @@ async def test_logout_all_revokes_other_sessions(client: AsyncClient): "/v1/users/me", headers={"Authorization": f"Bearer {first_auth['access_token']}"}, ) + second_protected_response = await client.get( "/v1/users/me", headers={"Authorization": f"Bearer {second_auth['access_token']}"}, ) + assert first_protected_response.status_code == 401 assert second_protected_response.status_code == 401 @@ -324,6 +345,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code( params={"redirect_uri": "papyrus://auth/callback"}, follow_redirects=False, ) + assert start_response.status_code == 302 start_location = start_response.headers["location"] state = parse_qs(urlparse(start_location).query)["state"][0] @@ -333,6 +355,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code( params={"code": "google-auth-code", "state": state}, follow_redirects=False, ) + assert callback_response.status_code == 302 redirect_location = callback_response.headers["location"] exchange_code = parse_qs(urlparse(redirect_location).query)["code"][0] @@ -341,6 +364,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code( "/v1/auth/exchange-code", json={"code": exchange_code, "client_type": "web"}, ) + assert exchange_response.status_code == 200 data = exchange_response.json() assert data["user"]["email"] == "google_user@example.com" @@ -361,6 +385,7 @@ async def test_google_oauth_does_not_auto_link_existing_email( "display_name": "Test User", }, ) + assert register_response.status_code == 201 monkeypatch.setattr( @@ -380,6 +405,7 @@ async def test_google_oauth_does_not_auto_link_existing_email( params={"redirect_uri": "papyrus://auth/callback"}, follow_redirects=False, ) + state = parse_qs(urlparse(start_response.headers["location"]).query)["state"][0] callback_response = await client.get( @@ -387,6 +413,7 @@ async def test_google_oauth_does_not_auto_link_existing_email( params={"code": "google-auth-code", "state": state}, follow_redirects=False, ) + assert callback_response.status_code == 302 redirect_location = callback_response.headers["location"] assert parse_qs(urlparse(redirect_location).query)["error"][0] == "account_exists" @@ -402,6 +429,7 @@ async def test_google_oauth_start_requires_configuration( params={"redirect_uri": "papyrus://auth/callback"}, follow_redirects=False, ) + assert response.status_code == 400 assert response.json()["error"]["code"] == "VALIDATION_ERROR" @@ -416,6 +444,7 @@ async def test_google_oauth_start_rejects_unallowed_redirect_uri( params={"redirect_uri": "https://evil.example.test/auth/callback"}, follow_redirects=False, ) + assert response.status_code == 400 assert response.json()["error"]["code"] == "VALIDATION_ERROR" @@ -434,6 +463,7 @@ async def test_google_oauth_start_allows_configured_web_redirect_host( params={"redirect_uri": "https://app.example.test/auth/callback"}, follow_redirects=False, ) + assert response.status_code == 302 @@ -463,6 +493,7 @@ async def test_google_link_flow_links_identity_to_existing_user( headers=auth_headers, json={"redirect_uri": "papyrus://auth/callback"}, ) + assert start_response.status_code == 200 authorization_url = start_response.json()["authorization_url"] state = parse_qs(urlparse(authorization_url).query)["state"][0] @@ -472,6 +503,7 @@ async def test_google_link_flow_links_identity_to_existing_user( params={"code": "google-link-code", "state": state}, follow_redirects=False, ) + assert callback_response.status_code == 302 exchange_code = parse_qs(urlparse(callback_response.headers["location"]).query)["code"][0] @@ -480,11 +512,13 @@ async def test_google_link_flow_links_identity_to_existing_user( headers=auth_headers, json={"code": exchange_code}, ) + assert complete_response.status_code == 200 identity_result = await db_session.execute( select(UserIdentity).where(UserIdentity.provider_subject == "google-sub-link") ) + identity = identity_result.scalar_one() assert str(identity.user_id) == auth_user["user_id"] @@ -499,12 +533,14 @@ async def test_powersync_token_contains_expected_claims( response = await client.post("/v1/auth/powersync-token", headers=auth_headers) assert response.status_code == 200 token = response.json()["token"] + payload = jwt.decode( token, configured_powersync, algorithms=["RS256"], audience="https://powersync.example.test", ) + assert payload["sub"] == auth_user["user_id"] assert payload["type"] == "powersync" @@ -538,6 +574,7 @@ async def test_verify_email(client: AsyncClient, db_session: AsyncSession): db_session.add(user) await db_session.flush() plain_token = generate_opaque_token() + db_session.add( EmailActionToken( user_id=user.user_id, @@ -546,6 +583,7 @@ async def test_verify_email(client: AsyncClient, db_session: AsyncSession): expires_at=datetime.now(UTC) + timedelta(hours=1), ) ) + await db_session.commit() response = await client.post("/v1/auth/verify-email", json={"token": plain_token}) assert response.status_code == 200 @@ -558,6 +596,7 @@ async def test_verify_email_rejects_expired_token(client: AsyncClient, db_sessio db_session.add(user) await db_session.flush() plain_token = generate_opaque_token() + db_session.add( EmailActionToken( user_id=user.user_id, @@ -566,6 +605,7 @@ async def test_verify_email_rejects_expired_token(client: AsyncClient, db_sessio expires_at=datetime.now(UTC) - timedelta(minutes=1), ) ) + await db_session.commit() response = await client.post("/v1/auth/verify-email", json={"token": plain_token}) assert response.status_code == 400 @@ -585,12 +625,14 @@ async def test_resend_verification_sends_email_when_configured( "display_name": "Test User", }, ) + assert register_response.status_code == 201 response = await client.post( "/v1/auth/resend-verification", json={"email": "test@example.com"}, ) + assert response.status_code == 200 assert response.json()["message"] == "If the email is registered, a verification link has been sent" assert len(configured_email_delivery) == 1 @@ -613,12 +655,14 @@ async def test_forgot_password_returns_configuration_message( "display_name": "Test User", }, ) + assert register_response.status_code == 201 response = await client.post( "/v1/auth/forgot-password", json={"email": "test@example.com"}, ) + assert response.status_code == 200 assert response.json()["message"] == "Password reset is not configured on this server" @@ -637,6 +681,7 @@ async def test_forgot_password_requires_app_public_base_url( "display_name": "Test User", }, ) + assert register_response.status_code == 201 settings = get_settings() monkeypatch.setattr(settings, "app_public_base_url", None) @@ -645,6 +690,7 @@ async def test_forgot_password_requires_app_public_base_url( "/v1/auth/forgot-password", json={"email": "test@example.com"}, ) + assert response.status_code == 200 assert response.json()["message"] == "Password reset is not configured on this server" assert configured_email_delivery == [] @@ -663,12 +709,14 @@ async def test_forgot_password_sends_email_when_configured( "display_name": "Test User", }, ) + assert register_response.status_code == 201 response = await client.post( "/v1/auth/forgot-password", json={"email": "test@example.com"}, ) + assert response.status_code == 200 assert response.json()["message"] == "If the email is registered, a reset link has been sent" assert len(configured_email_delivery) == 1 @@ -694,11 +742,13 @@ async def test_forgot_password_send_failure_returns_service_unavailable( "display_name": "Test User", }, ) + assert register_response.status_code == 201 settings = get_settings() monkeypatch.setattr(settings, "email_delivery_enabled", True) monkeypatch.setattr(settings, "smtp_host", "smtp.example.test") monkeypatch.setattr(settings, "smtp_from_email", "noreply@example.test") + monkeypatch.setattr( email_service, "send_email", @@ -709,6 +759,7 @@ async def test_forgot_password_send_failure_returns_service_unavailable( "/v1/auth/forgot-password", json={"email": "test@example.com"}, ) + assert response.status_code == 503 assert response.json()["error"]["code"] == "SERVICE_UNAVAILABLE" @@ -719,6 +770,7 @@ async def test_reset_password(client: AsyncClient, db_session: AsyncSession): db_session.add(user) await db_session.flush() plain_token = generate_opaque_token() + db_session.add( EmailActionToken( user_id=user.user_id, @@ -727,6 +779,7 @@ async def test_reset_password(client: AsyncClient, db_session: AsyncSession): expires_at=datetime.now(UTC) + timedelta(hours=1), ) ) + await db_session.commit() response = await client.post( @@ -736,6 +789,7 @@ async def test_reset_password(client: AsyncClient, db_session: AsyncSession): "password": "NewSecureP@ss123", }, ) + assert response.status_code == 200 assert response.json()["message"] == "Password has been reset successfully" @@ -746,6 +800,7 @@ async def test_reset_password(client: AsyncClient, db_session: AsyncSession): "password": "NewSecureP@ss123", }, ) + assert login_response.status_code == 200 @@ -757,6 +812,7 @@ async def test_reset_password_revokes_existing_access_token( ): """Test password reset revokes any existing authenticated session.""" plain_token = generate_opaque_token() + db_session.add( EmailActionToken( user_id=UUID(auth_user["user_id"]), @@ -765,6 +821,7 @@ async def test_reset_password_revokes_existing_access_token( expires_at=datetime.now(UTC) + timedelta(hours=1), ) ) + await db_session.commit() response = await client.post( @@ -774,6 +831,7 @@ async def test_reset_password_revokes_existing_access_token( "password": "NewSecureP@ss123", }, ) + assert response.status_code == 200 protected_response = await client.get("/v1/users/me", headers=auth_headers) assert protected_response.status_code == 401 @@ -785,6 +843,7 @@ async def test_reset_password_rejects_expired_token(client: AsyncClient, db_sess db_session.add(user) await db_session.flush() plain_token = generate_opaque_token() + db_session.add( EmailActionToken( user_id=user.user_id, @@ -793,6 +852,7 @@ async def test_reset_password_rejects_expired_token(client: AsyncClient, db_sess expires_at=datetime.now(UTC) - timedelta(minutes=1), ) ) + await db_session.commit() response = await client.post( @@ -802,6 +862,7 @@ async def test_reset_password_rejects_expired_token(client: AsyncClient, db_sess "password": "NewSecureP@ss123", }, ) + assert response.status_code == 400 assert response.json()["error"]["code"] == "VALIDATION_ERROR" @@ -812,6 +873,7 @@ async def test_exchange_code_rejects_expired_code(client: AsyncClient, db_sessio db_session.add(user) await db_session.flush() plain_code = generate_opaque_token() + db_session.add( AuthExchangeCode( code_hash=hash_opaque_token(plain_code), @@ -822,12 +884,14 @@ async def test_exchange_code_rejects_expired_code(client: AsyncClient, db_sessio expires_at=datetime.now(UTC) - timedelta(minutes=1), ) ) + await db_session.commit() response = await client.post( "/v1/auth/exchange-code", json={"code": plain_code, "client_type": "web"}, ) + assert response.status_code == 401 assert response.json()["error"]["code"] == "UNAUTHORIZED" @@ -838,6 +902,7 @@ async def test_exchange_code_rejects_reused_code(client: AsyncClient, db_session db_session.add(user) await db_session.flush() plain_code = generate_opaque_token() + db_session.add( AuthExchangeCode( code_hash=hash_opaque_token(plain_code), @@ -849,11 +914,13 @@ async def test_exchange_code_rejects_reused_code(client: AsyncClient, db_session used_at=datetime.now(UTC), ) ) + await db_session.commit() response = await client.post( "/v1/auth/exchange-code", json={"code": plain_code, "client_type": "web"}, ) + assert response.status_code == 401 assert response.json()["error"]["code"] == "UNAUTHORIZED" diff --git a/tests/api/routes/test_auth_sandbox.py b/tests/api/routes/test_auth_sandbox.py index 6168f4d..073d117 100644 --- a/tests/api/routes/test_auth_sandbox.py +++ b/tests/api/routes/test_auth_sandbox.py @@ -21,9 +21,7 @@ async def test_auth_sandbox_registered_in_debug_vite_mode( """Test the auth sandbox page points at the Vite dev server when enabled.""" monkeypatch.setattr(app_settings, "dev_pages_use_vite", True) monkeypatch.setattr(app_settings, "dev_pages_vite_url", "http://vite.test:5173") - response = await debug_client.get("/__dev/auth-sandbox") - assert response.status_code == 200 assert "Authentication sandbox" in response.text assert "window.__PAPYRUS_DEV_PAGE_CONFIG__ =" in response.text @@ -46,6 +44,7 @@ async def test_auth_sandbox_renders_built_assets_when_manifest_exists( ): """Test the auth sandbox uses built assets when Vite mode is disabled.""" manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( json.dumps( { @@ -63,9 +62,7 @@ async def test_auth_sandbox_renders_built_assets_when_manifest_exists( monkeypatch.setattr(app_settings, "dev_pages_use_vite", False) monkeypatch.setattr(app_settings, "dev_pages_manifest_path", str(manifest_path)) dev_pages._load_manifest.cache_clear() - response = await debug_client.get("/__dev/auth-sandbox") - assert response.status_code == 200 assert 'href="/__dev/static/assets/auth-sandbox.css"' in response.text assert 'src="/__dev/static/assets/auth-sandbox.js"' in response.text diff --git a/tests/api/routes/test_bookmark_sync.py b/tests/api/routes/test_bookmark_sync.py index 9fccb17..b95f1be 100644 --- a/tests/api/routes/test_bookmark_sync.py +++ b/tests/api/routes/test_bookmark_sync.py @@ -13,6 +13,7 @@ async def test_bookmark_roundtrip_retry_partial_update_and_null_clear(client, auth_headers, auth_user, db_session): book_id, bookmark_id = uuid4(), uuid4() created_at = "2025-01-02T03:04:05Z" + bookmark = mutation( "bookmarks", bookmark_id, @@ -28,9 +29,17 @@ async def test_bookmark_roundtrip_retry_partial_update_and_null_clear(client, au "updated_at": created_at, }, ) - response = await upload(client, auth_headers, mutation("books", book_id, {"title": "Book"}), bookmark) + + response = await upload( + client, + auth_headers, + mutation("books", book_id, {"title": "Book"}), + bookmark, + ) + assert response.status_code == 200, response.text assert (await upload(client, auth_headers, bookmark)).status_code == 200 + response = await upload( client, auth_headers, @@ -45,25 +54,43 @@ async def test_bookmark_roundtrip_retry_partial_update_and_null_clear(client, au "PATCH", ), ) + assert response.status_code == 200, response.text row = (await db_session.execute(text("SELECT * FROM bookmarks"))).mappings().one() assert row["position"] == 0.25 assert row["color_hex"] == "#ABCDEF" assert row["note"] is row["page_number"] is row["chapter_title"] is None assert row["owner_user_id"] == UUID(auth_user["user_id"]) - assert row["created_at"] == datetime(2025, 1, 2, 3, 4, 5, tzinfo=UTC) + + assert row["created_at"] == datetime( + 2025, + 1, + 2, + 3, + 4, + 5, + tzinfo=UTC, + ) + assert row["updated_at"] > row["created_at"] async def test_bookmark_defaults_and_missing_patch(client, auth_headers, db_session): book_id, bookmark_id = uuid4(), uuid4() + response = await upload( client, auth_headers, mutation("books", book_id, {"title": "Book"}), mutation("bookmarks", bookmark_id, {"book_id": str(book_id)}), - mutation("bookmarks", uuid4(), {"note": "Missing"}, "PATCH"), + mutation( + "bookmarks", + uuid4(), + {"note": "Missing"}, + "PATCH", + ), ) + assert response.status_code == 200, response.text row = (await db_session.execute(text("SELECT position, color_hex FROM bookmarks"))).one() assert row == (0.0, "#FF5722") @@ -73,19 +100,41 @@ async def test_bookmark_defaults_and_missing_patch(client, auth_headers, db_sess async def test_bookmark_deletion_wins_over_stale_writes(client, auth_headers, db_session, delete_parent): book_id, bookmark_id = uuid4(), uuid4() bookmark = mutation("bookmarks", bookmark_id, {"book_id": str(book_id), "position": 0.5}) + assert ( - await upload(client, auth_headers, mutation("books", book_id, {"title": "Book"}), bookmark) + await upload( + client, + auth_headers, + mutation("books", book_id, {"title": "Book"}), + bookmark, + ) ).status_code == 200 + deletion = mutation( - "books" if delete_parent else "bookmarks", book_id if delete_parent else bookmark_id, op="DELETE" + "books" if delete_parent else "bookmarks", + book_id if delete_parent else bookmark_id, + op="DELETE", ) + for _ in range(2): assert (await upload(client, auth_headers, deletion)).status_code == 200 - stale = [bookmark, mutation("bookmarks", bookmark_id, {"note": "Stale"}, "PATCH")] + + stale = [ + bookmark, + mutation( + "bookmarks", + bookmark_id, + {"note": "Stale"}, + "PATCH", + ), + ] + if delete_parent: stale.append(mutation("bookmarks", uuid4(), {"book_id": str(book_id), "position": 0.7})) + assert (await upload(client, auth_headers, *stale)).status_code == 200 assert (await db_session.execute(text("SELECT count(*) FROM bookmarks"))).scalar_one() == 0 + assert ( await db_session.execute(text("SELECT count(*) FROM sync_tombstones WHERE table_name = 'bookmarks'")) ).scalar_one() == 1 @@ -98,18 +147,27 @@ async def test_bookmark_rejects_foreign_book_and_rolls_back(client, auth_headers primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(owner) await db_session.flush() - book = SyncBook(book_id=uuid4(), owner_user_id=owner.user_id, title="Foreign") + + book = SyncBook( + book_id=uuid4(), + owner_user_id=owner.user_id, + title="Foreign", + ) + db_session.add(book) await db_session.commit() foreign_id = book.book_id + response = await upload( client, auth_headers, mutation("shelves", uuid4(), {"name": "Rollback"}), mutation("bookmarks", uuid4(), {"book_id": str(foreign_id), "position": 0.5}), ) + assert response.status_code == 403, response.text assert (await db_session.execute(text("SELECT count(*) FROM shelves"))).scalar_one() == 0 @@ -123,29 +181,55 @@ async def test_bookmark_rejects_foreign_entity_mutations(client, auth_headers, d primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(owner) await db_session.flush() - book = SyncBook(book_id=uuid4(), owner_user_id=owner.user_id, title="Foreign") + + book = SyncBook( + book_id=uuid4(), + owner_user_id=owner.user_id, + title="Foreign", + ) + db_session.add(book) await db_session.flush() - bookmark = SyncBookmark(bookmark_id=uuid4(), owner_user_id=owner.user_id, book_id=book.book_id) + + bookmark = SyncBookmark( + bookmark_id=uuid4(), + owner_user_id=owner.user_id, + book_id=book.book_id, + ) + db_session.add(bookmark) await db_session.commit() bookmark_id = bookmark.bookmark_id + for op in ("PUT", "PATCH", "DELETE"): - response = await upload(client, auth_headers, mutation("bookmarks", bookmark_id, {"note": "Forbidden"}, op)) + response = await upload( + client, + auth_headers, + mutation( + "bookmarks", + bookmark_id, + {"note": "Forbidden"}, + op, + ), + ) + assert response.status_code == 403, response.text @pytest.mark.parametrize("position", [-0.1, 1.1, None, "0.5", True]) async def test_bookmark_invalid_position_rolls_back(client, auth_headers, db_session, position): book_id = uuid4() + response = await upload( client, auth_headers, mutation("books", book_id, {"title": "Rollback"}), mutation("bookmarks", uuid4(), {"book_id": str(book_id), "position": position}), ) + assert response.status_code == 400, response.text assert (await db_session.execute(text("SELECT count(*) FROM books"))).scalar_one() == 0 diff --git a/tests/api/routes/test_bookmarks.py b/tests/api/routes/test_bookmarks.py index c7206e0..28b1081 100644 --- a/tests/api/routes/test_bookmarks.py +++ b/tests/api/routes/test_bookmarks.py @@ -29,6 +29,7 @@ async def test_create_bookmark(client: AsyncClient, auth_headers: dict[str, str] "note": "Interesting part", }, ) + assert response.status_code == 201 data = response.json() assert data["page_number"] == 42 @@ -47,6 +48,7 @@ async def test_update_bookmark(client: AsyncClient, auth_headers: dict[str, str] headers=auth_headers, json={"note": "Updated note"}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_books.py b/tests/api/routes/test_books.py index b9d53c4..462fa32 100644 --- a/tests/api/routes/test_books.py +++ b/tests/api/routes/test_books.py @@ -26,6 +26,7 @@ async def test_list_books_with_filters(client: AsyncClient, auth_headers: dict[s "limit": 10, }, ) + assert response.status_code == 200 @@ -40,6 +41,7 @@ async def test_create_book(client: AsyncClient, auth_headers: dict[str, str]): "isbn": "1234567890", }, ) + assert response.status_code == 201 data = response.json() assert data["title"] == "Test Book" @@ -61,6 +63,7 @@ async def test_update_book(client: AsyncClient, auth_headers: dict[str, str], bo headers=auth_headers, json={"title": "Updated Title"}, ) + assert response.status_code == 200 @@ -81,11 +84,13 @@ async def test_get_book_shelves(client: AsyncClient, auth_headers: dict[str, str async def test_set_book_shelves(client: AsyncClient, auth_headers: dict[str, str], book_id: str): """Test setting book's shelves.""" shelf_id = str(uuid4()) + response = await client.put( f"/v1/books/{book_id}/shelves", headers=auth_headers, json={"shelf_ids": [shelf_id]}, ) + assert response.status_code == 200 @@ -100,11 +105,13 @@ async def test_get_book_tags(client: AsyncClient, auth_headers: dict[str, str], async def test_set_book_tags(client: AsyncClient, auth_headers: dict[str, str], book_id: str): """Test setting book's tags.""" tag_id = str(uuid4()) + response = await client.put( f"/v1/books/{book_id}/tags", headers=auth_headers, json={"tag_ids": [tag_id]}, ) + assert response.status_code == 200 @@ -126,6 +133,7 @@ async def test_update_book_progress(client: AsyncClient, auth_headers: dict[str, "current_position": 0.25, }, ) + assert response.status_code == 200 @@ -141,6 +149,7 @@ async def test_batch_create_books(client: AsyncClient, auth_headers: dict[str, s ] }, ) + assert response.status_code == 201 data = response.json() assert len(data["created"]) == 2 @@ -149,6 +158,7 @@ async def test_batch_create_books(client: AsyncClient, auth_headers: dict[str, s async def test_batch_update_books(client: AsyncClient, auth_headers: dict[str, str]): """Test batch updating books.""" book_ids = [str(uuid4()), str(uuid4())] + response = await client.patch( "/v1/books/batch", headers=auth_headers, @@ -156,6 +166,7 @@ async def test_batch_update_books(client: AsyncClient, auth_headers: dict[str, s "book_ids": book_ids, }, ) + assert response.status_code == 200 data = response.json() assert data["updated_count"] == 2 @@ -164,12 +175,14 @@ async def test_batch_update_books(client: AsyncClient, auth_headers: dict[str, s async def test_batch_delete_books(client: AsyncClient, auth_headers: dict[str, str]): """Test batch deleting books.""" book_ids = [str(uuid4()), str(uuid4())] + response = await client.request( "DELETE", "/v1/books/batch", headers=auth_headers, json={"book_ids": book_ids, "delete_files": False}, ) + assert response.status_code == 200 data = response.json() assert data["deleted_count"] == 2 @@ -182,6 +195,7 @@ async def test_fetch_book_metadata(client: AsyncClient, auth_headers: dict[str, headers=auth_headers, json={"isbn": "9780142424179"}, ) + assert response.status_code == 200 data = response.json() assert "results" in data diff --git a/tests/api/routes/test_goals.py b/tests/api/routes/test_goals.py index d40caf9..461742f 100644 --- a/tests/api/routes/test_goals.py +++ b/tests/api/routes/test_goals.py @@ -10,6 +10,7 @@ @pytest.fixture async def goal_id(client: AsyncClient, auth_headers: dict[str, str]): today = datetime.now(UTC).date() + response = await client.post( "/v1/goals", headers=auth_headers, @@ -22,6 +23,7 @@ async def goal_id(client: AsyncClient, auth_headers: dict[str, str]): "end_date": f"{today.year}-12-31", }, ) + assert response.status_code == 201 return response.json()["goal_id"] @@ -57,6 +59,7 @@ async def test_unknown_goal_is_not_an_example(client, auth_headers): async def test_nonrecurring_calendar_goal_retains_future_dates(client, auth_headers): year = datetime.now(UTC).year + 1 + response = await client.post( "/v1/goals", headers=auth_headers, @@ -71,6 +74,7 @@ async def test_nonrecurring_calendar_goal_retains_future_dates(client, auth_head "end_date": f"{year}-12-31", }, ) + assert response.status_code == 201, response.text saved = response.json() fetched = (await client.get(f"/v1/goals/{saved['goal_id']}", headers=auth_headers)).json() @@ -101,6 +105,7 @@ async def test_goal_description_can_be_cleared(client, auth_headers, goal_id): async def test_create_reading_days_goal(client, auth_headers): today = datetime.now(UTC).date().isoformat() + response = await client.post( "/v1/goals", headers=auth_headers, @@ -115,6 +120,7 @@ async def test_create_reading_days_goal(client, auth_headers): "minimum_minutes": 5, }, ) + assert response.status_code == 201 assert response.json()["goal_type"] == "reading_days" assert response.json()["timezone"] == "Europe/Vilnius" @@ -125,6 +131,7 @@ async def test_create_selected_books_goal_round_trips_and_bounds_target(client, for title in ["First selected book", "Second selected book"]: book_id = str(uuid4()) + response = await client.post( "/v1/sync/powersync-upload", headers=auth_headers, @@ -134,10 +141,12 @@ async def test_create_selected_books_goal_round_trips_and_bounds_target(client, ] }, ) + assert response.status_code == 200, response.text books.append(book_id) today = datetime.now(UTC).date().isoformat() + request = { "title": "Finish these books", "goal_type": "books_count", @@ -148,6 +157,7 @@ async def test_create_selected_books_goal_round_trips_and_bounds_target(client, "scope": "book", "book_ids": books, } + response = await client.post("/v1/goals", headers=auth_headers, json=request) assert response.status_code == 201, response.text goal = response.json() @@ -157,14 +167,22 @@ async def test_create_selected_books_goal_round_trips_and_bounds_target(client, assert set(result.json()["book_ids"]) == set(books) invalid = await client.post("/v1/goals", headers=auth_headers, json={**request, "target_value": 12}) assert invalid.status_code == 400 + single = await client.post( - "/v1/goals", headers=auth_headers, json={**request, "book_ids": books[:1], "target_value": 1} + "/v1/goals", + headers=auth_headers, + json={**request, "book_ids": books[:1], "target_value": 1}, ) + assert single.status_code == 201 assert single.json()["scope_id"] == books[0] assert single.json()["book_ids"] == [] + saved = await client.patch( - f"/v1/goals/{single.json()['goal_id']}", headers=auth_headers, json={"title": "One selected book"} + f"/v1/goals/{single.json()['goal_id']}", + headers=auth_headers, + json={"title": "One selected book"}, ) + assert saved.status_code == 200 assert saved.json()["scope_id"] == books[0] diff --git a/tests/api/routes/test_library_sync.py b/tests/api/routes/test_library_sync.py index 8781846..52a1c92 100644 --- a/tests/api/routes/test_library_sync.py +++ b/tests/api/routes/test_library_sync.py @@ -16,6 +16,7 @@ async def upload(client, auth_headers, *batch): async def test_mixed_roundtrip_and_null_patch(client, auth_headers, db_session): book, shelf, tag, note, annotation = [uuid4() for _ in range(5)] location = {"chapter": 3, "chapter_title": "Chapter", "page_number": 12, "percentage": 0.4} + batch = [ mutation("books", book, {"title": "Book", "series_id": "legacy-series", "is_physical": True}), mutation("shelves", shelf, {"name": "Shelf", "icon_code_point": 123, "icon_font_family": "MaterialIcons"}), @@ -40,12 +41,22 @@ async def test_mixed_roundtrip_and_null_patch(client, auth_headers, db_session): mutation("book_shelves", f"{book}:{shelf}", {"book_id": str(book), "shelf_id": str(shelf), "sort_order": 2}), mutation("book_tags", f"{book}:{tag}", {"book_id": str(book), "tag_id": str(tag)}), ] + for _ in range(2): response = await upload(client, auth_headers, *batch) assert response.status_code == 200, response.text + response = await upload( - client, auth_headers, mutation("notes", note, {"location": None, "content": "Edited"}, "PATCH") + client, + auth_headers, + mutation( + "notes", + note, + {"location": None, "content": "Edited"}, + "PATCH", + ), ) + assert response.status_code == 200 row = (await db_session.execute(text("SELECT title, content, location, tags, is_pinned FROM notes"))).one() assert row == ("Note", "Edited", None, ["free text"], True) @@ -54,6 +65,7 @@ async def test_mixed_roundtrip_and_null_patch(client, auth_headers, db_session): async def test_delete_wins_and_cascades(client, auth_headers, db_session): book, shelf, note = [uuid4() for _ in range(3)] + response = await upload( client, auth_headers, @@ -62,8 +74,10 @@ async def test_delete_wins_and_cascades(client, auth_headers, db_session): mutation("notes", note, {"book_id": str(book), "title": "Note", "content": "Body"}), mutation("book_shelves", f"{book}:{shelf}", {"book_id": str(book), "shelf_id": str(shelf)}), ) + assert response.status_code == 200, response.text assert (await upload(client, auth_headers, mutation("books", book, op="DELETE"))).status_code == 200 + response = await upload( client, auth_headers, @@ -72,13 +86,16 @@ async def test_delete_wins_and_cascades(client, auth_headers, db_session): mutation("notes", uuid4(), {"book_id": str(book), "title": "Late", "content": "Body"}), mutation("book_shelves", f"{book}:{shelf}", {"book_id": str(book), "shelf_id": str(shelf)}), ) + assert response.status_code == 200, response.text + for table in ("books", "notes", "book_shelves"): assert (await db_session.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one() == 0 async def test_shelf_cycle_rolls_back_and_delete_reparents(client, auth_headers, db_session): parent, child = uuid4(), uuid4() + assert ( await upload( client, @@ -87,16 +104,30 @@ async def test_shelf_cycle_rolls_back_and_delete_reparents(client, auth_headers, mutation("shelves", child, {"name": "Child", "parent_shelf_id": str(parent)}), ) ).status_code == 200 + response = await upload( client, auth_headers, - mutation("shelves", child, {"name": "Wrong"}, "PATCH"), - mutation("shelves", parent, {"parent_shelf_id": str(child)}, "PATCH"), + mutation( + "shelves", + child, + {"name": "Wrong"}, + "PATCH", + ), + mutation( + "shelves", + parent, + {"parent_shelf_id": str(child)}, + "PATCH", + ), ) + assert response.status_code == 400 + assert ( await db_session.execute(text("SELECT name FROM shelves WHERE shelf_id = :id"), {"id": child}) ).scalar_one() == "Child" + await db_session.rollback() assert (await upload(client, auth_headers, mutation("shelves", parent, op="DELETE"))).status_code == 200 assert (await db_session.execute(text("SELECT parent_shelf_id FROM shelves"))).scalar_one() is None @@ -105,6 +136,7 @@ async def test_shelf_cycle_rolls_back_and_delete_reparents(client, auth_headers, async def test_membership_remove_readd_and_pair_validation(client, auth_headers): book, tag = uuid4(), uuid4() membership = mutation("book_tags", f"{book}:{tag}", {"book_id": str(book), "tag_id": str(tag)}) + assert ( await upload( client, @@ -114,8 +146,10 @@ async def test_membership_remove_readd_and_pair_validation(client, auth_headers) membership, ) ).status_code == 200 + assert (await upload(client, auth_headers, mutation("book_tags", membership["id"], op="DELETE"))).status_code == 200 assert (await upload(client, auth_headers, membership)).status_code == 200 + assert ( await upload(client, auth_headers, mutation("book_tags", f"{book}:{uuid4()}", membership["data"])) ).status_code == 400 @@ -129,11 +163,13 @@ async def test_legacy_envelope_normalizes_and_preserves_metadata(client, auth_he "is_physical": True, "custom_metadata": {"key": "value"}, } + response = await upload( client, auth_headers, mutation("books", uuid4(), {"title": "Book", "custom_metadata": envelope, "series_id": "explicit"}), ) + assert response.status_code == 200, response.text row = (await db_session.execute(text("SELECT series_id, file_size, is_physical, custom_metadata FROM books"))).one() assert row == ("explicit", 42, True, envelope) @@ -150,11 +186,19 @@ async def test_foreign_reference_rolls_back_mixed_batch(client, auth_headers, db primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(other) await db_session.flush() - foreign_book = SyncBook(book_id=uuid4(), owner_user_id=other.user_id, title="Foreign") + + foreign_book = SyncBook( + book_id=uuid4(), + owner_user_id=other.user_id, + title="Foreign", + ) + db_session.add(foreign_book) await db_session.commit() + for table, data in ( ("notes", {"book_id": str(foreign_book.book_id), "title": "Note", "content": "Body"}), ( @@ -163,12 +207,14 @@ async def test_foreign_reference_rolls_back_mixed_batch(client, auth_headers, db ), ): shelf_id = uuid4() + response = await upload( client, auth_headers, mutation("shelves", shelf_id, {"name": "Must roll back"}), mutation(table, uuid4(), data), ) + assert response.status_code == 403, response.text assert (await db_session.execute(text("SELECT count(*) FROM shelves"))).scalar_one() == 0 await db_session.rollback() @@ -179,28 +225,51 @@ async def test_delete_before_create_and_missing_patch_do_not_create(client, auth row_id = uuid4() assert (await upload(client, auth_headers, mutation(table, row_id, op="DELETE"))).status_code == 200 assert (await upload(client, auth_headers, mutation(table, row_id, {}))).status_code == 200 - assert (await upload(client, auth_headers, mutation(table, uuid4(), {}, "PATCH"))).status_code == 200 + + assert ( + await upload( + client, + auth_headers, + mutation( + table, + uuid4(), + {}, + "PATCH", + ), + ) + ).status_code == 200 + assert (await db_session.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one() == 0 await db_session.rollback() async def test_deleting_annotation_or_tag_blocks_stale_recreation(client, auth_headers, db_session): book, tag, annotation = uuid4(), uuid4(), uuid4() + batch = [ mutation("books", book, {"title": "Book"}), mutation("tags", tag, {"name": "Tag", "color_hex": "red"}), mutation( - "annotations", annotation, {"book_id": str(book), "selected_text": "Quote", "location": {"page_number": 1}} + "annotations", + annotation, + {"book_id": str(book), "selected_text": "Quote", "location": {"page_number": 1}}, ), mutation("book_tags", f"{book}:{tag}", {"book_id": str(book), "tag_id": str(tag)}), ] + assert (await upload(client, auth_headers, *batch)).status_code == 200 + assert ( await upload( - client, auth_headers, mutation("tags", tag, op="DELETE"), mutation("annotations", annotation, op="DELETE") + client, + auth_headers, + mutation("tags", tag, op="DELETE"), + mutation("annotations", annotation, op="DELETE"), ) ).status_code == 200 + assert (await upload(client, auth_headers, *batch[1:])).status_code == 200 + for table in ("tags", "annotations", "book_tags"): assert (await db_session.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one() == 0 @@ -209,6 +278,7 @@ async def test_concurrent_patches_preserve_unrelated_values(client, auth_headers import asyncio book = uuid4() + assert ( await upload( client, @@ -220,10 +290,30 @@ async def test_concurrent_patches_preserve_unrelated_values(client, auth_headers ), ) ).status_code == 200 + results = await asyncio.gather( - upload(client, auth_headers, mutation("books", book, {"title": "New title"}, "PATCH")), - upload(client, auth_headers, mutation("books", book, {"author": "New author"}, "PATCH")), + upload( + client, + auth_headers, + mutation( + "books", + book, + {"title": "New title"}, + "PATCH", + ), + ), + upload( + client, + auth_headers, + mutation( + "books", + book, + {"author": "New author"}, + "PATCH", + ), + ), ) + assert [result.status_code for result in results] == [200, 200] row = (await db_session.execute(text("SELECT title, author, custom_metadata FROM books"))).one() assert row == ("New title", "New author", {"custom_metadata": {"keep": True}}) @@ -237,8 +327,12 @@ async def test_library_field_validation_is_atomic(client, auth_headers, db_sessi ("books", {"title": "Book", "file_size": 1.5}), ): response = await upload( - client, auth_headers, mutation("books", uuid4(), {"title": "Rollback"}), mutation(table, uuid4(), data) + client, + auth_headers, + mutation("books", uuid4(), {"title": "Rollback"}), + mutation(table, uuid4(), data), ) + assert response.status_code == 400, response.text assert (await db_session.execute(text("SELECT count(*) FROM books"))).scalar_one() == 0 await db_session.rollback() diff --git a/tests/api/routes/test_media.py b/tests/api/routes/test_media.py index 7e8daa2..0efc0fd 100644 --- a/tests/api/routes/test_media.py +++ b/tests/api/routes/test_media.py @@ -26,6 +26,7 @@ async def _create_owned_book(db_session: AsyncSession, user_id: str) -> SyncBook added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() return book @@ -59,9 +60,9 @@ async def test_upload_media_persists_file_and_updates_usage( assert body["size_bytes"] == len(b"epub bytes") assert body["sha256"] == "227dae38658f29c3a8494e65302e70b406162c2f581845339dfa19cbfad839d4" assert (tmp_path / body["storage_path"]).read_bytes() == b"epub bytes" - usage = await client.get("/v1/media/usage", headers=auth_headers) assert usage.status_code == 200 + assert usage.json() == { "used_bytes": len(b"epub bytes"), "quota_bytes": 1_073_741_824, @@ -96,7 +97,6 @@ async def test_import_media_path_copies_download_and_keeps_source_for_seeding( assert asset.sha256 == "b7783cce10abf92f10284f7089ffd31daf92e0b532284a6e14488b20834b5e16" assert (media_root / asset.storage_path).read_bytes() == b"downloaded epub" assert source_path.read_bytes() == b"downloaded epub" - await db_session.refresh(book) assert book.file_media_id == asset.asset_id @@ -111,20 +111,20 @@ async def test_download_and_delete_owned_media( ): monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) book = await _create_owned_book(db_session, auth_user["user_id"]) + upload = await client.post( "/v1/media", headers=auth_headers, data={"book_id": str(book.book_id), "kind": "cover_image"}, files={"file": ("cover.jpg", b"jpeg bytes", "image/jpeg")}, ) + assert upload.status_code == 201 asset_id = upload.json()["asset_id"] - download = await client.get(f"/v1/media/{asset_id}", headers=auth_headers) assert download.status_code == 200 assert download.content == b"jpeg bytes" assert download.headers["content-type"] == "image/jpeg" - delete = await client.delete(f"/v1/media/{asset_id}", headers=auth_headers) assert delete.status_code == 204 assert not (tmp_path / upload.json()["storage_path"]).exists() @@ -163,14 +163,17 @@ async def test_upload_rejects_cross_user_book( tmp_path: Path, ): monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) + other_user = User( display_name="Other User", primary_email="other-media@example.com", primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(other_user) await db_session.flush() + foreign_book = SyncBook( book_id=uuid4(), owner_user_id=other_user.user_id, @@ -178,6 +181,7 @@ async def test_upload_rejects_cross_user_book( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(foreign_book) await db_session.commit() @@ -227,6 +231,7 @@ async def test_replacing_media_keeps_existing_file_when_commit_fails( monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 1_073_741_824) book = await _create_owned_book(db_session, auth_user["user_id"]) + first = await media_service.upload_media( db_session, UUID(auth_user["user_id"]), @@ -234,6 +239,7 @@ async def test_replacing_media_keeps_existing_file_when_commit_fails( kind="book_file", file=UploadFile(filename="book.epub", file=BytesIO(b"first book")), ) + first_path = tmp_path / first.storage_path assert first_path.read_bytes() == b"first book" @@ -264,6 +270,7 @@ async def test_concurrent_same_kind_uploads_leave_one_asset( monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 1_073_741_824) user_id = UUID(auth_user["user_id"]) + async with test_session_maker() as setup_session: book = await _create_owned_book(setup_session, auth_user["user_id"]) book_id = book.book_id @@ -296,6 +303,7 @@ async def test_user_upload_lock_blocks_a_second_session( test_session_maker: async_sessionmaker[AsyncSession], ): user_id = UUID(auth_user["user_id"]) + async with test_session_maker() as first_session, test_session_maker() as second_session: await media_service._lock_user_uploads(first_session, user_id) second_lock = asyncio.create_task(media_service._lock_user_uploads(second_session, user_id)) @@ -314,6 +322,7 @@ async def test_concurrent_uploads_enforce_aggregate_user_quota( monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 5) user_id = UUID(auth_user["user_id"]) + async with test_session_maker() as setup_session: first_book = await _create_owned_book(setup_session, auth_user["user_id"]) second_book = await _create_owned_book(setup_session, auth_user["user_id"]) @@ -335,6 +344,7 @@ async def upload(book_id: UUID) -> MediaAsset: asset_count = await session.scalar( select(func.count()).select_from(MediaAsset).where(MediaAsset.owner_user_id == user_id) ) + used_bytes = await session.scalar( select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(MediaAsset.owner_user_id == user_id) ) diff --git a/tests/api/routes/test_notes.py b/tests/api/routes/test_notes.py index 53d35d4..d7aac0d 100644 --- a/tests/api/routes/test_notes.py +++ b/tests/api/routes/test_notes.py @@ -28,6 +28,7 @@ async def test_create_note(client: AsyncClient, auth_headers: dict[str, str], bo "is_pinned": False, }, ) + assert response.status_code == 201 data = response.json() assert data["title"] == "My Note" @@ -46,6 +47,7 @@ async def test_update_note(client: AsyncClient, auth_headers: dict[str, str], no headers=auth_headers, json={"title": "Updated Note Title"}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_opds.py b/tests/api/routes/test_opds.py index 3e9d19b..eaae2b3 100644 --- a/tests/api/routes/test_opds.py +++ b/tests/api/routes/test_opds.py @@ -45,6 +45,7 @@ async def test_anonymous_stream_preserves_final_url_and_exposes_cors_metadata(mo open_resource = Mock(return_value=resource) monkeypatch.setattr(route, "open_resource", open_resource) monkeypatch.setattr(get_settings(), "cors_origins", ["https://papyrus.example"]) + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: response = await client.post( f"{get_settings().api_prefix}/opds/relay", @@ -67,6 +68,7 @@ async def test_anonymous_stream_preserves_final_url_and_exposes_cors_metadata(mo async def test_relay_rate_limits_anonymous_requests(monkeypatch) -> None: monkeypatch.setattr(get_settings(), "rate_limit_general", 1) + async with AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://test") as client: payload = {"url": "http://127.0.0.1/feed", "catalog_url": "https://books.example/feed"} first = await client.post(f"{get_settings().api_prefix}/opds/relay", json=payload) diff --git a/tests/api/routes/test_powersync_sandbox.py b/tests/api/routes/test_powersync_sandbox.py index 4536d97..69f1063 100644 --- a/tests/api/routes/test_powersync_sandbox.py +++ b/tests/api/routes/test_powersync_sandbox.py @@ -30,6 +30,7 @@ async def _seed_demo_item( created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + session.add(item) await session.commit() await session.refresh(item) @@ -49,9 +50,7 @@ async def test_powersync_sandbox_registered_in_debug_vite_mode( """The PowerSync sandbox page uses Vite-served assets when configured.""" monkeypatch.setattr(app_settings, "dev_pages_use_vite", True) monkeypatch.setattr(app_settings, "dev_pages_vite_url", "http://vite.test:5173") - response = await debug_client.get("/__dev/powersync-sandbox") - assert response.status_code == 200 assert "PowerSync Sandbox" in response.text assert 'data-dev-page="powersync-sandbox"' in response.text @@ -73,6 +72,7 @@ async def test_powersync_sandbox_renders_built_assets_when_manifest_exists( ): """The PowerSync sandbox page falls back to built assets outside Vite mode.""" manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( json.dumps( { @@ -90,9 +90,7 @@ async def test_powersync_sandbox_renders_built_assets_when_manifest_exists( monkeypatch.setattr(app_settings, "dev_pages_use_vite", False) monkeypatch.setattr(app_settings, "dev_pages_manifest_path", str(manifest_path)) dev_pages._load_manifest.cache_clear() - response = await debug_client.get("/__dev/powersync-sandbox") - assert response.status_code == 200 assert 'href="/__dev/static/assets/powersync-sandbox.css"' in response.text assert 'src="/__dev/static/assets/powersync-sandbox.js"' in response.text @@ -132,7 +130,6 @@ async def test_powersync_sandbox_worker_asset_is_served_from_backend_origin( assert response.headers["content-type"].startswith("text/javascript") assert response.headers["x-papyrus-vendor-path"] == "WASQLiteDB.umd.js" assert response.text == "// worker/WASQLiteDB.umd.js" - nested_response = await debug_client.get("/__dev/powersync-sandbox/worker/node_modules_pnpm_example_chunk.umd.js") assert nested_response.status_code == 200 assert nested_response.headers["x-papyrus-vendor-path"] == "node_modules_pnpm_example_chunk.umd.js" @@ -181,12 +178,11 @@ async def test_powersync_demo_items_only_lists_owned_rows( primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(other_user) await db_session.flush() - await _seed_demo_item(db_session, owner_user_id=owner.user_id, title="Owned Item") await _seed_demo_item(db_session, owner_user_id=other_user.user_id, title="Other Item") - response = await debug_client.get("/__dev/powersync-demo/items", headers=auth_headers) assert response.status_code == 200 body = response.json() @@ -216,9 +212,9 @@ async def test_powersync_upload_applies_create_update_and_delete( ] }, ) + assert create_response.status_code == 200 assert create_response.json()["applied_count"] == 1 - created = await db_session.get(PowerSyncDemoItem, UUID(item_id)) assert created is not None assert created.owner_user_id == UUID(auth_user["user_id"]) @@ -238,8 +234,8 @@ async def test_powersync_upload_applies_create_update_and_delete( ] }, ) - assert update_response.status_code == 200 + assert update_response.status_code == 200 await db_session.refresh(created) assert created.title == "Updated Item" assert created.notes == "Initial notes" @@ -249,6 +245,7 @@ async def test_powersync_upload_applies_create_update_and_delete( headers=auth_headers, json={"batch": [{"type": "demo_items", "op": "DELETE", "id": item_id}]}, ) + assert delete_response.status_code == 200 db_session.expire_all() assert await db_session.get(PowerSyncDemoItem, UUID(item_id)) is None @@ -267,6 +264,7 @@ async def test_powersync_upload_rejects_mutating_other_users_rows( primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(other_user) await db_session.flush() foreign_item = await _seed_demo_item(db_session, owner_user_id=other_user.user_id, title="Other Owner Item") diff --git a/tests/api/routes/test_progress.py b/tests/api/routes/test_progress.py index 90626d8..ccae388 100644 --- a/tests/api/routes/test_progress.py +++ b/tests/api/routes/test_progress.py @@ -10,6 +10,7 @@ @pytest.fixture async def book_id(client, auth_headers): identifier = str(uuid4()) + result = await client.post( "/v1/sync/powersync-upload", headers=auth_headers, @@ -19,6 +20,7 @@ async def book_id(client, auth_headers): ] }, ) + assert result.status_code == 200 return identifier @@ -35,6 +37,7 @@ async def test_list_reading_sessions(client: AsyncClient, auth_headers: dict[str async def test_create_reading_session(client: AsyncClient, auth_headers: dict[str, str], book_id: str): """Test creating a reading session.""" now = datetime.now(UTC) + response = await client.post( "/v1/progress/sessions", headers=auth_headers, @@ -46,6 +49,7 @@ async def test_create_reading_session(client: AsyncClient, auth_headers: dict[st "pages_read": 20, }, ) + assert response.status_code == 200 data = response.json() assert data["book_id"] == book_id @@ -72,6 +76,7 @@ async def test_get_reading_statistics_with_date_range(client: AsyncClient, auth_ "end_date": "2024-12-31", }, ) + assert response.status_code == 200 @@ -79,6 +84,7 @@ async def test_manual_session_retry_and_real_statistics(client, auth_headers, bo from datetime import timedelta end = datetime.now(UTC) + payload = { "session_id": str(uuid4()), "book_id": book_id, @@ -86,6 +92,7 @@ async def test_manual_session_retry_and_real_statistics(client, auth_headers, bo "end_time": end.isoformat(), "pages_read": 15, } + first = await client.post("/v1/progress/sessions", headers=auth_headers, json=payload) second = await client.post("/v1/progress/sessions", headers=auth_headers, json=payload) assert first.status_code == second.status_code == 200 @@ -103,12 +110,14 @@ async def test_missing_book_is_rejected(client, auth_headers): headers=auth_headers, json={"book_id": str(uuid4()), "start_time": datetime.now(UTC).isoformat(), "pages_read": 2}, ) + assert response.status_code == 404 async def test_statistics_group_reader_checkpoints_as_one_session(client, auth_headers, book_id): end = datetime.now(UTC) session_id = str(uuid4()) + response = await client.post( "/v1/sync/powersync-upload", headers=auth_headers, @@ -135,15 +144,18 @@ async def test_statistics_group_reader_checkpoints_as_one_session(client, auth_h ] }, ) + assert response.status_code == 200, response.text sessions = (await client.get("/v1/progress/sessions", headers=auth_headers)).json() assert sessions["pagination"]["total"] == 1 assert sessions["sessions"][0]["duration_minutes"] == 20 + result = await client.get( "/v1/progress/statistics", headers=auth_headers, params={"start_date": (end - timedelta(minutes=20)).date().isoformat(), "end_date": end.date().isoformat()}, ) + assert result.status_code == 200 statistics = result.json() assert statistics["totals"]["sessions_count"] == 1 diff --git a/tests/api/routes/test_reading_profiles.py b/tests/api/routes/test_reading_profiles.py index 7b1b0ff..6c94e18 100644 --- a/tests/api/routes/test_reading_profiles.py +++ b/tests/api/routes/test_reading_profiles.py @@ -25,6 +25,7 @@ async def test_create_reading_profile(client: AsyncClient, auth_headers: dict[st "font_size": 18, }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "Dark Mode Profile" @@ -40,11 +41,13 @@ async def test_get_reading_profile(client: AsyncClient, auth_headers: dict[str, async def test_update_reading_profile(client: AsyncClient, auth_headers: dict[str, str]): """Test updating a reading profile.""" profile_id = str(uuid4()) + response = await client.patch( f"/v1/reading-profiles/{profile_id}", headers=auth_headers, json={"font_size": 20}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_saved_filters.py b/tests/api/routes/test_saved_filters.py index b580899..7768469 100644 --- a/tests/api/routes/test_saved_filters.py +++ b/tests/api/routes/test_saved_filters.py @@ -25,6 +25,7 @@ async def test_create_saved_filter(client: AsyncClient, auth_headers: dict[str, "is_pinned": True, }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "Unread Fiction" @@ -40,11 +41,13 @@ async def test_get_saved_filter(client: AsyncClient, auth_headers: dict[str, str async def test_update_saved_filter(client: AsyncClient, auth_headers: dict[str, str]): """Test updating a saved filter.""" filter_id = str(uuid4()) + response = await client.patch( f"/v1/saved-filters/{filter_id}", headers=auth_headers, json={"name": "Updated Filter Name"}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_series.py b/tests/api/routes/test_series.py index aa6d983..fb24fb2 100644 --- a/tests/api/routes/test_series.py +++ b/tests/api/routes/test_series.py @@ -23,6 +23,7 @@ async def test_create_series(client: AsyncClient, auth_headers: dict[str, str]): "is_complete": True, }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "Harry Potter" @@ -43,6 +44,7 @@ async def test_update_series(client: AsyncClient, auth_headers: dict[str, str], headers=auth_headers, json={"is_complete": True}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_shelves.py b/tests/api/routes/test_shelves.py index 0d6ad5f..9a82cd1 100644 --- a/tests/api/routes/test_shelves.py +++ b/tests/api/routes/test_shelves.py @@ -24,6 +24,7 @@ async def test_create_shelf(client: AsyncClient, auth_headers: dict[str, str]): "description": "A test shelf", }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "Test Shelf" @@ -42,6 +43,7 @@ async def test_update_shelf(client: AsyncClient, auth_headers: dict[str, str], s headers=auth_headers, json={"name": "Updated Shelf Name"}, ) + assert response.status_code == 200 @@ -65,6 +67,7 @@ async def test_add_book_to_shelf(client: AsyncClient, auth_headers: dict[str, st f"/v1/shelves/{shelf_id}/books/{book_id}", headers=auth_headers, ) + assert response.status_code == 204 @@ -74,6 +77,7 @@ async def test_remove_book_from_shelf(client: AsyncClient, auth_headers: dict[st f"/v1/shelves/{shelf_id}/books/{book_id}", headers=auth_headers, ) + assert response.status_code == 204 @@ -82,11 +86,13 @@ async def test_remove_multiple_books_from_shelf( ): """Test removing multiple books from a shelf.""" book_ids = [book_id, str(uuid4())] + response = await client.post( f"/v1/shelves/{shelf_id}/books/remove", headers=auth_headers, json={"book_ids": book_ids}, ) + assert response.status_code == 200 data = response.json() assert data["removed_count"] == 2 diff --git a/tests/api/routes/test_storage.py b/tests/api/routes/test_storage.py index 77def3e..139483b 100644 --- a/tests/api/routes/test_storage.py +++ b/tests/api/routes/test_storage.py @@ -25,6 +25,7 @@ async def test_create_storage_backend(client: AsyncClient, auth_headers: dict[st "base_path": "/storage/books", }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "My Local Storage" @@ -40,11 +41,13 @@ async def test_get_storage_backend(client: AsyncClient, auth_headers: dict[str, async def test_update_storage_backend(client: AsyncClient, auth_headers: dict[str, str]): """Test updating a storage backend.""" backend_id = str(uuid4()) + response = await client.patch( f"/v1/storage/backends/{backend_id}", headers=auth_headers, json={"name": "Updated Storage Name"}, ) + assert response.status_code == 200 @@ -65,9 +68,11 @@ async def test_test_storage_backend(client: AsyncClient, auth_headers: dict[str, async def test_set_primary_storage_backend(client: AsyncClient, auth_headers: dict[str, str]): """Test setting primary storage backend.""" backend_id = str(uuid4()) + response = await client.post( "/v1/storage/backends/set-primary", headers=auth_headers, json={"backend_id": backend_id}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index 8aee3ed..7d78cbf 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -21,10 +21,9 @@ async def test_sync_settings_are_public_and_hide_implementation_details(client: monkeypatch.setattr(app_settings, "powersync_service_url", "https://sync.papyrus.test") monkeypatch.setattr(app_settings, "file_storage_quota_bytes", 1_073_741_824) - response = await client.get("/v1/sync/settings") - assert response.status_code == 200 + assert response.json() == { "tracking_schema_version": 2, "data_sync_url": "https://sync.papyrus.test", @@ -33,6 +32,7 @@ async def test_sync_settings_are_public_and_hide_implementation_details(client: "quota_bytes": 1_073_741_824, }, } + assert "powersync" not in response.text.lower() @@ -54,6 +54,7 @@ async def test_powersync_upload_applies_book_mutation( ): """Test production PowerSync upload endpoint applies owned book mutations.""" book_id = str(uuid4()) + response = await client.post( "/v1/sync/powersync-upload", headers=auth_headers, @@ -71,9 +72,9 @@ async def test_powersync_upload_applies_book_mutation( ] }, ) + assert response.status_code == 200 assert response.json()["applied_count"] == 1 - book = await db_session.get(SyncBook, UUID(book_id)) assert book is not None assert book.owner_user_id == UUID(auth_user["user_id"]) @@ -95,6 +96,7 @@ async def test_powersync_upload_applies_book_patch( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() book_id = book.book_id @@ -113,9 +115,9 @@ async def test_powersync_upload_applies_book_patch( ] }, ) + assert response.status_code == 200 assert response.json()["applied_count"] == 1 - db_session.expire_all() patched_book = await db_session.get(SyncBook, book_id) assert patched_book is not None @@ -137,6 +139,7 @@ async def test_powersync_upload_applies_book_delete( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() book_id = book.book_id @@ -146,9 +149,9 @@ async def test_powersync_upload_applies_book_delete( headers=auth_headers, json={"batch": [{"type": "books", "op": "DELETE", "id": str(book.book_id)}]}, ) + assert response.status_code == 200 assert response.json()["applied_count"] == 1 - db_session.expire_all() deleted_book = await db_session.get(SyncBook, book_id) assert deleted_book is None @@ -164,6 +167,7 @@ async def test_powersync_upload_rejects_unsupported_table( headers=auth_headers, json={"batch": [{"type": "reading_sessions", "op": "PUT", "id": str(uuid4()), "data": {"name": "Shelf"}}]}, ) + assert response.status_code == 422 @@ -178,6 +182,7 @@ async def test_powersync_upload_rejects_partial_future_tables( headers=auth_headers, json={"batch": [{"type": table, "op": "PUT", "id": str(uuid4()), "data": {}}]}, ) + assert response.status_code == 422 @@ -199,6 +204,7 @@ async def test_powersync_upload_rejects_unknown_book_fields( ] }, ) + assert response.status_code == 422 @@ -209,7 +215,14 @@ async def test_powersync_upload_controls_owner_and_updated_at( db_session: AsyncSession, ): book_id = uuid4() - client_timestamp = datetime(2000, 1, 1, tzinfo=UTC) + + client_timestamp = datetime( + 2000, + 1, + 1, + tzinfo=UTC, + ) + response = await client.post( "/v1/sync/powersync-upload", headers=auth_headers, @@ -249,8 +262,10 @@ async def test_powersync_upload_rejects_cross_user_book_mutation( primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(other_user) await db_session.flush() + foreign_book = SyncBook( book_id=uuid4(), owner_user_id=other_user.user_id, @@ -258,6 +273,7 @@ async def test_powersync_upload_rejects_cross_user_book_mutation( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(foreign_book) await db_session.commit() @@ -275,6 +291,7 @@ async def test_powersync_upload_rejects_cross_user_book_mutation( ] }, ) + assert response.status_code == 403 @@ -288,6 +305,7 @@ async def test_powersync_upload_accepts_owned_media_references( ): monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) book_id = uuid4() + book = SyncBook( book_id=book_id, owner_user_id=UUID(auth_user["user_id"]), @@ -295,14 +313,17 @@ async def test_powersync_upload_accepts_owned_media_references( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() + upload = await client.post( "/v1/media", headers=auth_headers, data={"book_id": str(book_id), "kind": "cover_image"}, files={"file": ("cover.png", b"png bytes", "image/png")}, ) + assert upload.status_code == 201 asset_id = upload.json()["asset_id"] @@ -361,6 +382,7 @@ async def test_powersync_book_delete_removes_media_files_after_commit( ): monkeypatch.setattr("papyrus.main.settings.media_storage_root", str(tmp_path), raising=False) book_id = uuid4() + book = SyncBook( book_id=book_id, owner_user_id=UUID(auth_user["user_id"]), @@ -368,14 +390,17 @@ async def test_powersync_book_delete_removes_media_files_after_commit( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() + upload = await client.post( "/v1/media", headers=auth_headers, data={"book_id": str(book_id), "kind": "book_file"}, files={"file": ("book.epub", b"epub bytes", "application/epub+zip")}, ) + assert upload.status_code == 201 media_path = tmp_path / upload.json()["storage_path"] assert media_path.exists() @@ -400,6 +425,7 @@ async def test_powersync_book_delete_keeps_media_file_when_commit_fails( monkeypatch.setattr("papyrus.main.settings.file_storage_quota_bytes", 1_073_741_824) user_id = UUID(auth_user["user_id"]) book_id = uuid4() + book = SyncBook( book_id=book_id, owner_user_id=user_id, @@ -407,8 +433,10 @@ async def test_powersync_book_delete_keeps_media_file_when_commit_fails( added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + db_session.add(book) await db_session.commit() + asset = await media_service.upload_media( db_session, user_id, @@ -416,6 +444,7 @@ async def test_powersync_book_delete_keeps_media_file_when_commit_fails( kind="book_file", file=UploadFile(filename="book.epub", file=BytesIO(b"epub bytes")), ) + media_path = tmp_path / asset.storage_path assert media_path.exists() @@ -428,7 +457,13 @@ async def fail_commit() -> None: await sync_service.apply_powersync_upload_batch( db_session, user_id, - [PowerSyncCrudMutation(type="books", op="DELETE", id=str(book_id))], + [ + PowerSyncCrudMutation( + type="books", + op="DELETE", + id=str(book_id), + ) + ], ) assert media_path.exists() diff --git a/tests/api/routes/test_tags.py b/tests/api/routes/test_tags.py index 8948326..69cc67c 100644 --- a/tests/api/routes/test_tags.py +++ b/tests/api/routes/test_tags.py @@ -22,6 +22,7 @@ async def test_create_tag(client: AsyncClient, auth_headers: dict[str, str]): "description": "Fiction books", }, ) + assert response.status_code == 201 data = response.json() assert data["name"] == "Fiction" @@ -41,6 +42,7 @@ async def test_update_tag(client: AsyncClient, auth_headers: dict[str, str], tag headers=auth_headers, json={"name": "Updated Tag"}, ) + assert response.status_code == 200 diff --git a/tests/api/routes/test_users.py b/tests/api/routes/test_users.py index 70246d7..f705251 100644 --- a/tests/api/routes/test_users.py +++ b/tests/api/routes/test_users.py @@ -26,6 +26,7 @@ async def test_update_current_user(client: AsyncClient, auth_headers: dict[str, headers=auth_headers, json={"display_name": "Updated Name"}, ) + assert response.status_code == 200 data = response.json() assert data["display_name"] == "Updated Name" @@ -39,6 +40,7 @@ async def test_delete_current_user(client: AsyncClient, auth_headers: dict[str, headers=auth_headers, json={"password": auth_user["password"]}, ) + assert response.status_code == 204 protected_response = await client.get("/v1/users/me", headers=auth_headers) assert protected_response.status_code == 401 @@ -59,6 +61,7 @@ async def test_update_user_preferences(client: AsyncClient, auth_headers: dict[s headers=auth_headers, json={"theme": "light", "notifications_enabled": False}, ) + assert response.status_code == 200 data = response.json() assert data["theme"] == "light" @@ -75,6 +78,7 @@ async def test_change_password(client: AsyncClient, auth_headers: dict[str, str] "new_password": "NewSecureP@ss123", }, ) + assert response.status_code == 200 data = response.json() assert "message" in data @@ -101,6 +105,7 @@ async def test_expired_access_token_is_rejected(client: AsyncClient, auth_user: {"sub": auth_user["user_id"], "sid": auth_user["session_id"]}, expires_delta=timedelta(seconds=-1), ) + response = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {expired_token}"}) assert response.status_code == 401 diff --git a/tests/conftest.py b/tests/conftest.py index 02d458d..d6887dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,6 +153,7 @@ async def test_session_maker(setup_test_db: None) -> AsyncGenerator[async_sessio finally: async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() @@ -171,6 +172,7 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]: yield session prod_app.dependency_overrides[get_db] = override_get_db + try: async with AsyncClient(transport=ASGITransport(app=prod_app), base_url="http://test") as c: yield c @@ -193,6 +195,7 @@ async def override_get_db() -> AsyncGenerator[AsyncSession, None]: yield session debug_app.dependency_overrides[get_db] = override_get_db + try: async with AsyncClient(transport=ASGITransport(app=debug_app), base_url="http://test") as c: yield c @@ -241,6 +244,7 @@ async def auth_user( expires_at=datetime.now(UTC) + timedelta(days=30), last_seen_at=datetime.now(UTC), ) + session.add(auth_session) await session.commit() access_token = create_access_token({"sub": str(user_uuid), "sid": str(auth_session.session_id)}) diff --git a/tests/core/test_security.py b/tests/core/test_security.py index 99325fb..a407902 100644 --- a/tests/core/test_security.py +++ b/tests/core/test_security.py @@ -27,6 +27,7 @@ def powersync_key_files(tmp_path: Path) -> tuple[Path, Path]: encryption_algorithm=serialization.NoEncryption(), ) ) + public_key_path.write_bytes( private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, @@ -59,12 +60,14 @@ def test_create_powersync_token_supports_file_based_keys( try: token, expires_in = security_module.create_powersync_token("user-123") + payload = jwt.decode( token, public_key_path.read_text(encoding="utf-8"), algorithms=["RS256"], audience="powersync-dev", ) + jwks = security_module.get_powersync_jwks() finally: security_module._get_powersync_private_key.cache_clear() @@ -119,6 +122,7 @@ def test_powersync_jwks_includes_previous_public_key_for_rotation( private_key_path, public_key_path = powersync_key_files previous_private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) previous_public_key_path = tmp_path / "previous_public.pem" + previous_public_key_path.write_bytes( previous_private_key.public_key().public_bytes( encoding=serialization.Encoding.PEM, diff --git a/tests/integration/test_auth_smoke.py b/tests/integration/test_auth_smoke.py index 1a021a3..f981332 100644 --- a/tests/integration/test_auth_smoke.py +++ b/tests/integration/test_auth_smoke.py @@ -27,6 +27,7 @@ async def test_smtp_password_reset_smoke( pytest.skip("RUN_SMTP_SMOKE_TEST is not enabled") recipient = os.environ.get("AUTH_SMOKE_EMAIL_RECIPIENT") + if not recipient: pytest.skip("AUTH_SMOKE_EMAIL_RECIPIENT is not configured") @@ -36,7 +37,14 @@ async def test_smtp_password_reset_smoke( async with test_session_maker() as session: from papyrus.models import User - session.add(User(display_name="SMTP Smoke", primary_email=recipient, primary_email_verified=True)) + session.add( + User( + display_name="SMTP Smoke", + primary_email=recipient, + primary_email_verified=True, + ) + ) + await session.commit() message = await auth_service.begin_password_reset(session, recipient) assert message == "If the email is registered, a reset link has been sent" @@ -96,6 +104,7 @@ async def test_google_oauth_smoke(): refresh_url, json={"refresh_token": refresh_token}, ) + assert refresh_response.status_code == 200, refresh_response.text refresh_body = refresh_response.json() assert refresh_body["access_token"] @@ -103,10 +112,12 @@ async def test_google_oauth_smoke(): assert refresh_body["refresh_token"] != refresh_token print(f"AUTH_SMOKE_ROTATED_REFRESH_TOKEN={refresh_body['refresh_token']}") current_access_token = refresh_body["access_token"] + me_response = await client.get( me_url, headers={"Authorization": f"Bearer {current_access_token}"}, ) + assert me_response.status_code == 200, me_response.text me_body = me_response.json() assert me_body["user_id"] diff --git a/tests/services/test_acquisition.py b/tests/services/test_acquisition.py index 6b1059c..1727164 100644 --- a/tests/services/test_acquisition.py +++ b/tests/services/test_acquisition.py @@ -39,11 +39,17 @@ def _release_candidate() -> object: def test_release_token_round_trip_preserves_private_release_data() -> None: endpoint = _endpoint("prowlarr") - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) token = acquisition.create_release_token(_release_candidate(), endpoint, now=now) payload = acquisition.decode_release_token(token, endpoint.owner_user_id, now=now) - assert payload.endpoint_id == endpoint.endpoint_id assert payload.owner_user_id == endpoint.owner_user_id assert payload.title == "A Test Book" @@ -64,7 +70,15 @@ def test_release_token_rejects_another_owner() -> None: def test_release_token_rejects_expired_or_tampered_values() -> None: endpoint = _endpoint("prowlarr") - issued_at = datetime(2026, 7, 25, 12, tzinfo=UTC) + + issued_at = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) + token = acquisition.create_release_token(_release_candidate(), endpoint, now=issued_at) with pytest.raises(HTTPException) as expired: @@ -161,7 +175,6 @@ async def request(*args: object, **kwargs: object) -> tuple[int, dict[str, str], return 204, {"Set-Cookie": "QBT_SID_8082=test; path=/"}, b"" monkeypatch.setattr(providers, "_request", request) - await acquisition.test_endpoint_connection(_endpoint("qbittorrent")) @@ -195,6 +208,7 @@ async def test_qbittorrent_submission_sends_managed_tag_and_save_path( (200, {}, b""), ] ) + bodies: list[bytes] = [] async def request(*args: object, **kwargs: object) -> tuple[int, dict[str, str], bytes]: @@ -236,6 +250,7 @@ async def test_qbittorrent_client_reuses_login_for_torrent_and_file_queries( ), ] ) + requests: list[tuple[str, dict[str, str]]] = [] async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], bytes]: @@ -243,17 +258,16 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) torrent = await client.find_torrent(tag="papyrus:job") files = await client.files("abc123") - assert torrent.hash == "abc123" assert torrent.progress_basis_points == 5000 assert torrent.downloaded_bytes == 512 assert torrent.total_bytes == 1024 assert torrent.download_speed_bytes_per_second == 128 assert torrent.eta_seconds == 4 + assert files == [ acquisition.QbittorrentFile( index=0, @@ -263,6 +277,7 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte priority=1, ) ] + assert requests[1][0].endswith("api/v2/torrents/info?tag=papyrus%3Ajob") assert requests[2][0].endswith("api/v2/torrents/files?hash=abc123") assert requests[1][1]["Cookie"] == "QBT_SID_8082=test" @@ -289,10 +304,8 @@ async def request(*args: object, **kwargs: object) -> tuple[int, dict[str, str], return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) torrent = await client.find_torrent(tag="papyrus:job") - assert torrent.downloaded_bytes == 1024 assert torrent.total_bytes == 1024 assert torrent.eta_seconds == 0 @@ -313,6 +326,7 @@ async def test_qbittorrent_client_falls_back_to_job_tag_when_saved_hash_is_missi ), ] ) + request_urls: list[str] = [] async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], bytes]: @@ -320,8 +334,8 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) + torrent = await client.find_torrent( tag="papyrus:job", torrent_hash="stale-hash", @@ -344,15 +358,16 @@ async def test_qbittorrent_client_pauses_and_prioritizes_one_file( (200, {}, b""), ] ) + requests: list[tuple[str, dict[str, list[str]]]] = [] async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], bytes]: if body := kwargs.get("body"): requests.append((url, parse_qs(cast(bytes, body).decode()))) + return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) await client.select_file("abc123", selected_index=1, file_indices=[0, 1, 2]) @@ -362,17 +377,21 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte "filePrio", "resume", ] + assert requests[1][1] == {"hashes": ["abc123"]} + assert requests[2][1] == { "hash": ["abc123"], "id": ["0|2"], "priority": ["0"], } + assert requests[3][1] == { "hash": ["abc123"], "id": ["1"], "priority": ["1"], } + assert requests[4][1] == {"hashes": ["abc123"]} @@ -390,6 +409,7 @@ async def test_qbittorrent_client_uses_v5_stop_and_start_when_legacy_actions_are (200, {}, b""), ] ) + request_urls: list[str] = [] async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], bytes]: @@ -397,7 +417,6 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) await client.select_file("abc123", selected_index=1, file_indices=[0, 1, 2]) @@ -420,19 +439,20 @@ async def test_qbittorrent_client_deletes_torrent_and_downloaded_data( (200, {}, b""), ] ) + requests: list[tuple[str, dict[str, list[str]]]] = [] async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], bytes]: if body := kwargs.get("body"): requests.append((url, parse_qs(cast(bytes, body).decode()))) + return next(responses) monkeypatch.setattr(providers, "_request", request) - client = await acquisition.QbittorrentClient.connect(_endpoint("qbittorrent")) await client.delete_torrent("abc123") - assert requests[1][0].endswith("api/v2/torrents/delete") + assert requests[1][1] == { "hashes": ["abc123"], "deleteFiles": ["true"], @@ -448,6 +468,7 @@ async def test_qbittorrent_submission_accepts_lowercase_session_cookie_header( (200, {}, b""), ] ) + request_headers: list[dict[str, str]] = [] async def request(*args: object, **kwargs: object) -> tuple[int, dict[str, str], bytes]: @@ -492,7 +513,5 @@ async def request(url: str, **kwargs: object) -> tuple[int, dict[str, str], byte return 201, {}, b'{"id":1}' monkeypatch.setattr(providers, "_request", request) - await acquisition.dispatch_arr_command(_endpoint("readarr"), "BookSearch", [1]) - assert urls == ["http://integration.test/api/v3/command"] diff --git a/tests/services/test_acquisition_monitor.py b/tests/services/test_acquisition_monitor.py index 1d467d6..287bd42 100644 --- a/tests/services/test_acquisition_monitor.py +++ b/tests/services/test_acquisition_monitor.py @@ -23,8 +23,15 @@ async def test_process_job_persists_active_qbittorrent_progress( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_monitor_active_interval_seconds", 2, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_monitor_active_interval_seconds", + 2, + raising=False, + ) + owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -32,8 +39,10 @@ async def test_process_job_persists_active_qbittorrent_progress( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -41,9 +50,17 @@ async def test_process_job_persists_active_qbittorrent_progress( status="submitted", client_hash="saved-hash", ) + db_session.add(job) await db_session.commit() - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) class FakeQbittorrentClient: async def find_torrent( @@ -95,9 +112,23 @@ async def test_process_job_imports_one_completed_book_file_and_keeps_source( ) -> None: media_root = tmp_path / "media" import_root = tmp_path / "downloads" - monkeypatch.setattr(app_settings, "media_storage_root", str(media_root), raising=False) - monkeypatch.setattr(app_settings, "file_storage_quota_bytes", 1_073_741_824, raising=False) + + monkeypatch.setattr( + app_settings, + "media_storage_root", + str(media_root), + raising=False, + ) + + monkeypatch.setattr( + app_settings, + "file_storage_quota_bytes", + 1_073_741_824, + raising=False, + ) + owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -105,9 +136,11 @@ async def test_process_job_imports_one_completed_book_file_and_keeps_source( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Completed Book") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -115,12 +148,20 @@ async def test_process_job_imports_one_completed_book_file_and_keeps_source( title=book.title, status="downloading", ) + db_session.add(job) await db_session.commit() source_path = import_root / str(owner_user_id) / str(job.job_id) / "book.epub" source_path.parent.mkdir(parents=True) source_path.write_bytes(b"completed epub") - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) class FakeQbittorrentClient: async def find_torrent( @@ -141,6 +182,7 @@ async def find_torrent( async def files(self, torrent_hash: str) -> list[acquisition_service.QbittorrentFile]: assert torrent_hash == "abc123" + return [ acquisition_service.QbittorrentFile( index=0, @@ -176,6 +218,7 @@ async def test_process_job_pauses_completed_torrent_with_multiple_book_files( tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -183,9 +226,11 @@ async def test_process_job_pauses_completed_torrent_with_multiple_book_files( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Multiple Books") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -193,6 +238,7 @@ async def test_process_job_pauses_completed_torrent_with_multiple_book_files( title=book.title, status="downloading", ) + db_session.add(job) await db_session.commit() paused_hashes: list[str] = [] @@ -216,8 +262,20 @@ async def find_torrent( async def files(self, torrent_hash: str) -> list[acquisition_service.QbittorrentFile]: return [ - acquisition_service.QbittorrentFile(0, "first.epub", 1024, 10_000, 1), - acquisition_service.QbittorrentFile(1, "second.pdf", 2048, 10_000, 1), + acquisition_service.QbittorrentFile( + 0, + "first.epub", + 1024, + 10_000, + 1, + ), + acquisition_service.QbittorrentFile( + 1, + "second.pdf", + 2048, + 10_000, + 1, + ), ] async def pause(self, torrent_hash: str) -> None: @@ -234,6 +292,7 @@ async def pause(self, torrent_hash: str) -> None: assert job.status == "needs_file_selection" assert job.next_poll_at is None assert paused_hashes == ["abc123"] + assert ( await db_session.scalar(select(func.count()).select_from(MediaAsset).where(MediaAsset.book_id == book.book_id)) == 0 @@ -296,9 +355,23 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_monitor_lease_seconds", 30, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_monitor_lease_seconds", + 30, + raising=False, + ) + owner_user_id = UUID(auth_user["user_id"]) - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -306,11 +379,13 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() books = [SyncBook(owner_user_id=owner_user_id, title=f"Book {index}") for index in range(4)] db_session.add_all(books) await db_session.flush() + due_jobs = [ AcquisitionJob( owner_user_id=owner_user_id, @@ -322,6 +397,7 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( ) for index in (1, 2) ] + future_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -330,6 +406,7 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( status="downloading", next_poll_at=now + timedelta(minutes=1), ) + leased_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -340,6 +417,7 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( lease_owner="other-worker", lease_until=now + timedelta(minutes=1), ) + db_session.add_all([*due_jobs, future_job, leased_job]) await db_session.commit() @@ -349,6 +427,7 @@ async def test_claim_due_jobs_assigns_distinct_database_leases( now=now, limit=1, ) + second = await acquisition_monitor.claim_due_jobs( db_session, "worker-b", @@ -376,6 +455,7 @@ async def test_claim_due_jobs_ignores_automatic_rule_jobs( db_session: AsyncSession, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + download_client = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -383,23 +463,27 @@ async def test_claim_due_jobs_ignores_automatic_rule_jobs( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + arr_endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="Readarr", kind="readarr", base_url="http://readarr.local:8787", ) + rule = AcquisitionRule( owner_user_id=owner_user_id, name="Automatic", query="automatic book", ) + db_session.add_all([download_client, arr_endpoint, rule]) await db_session.flush() manual_book = SyncBook(owner_user_id=owner_user_id, title="Manual") rule_book = SyncBook(owner_user_id=owner_user_id, title="Automatic") db_session.add_all([manual_book, rule_book]) await db_session.flush() + manual_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=download_client.endpoint_id, @@ -407,6 +491,7 @@ async def test_claim_due_jobs_ignores_automatic_rule_jobs( title="Manual", status="submitted", ) + rule_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=download_client.endpoint_id, @@ -415,12 +500,14 @@ async def test_claim_due_jobs_ignores_automatic_rule_jobs( title="Automatic", status="submitted", ) + arr_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=arr_endpoint.endpoint_id, title="Readarr search", status="submitted", ) + legacy_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=download_client.endpoint_id, @@ -428,13 +515,20 @@ async def test_claim_due_jobs_ignores_automatic_rule_jobs( download_url="magnet:?xt=urn:btih:legacy", status="submitted", ) + db_session.add_all([manual_job, rule_job, arr_job, legacy_job]) await db_session.commit() claimed = await acquisition_monitor.claim_due_jobs( db_session, "worker", - now=datetime(2026, 7, 25, 12, tzinfo=UTC), + now=datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ), ) assert claimed == [manual_job.job_id] @@ -446,6 +540,7 @@ async def test_transient_endpoint_failure_reschedules_managed_job( tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -453,9 +548,11 @@ async def test_transient_endpoint_failure_reschedules_managed_job( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Transient") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -464,9 +561,17 @@ async def test_transient_endpoint_failure_reschedules_managed_job( status="submitted", lease_owner="worker", ) + db_session.add(job) await db_session.commit() - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) async def connect(endpoint: AcquisitionEndpoint) -> acquisition_monitor.QbittorrentMonitorClient: raise HTTPException(status_code=502, detail="qBittorrent is temporarily unavailable") @@ -494,6 +599,7 @@ async def test_missing_qbittorrent_torrent_becomes_terminal_after_bounded_retrie tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -501,9 +607,11 @@ async def test_missing_qbittorrent_torrent_becomes_terminal_after_bounded_retrie base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Missing") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -513,6 +621,7 @@ async def test_missing_qbittorrent_torrent_becomes_terminal_after_bounded_retrie retry_count=acquisition_monitor.MAX_MISSING_TORRENT_RETRIES - 1, lease_owner="worker", ) + db_session.add(job) await db_session.commit() @@ -550,6 +659,7 @@ async def test_failed_recovered_torrent_remains_eligible_for_import_retry( tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -557,9 +667,11 @@ async def test_failed_recovered_torrent_remains_eligible_for_import_retry( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + book = SyncBook(owner_user_id=owner_user_id, title="Recovered") db_session.add_all([endpoint, book]) await db_session.flush() + job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -568,9 +680,17 @@ async def test_failed_recovered_torrent_remains_eligible_for_import_retry( status="queued", lease_owner="worker", ) + db_session.add(job) await db_session.commit() - now = datetime(2026, 7, 25, 12, tzinfo=UTC) + + now = datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ) class FakeQbittorrentClient: async def find_torrent( @@ -629,6 +749,7 @@ async def test_failure_finalization_does_not_overwrite_cancelled_job( status="cancelled", lease_owner="worker", ) + db_session.add(job) await db_session.commit() @@ -636,7 +757,13 @@ async def test_failure_finalization_does_not_overwrite_cancelled_job( db_session, job.job_id, "Late monitor failure", - datetime(2026, 7, 25, 12, tzinfo=UTC), + datetime( + 2026, + 7, + 25, + 12, + tzinfo=UTC, + ), worker_id="worker", ) @@ -651,6 +778,7 @@ async def test_process_claimed_jobs_connects_once_per_endpoint( tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -658,8 +786,10 @@ async def test_process_claimed_jobs_connects_once_per_endpoint( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + jobs = [ AcquisitionJob( owner_user_id=owner_user_id, @@ -670,6 +800,7 @@ async def test_process_claimed_jobs_connects_once_per_endpoint( ) for index in (1, 2) ] + db_session.add_all(jobs) await db_session.commit() connected_endpoints: list[UUID] = [] @@ -698,7 +829,6 @@ async def connect( connected_endpoint: AcquisitionEndpoint, ) -> FakeQbittorrentClient: connected_endpoints.append(connected_endpoint.endpoint_id) - return FakeQbittorrentClient() await acquisition_monitor.process_claimed_jobs( @@ -718,6 +848,7 @@ async def test_process_claimed_jobs_isolates_job_failures( tmp_path: Path, ) -> None: owner_user_id = UUID(auth_user["user_id"]) + endpoint = AcquisitionEndpoint( owner_user_id=owner_user_id, name="qBittorrent", @@ -725,8 +856,10 @@ async def test_process_claimed_jobs_isolates_job_failures( base_url="http://qbittorrent.local:8080", download_root="/downloads", ) + db_session.add(endpoint) await db_session.flush() + failed_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -734,6 +867,7 @@ async def test_process_claimed_jobs_isolates_job_failures( status="submitted", lease_owner="worker", ) + healthy_job = AcquisitionJob( owner_user_id=owner_user_id, endpoint_id=endpoint.endpoint_id, @@ -741,6 +875,7 @@ async def test_process_claimed_jobs_isolates_job_failures( status="submitted", lease_owner="worker", ) + db_session.add_all([failed_job, healthy_job]) await db_session.commit() @@ -799,8 +934,20 @@ async def test_run_monitor_uses_idle_or_active_poll_interval( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_monitor_active_interval_seconds", 2, raising=False) - monkeypatch.setattr(app_settings, "acquisition_monitor_idle_interval_seconds", 10, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_monitor_active_interval_seconds", + 2, + raising=False, + ) + + monkeypatch.setattr( + app_settings, + "acquisition_monitor_idle_interval_seconds", + 10, + raising=False, + ) + processed_job_ids: list[UUID] = [] delays: list[float] = [] @@ -852,7 +999,13 @@ async def test_run_monitor_continues_after_cycle_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - monkeypatch.setattr(app_settings, "acquisition_monitor_idle_interval_seconds", 10, raising=False) + monkeypatch.setattr( + app_settings, + "acquisition_monitor_idle_interval_seconds", + 10, + raising=False, + ) + claim_attempts = 0 delays: list[float] = [] diff --git a/tests/services/test_auth.py b/tests/services/test_auth.py index 6967d7a..a3f8d7a 100644 --- a/tests/services/test_auth.py +++ b/tests/services/test_auth.py @@ -60,6 +60,7 @@ async def _create_user_with_password( primary_email_verified=True, disabled_at=datetime.now(UTC) if disabled else None, ) + session.add(user) await session.flush() session.add(PasswordCredential(user_id=user.user_id, password_hash=hash_password(password))) @@ -170,6 +171,7 @@ async def test_refresh_tokens_rotates_and_invalidates_previous_token( ), None, ) + first_refresh_token = register_result.refresh_token rotated = await auth_service.refresh_tokens(session, RefreshTokenRequest(refresh_token=first_refresh_token)) assert rotated.refresh_token != first_refresh_token @@ -192,6 +194,7 @@ async def test_logout_current_session_revokes_session( ), None, ) + session_result = await session.execute(select(AuthSession).where(AuthSession.user_id == result.user.user_id)) auth_session = session_result.scalar_one() await auth_service.logout_current_session(session, result.user.user_id, auth_session.session_id) @@ -213,6 +216,7 @@ async def test_logout_all_sessions_revokes_every_session( ), None, ) + second_login = await auth_service.login_user( session, LoginRequest(email="all-sessions@example.com", password="SecureP@ss123"), @@ -239,7 +243,9 @@ async def test_reset_password_revokes_all_sessions( ), None, ) + plain_token = generate_opaque_token() + session.add( EmailActionToken( user_id=register_result.user.user_id, @@ -248,11 +254,14 @@ async def test_reset_password_revokes_all_sessions( expires_at=datetime.now(UTC) + timedelta(minutes=30), ) ) + await session.commit() await auth_service.reset_password(session, plain_token, "NewSecureP@ss123") + session_result = await session.execute( select(AuthSession).where(AuthSession.user_id == register_result.user.user_id) ) + assert all(auth_session.revoked_at is not None for auth_session in session_result.scalars()) @@ -263,9 +272,15 @@ async def test_google_login_reuses_existing_identity( ): """Test Google login matches an existing linked identity by provider subject.""" async with test_session_maker() as session: - user = User(display_name="Google User", primary_email="google@example.com", primary_email_verified=True) + user = User( + display_name="Google User", + primary_email="google@example.com", + primary_email_verified=True, + ) + session.add(user) await session.flush() + session.add( UserIdentity( user_id=user.user_id, @@ -274,6 +289,7 @@ async def test_google_login_reuses_existing_identity( email_at_provider="old@example.com", ) ) + await session.commit() monkeypatch.setattr( @@ -299,6 +315,7 @@ async def test_google_login_reuses_existing_identity( state_token=state, error=None, ) + exchange_code = parse_qs(urlparse(redirect_url).query)["code"][0] result = await auth_service.exchange_login_code( @@ -306,9 +323,11 @@ async def test_google_login_reuses_existing_identity( OAuthExchangeRequest(code=exchange_code, client_type="web"), None, ) + identity_result = await session.execute( select(UserIdentity).where(UserIdentity.provider_subject == "google-subject") ) + identity = identity_result.scalar_one() assert result.user.user_id == user.user_id assert identity.email_at_provider == "updated@example.com" @@ -357,6 +376,7 @@ async def test_exchange_login_code_rejects_expired_code( async with test_session_maker() as session: user = await _create_user_with_password(session, email="exchange-expired@example.com") plain_code = generate_opaque_token() + session.add( AuthExchangeCode( code_hash=hash_opaque_token(plain_code), @@ -367,6 +387,7 @@ async def test_exchange_login_code_rejects_expired_code( expires_at=datetime.now(UTC) - timedelta(minutes=1), ) ) + await session.commit() with pytest.raises(UnauthorizedError): @@ -384,6 +405,7 @@ async def test_exchange_login_code_cannot_be_reused( async with test_session_maker() as session: user = await _create_user_with_password(session, email="exchange-reuse@example.com") plain_code = generate_opaque_token() + session.add( AuthExchangeCode( code_hash=hash_opaque_token(plain_code), @@ -394,6 +416,7 @@ async def test_exchange_login_code_cannot_be_reused( expires_at=datetime.now(UTC) + timedelta(minutes=5), ) ) + await session.commit() result = await auth_service.exchange_login_code( @@ -401,6 +424,7 @@ async def test_exchange_login_code_cannot_be_reused( OAuthExchangeRequest(code=plain_code, client_type="web"), None, ) + assert result.user.user_id == user.user_id with pytest.raises(UnauthorizedError): @@ -419,6 +443,7 @@ async def test_verify_email_token_rejects_expired_and_reused_tokens( user = await _create_user_with_password(session, email="verify@example.com") expired_token = generate_opaque_token() valid_token = generate_opaque_token() + session.add_all( [ EmailActionToken( @@ -435,6 +460,7 @@ async def test_verify_email_token_rejects_expired_and_reused_tokens( ), ] ) + await session.commit() with pytest.raises(ValidationError): diff --git a/tests/services/test_library_validation.py b/tests/services/test_library_validation.py index c7ea19d..1c6d6f5 100644 --- a/tests/services/test_library_validation.py +++ b/tests/services/test_library_validation.py @@ -27,5 +27,6 @@ def test_legacy_invalid_values_remain_in_envelope_without_blocking_queue(): def test_explicit_promoted_value_wins_and_stays_subject_to_validation(): normalized = normalize_book_payload({"custom_metadata": {"is_physical": True}, "is_physical": None}) assert normalized["is_physical"] is None + with pytest.raises(ValidationError): convert_value(SyncBook.__table__.c.is_physical, normalized["is_physical"]) diff --git a/tests/services/test_opds.py b/tests/services/test_opds.py index 657fdd2..25a290c 100644 --- a/tests/services/test_opds.py +++ b/tests/services/test_opds.py @@ -37,6 +37,7 @@ def connection(parts: SplitResult, address: str) -> Mock: item.address = address item._aborted = False result = responses.pop(0) + if isinstance(result, Exception): item.getresponse.side_effect = result else: @@ -65,13 +66,20 @@ def request(**kwargs: object) -> OpdsRelayRequest: def test_streams_redirected_bytes_with_final_url_and_origin_scoped_credentials(transport) -> None: responses, connections = transport + responses.extend( [ upstream(status=302, Location="/edition"), upstream(status=307, Location="https://cdn.example/book.epub"), - upstream(b"epub bytes", Content_Type="application/epub+zip", Content_Length="10", Set_Cookie="secret=1"), + upstream( + b"epub bytes", + Content_Type="application/epub+zip", + Content_Length="10", + Set_Cookie="secret=1", + ), ] ) + resource = opds.open_resource(request(credentials={"username": "reader", "password": "secret"})) assert resource.url == "https://cdn.example/book.epub" assert resource.length == 10 @@ -100,6 +108,7 @@ def test_streams_redirected_bytes_with_final_url_and_origin_scoped_credentials(t def test_rejects_nonpublic_dns_answers_before_connecting(transport, monkeypatch, address: str) -> None: _, connections = transport monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [(2, 1, 6, "", (address, 80))]) + with pytest.raises(opds.OpdsRelayError, match="public"): opds.open_resource(request()) @@ -109,6 +118,7 @@ def test_rejects_nonpublic_dns_answers_before_connecting(transport, monkeypatch, def test_rechecks_redirect_hosts_and_releases_connection(transport, monkeypatch) -> None: responses, connections = transport responses.append(upstream(status=302, Location="http://internal.example/private")) + monkeypatch.setattr( socket, "getaddrinfo", @@ -125,6 +135,7 @@ def test_rechecks_redirect_hosts_and_releases_connection(transport, monkeypatch) ) ], ) + with pytest.raises(opds.OpdsRelayError, match="public"): opds.open_resource(request(url="http://books.example/feed")) @@ -141,6 +152,7 @@ def test_rejects_mixed_public_and_private_dns_answers(transport, monkeypatch) -> (2, 1, 6, "", ("127.0.0.1", 80)), ], ) + with pytest.raises(opds.OpdsRelayError, match="public"): opds.open_resource(request()) @@ -165,6 +177,7 @@ def test_rejects_unsafe_url_syntax(transport, url: str) -> None: def test_maps_upstream_errors_without_exposing_response_content(transport, status: int, expected: int) -> None: responses, connections = transport responses.append(upstream(b"upstream private error details", status=status)) + with pytest.raises(opds.OpdsRelayError) as error: opds.open_resource(request()) @@ -177,6 +190,7 @@ def test_maps_upstream_errors_without_exposing_response_content(transport, statu def test_limits_known_and_unknown_length_streams_and_closes(transport, headers: dict[str, str]) -> None: responses, connections = transport responses.append(upstream(b"12345", **headers)) + with pytest.raises(opds.OpdsRelayError, match="too large"): resource = opds.open_resource(request(max_bytes=4)) list(resource.chunks()) @@ -188,6 +202,7 @@ def test_truncated_stream_fails_and_closes(transport) -> None: responses, connections = transport responses.append(upstream(b"123", Content_Length="5")) resource = opds.open_resource(request()) + with pytest.raises(opds.OpdsRelayError, match="incomplete"): list(resource.chunks()) @@ -197,6 +212,7 @@ def test_truncated_stream_fails_and_closes(transport) -> None: def test_enforces_concurrency_and_releases_unstarted_streams(transport) -> None: responses, _ = transport resources = [] + for _ in range(8): responses.append(upstream()) resources.append(opds.open_resource(request())) @@ -214,8 +230,10 @@ def test_enforces_concurrency_and_releases_unstarted_streams(transport) -> None: def test_failed_requests_release_slots_and_sanitize_errors(transport) -> None: responses, _ = transport + for _ in range(10): responses.append(OSError("secret network details")) + with pytest.raises(opds.OpdsRelayError, match="could not connect") as error: opds.open_resource(request()) @@ -225,17 +243,20 @@ def test_failed_requests_release_slots_and_sanitize_errors(transport) -> None: def test_redirect_limit_and_https_downgrade(transport) -> None: responses, connections = transport responses.extend(upstream(status=302, Location="/loop") for _ in range(6)) + with pytest.raises(opds.OpdsRelayError, match="too many"): opds.open_resource(request()) assert all(connection.close.called for connection in connections) responses.append(upstream(status=302, Location="http://books.example/file")) + with pytest.raises(opds.OpdsRelayError, match="insecure"): opds.open_resource(request()) def test_configured_host_allowlist_is_enforced(transport, monkeypatch) -> None: monkeypatch.setattr(get_settings(), "opds_relay_allowed_hosts", ["other.example"]) + with pytest.raises(opds.OpdsRelayError, match="not enabled"): opds.open_resource(request()) @@ -248,9 +269,13 @@ def test_connects_to_checked_ip_with_original_tls_hostname(monkeypatch) -> None: connection = opds._PinnedConnection(urlsplit("https://books.example/feed"), "93.184.216.34") connection.connect() create_connection.assert_called_once_with(("93.184.216.34", 443), timeout=30) + context.wrap_socket.assert_called_once_with( - create_connection.return_value, server_hostname="books.example", do_handshake_on_connect=False + create_connection.return_value, + server_hostname="books.example", + do_handshake_on_connect=False, ) + context.wrap_socket.return_value.do_handshake.assert_called_once() @@ -269,19 +294,24 @@ def test_slow_headers_are_interrupted_by_open_timeout(monkeypatch, status_first: def trickle() -> None: with upstream_socket, suppress(OSError): status = b"HTTP/1.1 200 OK\r\n" + if status_first: upstream_socket.sendall(status) data = (b"" if status_first else status) + b"Content-Type: application/epub+zip\r\n\r\nbook" + for char in data: upstream_socket.send(bytes([char])) + if stop.wait(0.02): break worker = threading.Thread(target=trickle, daemon=True) worker.start() + try: started = time.monotonic() + with pytest.raises(opds.OpdsRelayError) as error: opds.open_resource(request(url="http://books.example/feed")) @@ -302,8 +332,10 @@ def resolve(*args, **kwargs): monkeypatch.setattr(socket, "getaddrinfo", resolve) monkeypatch.setattr(opds, "_TIMEOUT", 0.05) + try: started = time.monotonic() + with pytest.raises(opds.OpdsRelayError) as error: opds.open_resource(request()) @@ -336,16 +368,20 @@ def test_chunk_framing_trickle_is_interrupted_by_total_deadline(monkeypatch) -> def trickle() -> None: with upstream_socket, suppress(OSError): upstream_socket.sendall(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + for char in b"000000000000000000000004\r\nbook\r\n0\r\n\r\n": upstream_socket.send(bytes([char])) + if stop.wait(0.02): break worker = threading.Thread(target=trickle, daemon=True) worker.start() + try: started = time.monotonic() resource = opds.open_resource(request(url="http://books.example/feed")) + with pytest.raises((http.client.HTTPException, OSError, opds.OpdsRelayError)): list(resource.chunks()) diff --git a/tests/services/test_powersync_demo.py b/tests/services/test_powersync_demo.py index 9fdcf40..8bf3ed8 100644 --- a/tests/services/test_powersync_demo.py +++ b/tests/services/test_powersync_demo.py @@ -21,6 +21,7 @@ async def _create_user(session: AsyncSession, email: str) -> User: primary_email_verified=True, last_login_at=datetime.now(UTC), ) + session.add(user) await session.flush() return user @@ -63,7 +64,6 @@ async def test_apply_upload_batch_creates_updates_and_deletes_demo_items( ) assert updated == 1 - items = await powersync_demo_service.list_demo_items(session, user.user_id) assert len(items) == 1 assert items[0].title == "Updated" @@ -72,7 +72,13 @@ async def test_apply_upload_batch_creates_updates_and_deletes_demo_items( deleted = await powersync_demo_service.apply_upload_batch( session, user.user_id, - [PowerSyncUploadMutation(type="demo_items", op="DELETE", id=item_id)], + [ + PowerSyncUploadMutation( + type="demo_items", + op="DELETE", + id=item_id, + ) + ], ) assert deleted == 1 @@ -86,6 +92,7 @@ async def test_apply_upload_batch_rejects_other_users_demo_items( async with test_session_maker() as session: owner = await _create_user(session, "owner@example.com") intruder = await _create_user(session, "intruder@example.com") + item = PowerSyncDemoItem( item_id=uuid4(), owner_user_id=owner.user_id, @@ -94,6 +101,7 @@ async def test_apply_upload_batch_rejects_other_users_demo_items( created_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + session.add(item) await session.commit() diff --git a/tests/services/test_sync.py b/tests/services/test_sync.py index ea3a086..99c82cd 100644 --- a/tests/services/test_sync.py +++ b/tests/services/test_sync.py @@ -22,6 +22,7 @@ async def _create_user(session: AsyncSession, email: str) -> User: primary_email_verified=True, last_login_at=datetime.now(UTC), ) + session.add(user) await session.flush() return user @@ -36,6 +37,7 @@ async def _create_book(session: AsyncSession, user: User, title: str = "Existing added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + session.add(book) await session.flush() return book @@ -48,6 +50,7 @@ async def test_apply_powersync_upload_batch_handles_book_mutations( async with test_session_maker() as session: user = await _create_user(session, "sync@example.com") book_id = str(uuid4()) + applied_count = await sync_service.apply_powersync_upload_batch( session, user.user_id, diff --git a/tests/services/test_tracking.py b/tests/services/test_tracking.py index b2a3b7b..fef6d8a 100644 --- a/tests/services/test_tracking.py +++ b/tests/services/test_tracking.py @@ -17,7 +17,10 @@ def mutation(table, payload): return PowerSyncCrudMutation( - table=table, op="PUT", id=str(payload.id), op_data={"payload": payload.model_dump(mode="json")} + table=table, + op="PUT", + id=str(payload.id), + op_data={"payload": payload.model_dump(mode="json")}, ) @@ -25,16 +28,28 @@ async def setup(session): owner = await _create_user(session, f"{uuid4()}@example.com") book = await _create_book(session, owner) now = datetime.now(UTC) - timedelta(minutes=30) + goal = GoalDefinition( id=uuid4(), goal_type="reading_time", target_value=30, time_period="daily", - start_date=now.replace(hour=0, minute=0, second=0, microsecond=0), - end_date=(now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0), + start_date=now.replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ), + end_date=(now + timedelta(days=1)).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ), created_at=now, rules=[GoalRule(at=now, target=30)], ) + activity = Activity( id=uuid4(), book_id=book.book_id, @@ -43,6 +58,7 @@ async def setup(session): end_time=now + timedelta(minutes=10), created_at=now + timedelta(minutes=10), ) + await session.commit() return owner.user_id, book.book_id, goal, activity @@ -62,7 +78,10 @@ async def test_tracking_retries_are_idempotent_and_mutation_is_atomic(test_sessi owner, [ PowerSyncCrudMutation( - table="books", op="PATCH", id=str(book_id), op_data={"title": "Should roll back"} + table="books", + op="PATCH", + id=str(book_id), + op_data={"title": "Should roll back"}, ), mutation("reading_activities", changed), ], @@ -85,8 +104,11 @@ async def test_tracking_ownership_applies_to_payload_references(test_session_mak await apply_powersync_upload_batch(session, intruder_id, [mutation("reading_activities", activity)]) assert await session.get(SyncReadingActivity, activity.id) is None + await apply_powersync_upload_batch( - session, owner, [mutation("reading_goals", goal), mutation("reading_activities", activity)] + session, + owner, + [mutation("reading_goals", goal), mutation("reading_activities", activity)], ) with pytest.raises(ForbiddenError): @@ -97,12 +119,22 @@ async def test_tracking_ownership_applies_to_payload_references(test_session_mak async def test_period_snapshot_retries_are_allowed_but_rule_changes_are_rejected(test_session_maker, operation): async with test_session_maker() as session: owner, _, goal, _ = await setup(session) - period = PeriodRecord(id=uuid4(), goal_id=goal.id, definition=goal.model_copy(update={"is_recurring": False})) + + period = PeriodRecord( + id=uuid4(), + goal_id=goal.id, + definition=goal.model_copy(update={"is_recurring": False}), + ) + await apply_powersync_upload_batch( - session, owner, [mutation("reading_goals", goal), mutation("goal_periods", period)] + session, + owner, + [mutation("reading_goals", goal), mutation("goal_periods", period)], ) + retry = mutation("goal_periods", period).model_copy(update={"op": operation}) await apply_powersync_upload_batch(session, owner, [retry]) + changed = period.model_copy( update={ "definition": period.definition.model_copy( @@ -110,6 +142,7 @@ async def test_period_snapshot_retries_are_allowed_but_rule_changes_are_rejected ) } ) + rewrite = mutation("goal_periods", changed).model_copy(update={"op": operation}) with pytest.raises(ValidationError, match="immutable"): @@ -123,18 +156,21 @@ async def test_goal_rule_branches_merge_without_rewriting_existing_history(test_ async with test_session_maker() as session: owner, _, goal, _ = await setup(session) await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", goal)]) + first = goal.model_copy( update={ "target_value": 20, "rules": [*goal.rules, GoalRule(at=goal.created_at + timedelta(minutes=5), target=20)], } ) + second = goal.model_copy( update={ "target_value": 40, "rules": [*goal.rules, GoalRule(at=goal.created_at + timedelta(minutes=10), target=40)], } ) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", first)]) await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", second)]) row = await session.get(SyncReadingGoal, goal.id) @@ -150,17 +186,30 @@ async def test_goal_rule_branches_merge_without_rewriting_existing_history(test_ async def test_book_goal_deletion_preserves_activity_and_periods(test_session_maker): async with test_session_maker() as session: owner, book_id, goal, activity = await setup(session) + await apply_powersync_upload_batch( - session, owner, [mutation("reading_goals", goal), mutation("reading_activities", activity)] + session, + owner, + [mutation("reading_goals", goal), mutation("reading_activities", activity)], ) + await apply_powersync_upload_batch( session, owner, [ - PowerSyncCrudMutation(table="books", op="DELETE", id=str(book_id)), - PowerSyncCrudMutation(table="reading_goals", op="DELETE", id=str(goal.id)), + PowerSyncCrudMutation( + table="books", + op="DELETE", + id=str(book_id), + ), + PowerSyncCrudMutation( + table="reading_goals", + op="DELETE", + id=str(goal.id), + ), ], ) + assert await session.get(SyncReadingActivity, activity.id) is not None periods = (await session.execute(select(SyncGoalPeriod))).scalars().all() assert len(periods) == 1 @@ -169,6 +218,7 @@ async def test_book_goal_deletion_preserves_activity_and_periods(test_session_ma await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", goal)]) assert await session.get(SyncReadingGoal, goal.id) is None at = datetime.now(UTC) + reverse = Activity( id=uuid4(), book_id=book_id, @@ -179,6 +229,7 @@ async def test_book_goal_deletion_preserves_activity_and_periods(test_session_ma kind="reversal", correction_of=activity.id, ) + await apply_powersync_upload_batch(session, owner, [mutation("reading_activities", reverse)]) assert await session.get(SyncReadingActivity, reverse.id) is not None @@ -205,14 +256,19 @@ async def test_selected_book_goals_validate_every_owner_and_keep_selection_immut await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", changed)]) history = PeriodRecord( - id=uuid4(), goal_id=selected.id, definition=selected.model_copy(update={"is_recurring": False}) + id=uuid4(), + goal_id=selected.id, + definition=selected.model_copy(update={"is_recurring": False}), ) + await apply_powersync_upload_batch(session, owner, [mutation("goal_periods", history)]) + assert set( GoalDefinition.model_validate( (await session.get(SyncGoalPeriod, history.id)).payload["definition"] ).book_ids ) == {book_id, other_id} + foreign_history = history.model_copy( update={ "id": uuid4(), @@ -229,6 +285,7 @@ async def test_selected_book_goals_validate_every_owner_and_keep_selection_immut await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", invalid)]) assert await session.get(SyncReadingGoal, invalid.id) is None + assert set(GoalDefinition.model_validate((await session.get(SyncReadingGoal, goal.id)).payload).book_ids) == { book_id, other_id, @@ -238,6 +295,7 @@ async def test_selected_book_goals_validate_every_owner_and_keep_selection_immut async def test_completion_target_is_bounded_by_distinct_selected_books(test_session_maker): async with test_session_maker() as session: owner, book_id, goal, _ = await setup(session) + invalid = goal.model_copy( update={ "scope": "book", diff --git a/tests/services/test_users.py b/tests/services/test_users.py index e6bf310..210e008 100644 --- a/tests/services/test_users.py +++ b/tests/services/test_users.py @@ -26,6 +26,7 @@ async def _seed_user_with_session( primary_email_verified=True, last_login_at=datetime.now(UTC), ) + session.add(user) await session.flush() session.add(PasswordCredential(user_id=user.user_id, password_hash=hash_password(password))) @@ -38,6 +39,7 @@ async def _seed_user_with_session( expires_at=datetime.now(UTC) + timedelta(days=30), last_seen_at=datetime.now(UTC), ) + session.add(auth_session) await session.commit() await session.refresh(user) @@ -51,7 +53,14 @@ async def test_change_user_password_revokes_sessions( """Test password changes revoke active sessions.""" async with test_session_maker() as session: user, _ = await _seed_user_with_session(session) - await user_service.change_user_password(session, user.user_id, "SecureP@ss123", "NewSecureP@ss123") + + await user_service.change_user_password( + session, + user.user_id, + "SecureP@ss123", + "NewSecureP@ss123", + ) + session_result = await session.execute(select(AuthSession).where(AuthSession.user_id == user.user_id)) assert all(auth_session.revoked_at is not None for auth_session in session_result.scalars()) @@ -64,7 +73,12 @@ async def test_change_user_password_rejects_invalid_current_password( user, _ = await _seed_user_with_session(session) with pytest.raises(UnauthorizedError): - await user_service.change_user_password(session, user.user_id, "WrongPassword123", "NewSecureP@ss123") + await user_service.change_user_password( + session, + user.user_id, + "WrongPassword123", + "NewSecureP@ss123", + ) async def test_delete_user_account_disables_user_and_revokes_sessions( diff --git a/tests/test_acquisition_migration.py b/tests/test_acquisition_migration.py index ed60de7..706316e 100644 --- a/tests/test_acquisition_migration.py +++ b/tests/test_acquisition_migration.py @@ -14,8 +14,8 @@ def test_managed_acquisition_has_a_follow_up_migration() -> None: ] assert len(revisions) == 1 - migration = revisions[0].read_text(encoding="utf-8") + for column in ( "download_root", "book_id", diff --git a/tests/test_bookmark_migration.py b/tests/test_bookmark_migration.py index 4832c4f..7558047 100644 --- a/tests/test_bookmark_migration.py +++ b/tests/test_bookmark_migration.py @@ -24,12 +24,16 @@ def migrate(session, fn): await db_session.run_sync(lambda session: migrate(session, revision.downgrade)) assert not await db_session.run_sync(lambda session: inspect(session.connection()).has_table("bookmarks")) await db_session.run_sync(lambda session: migrate(session, revision.upgrade)) + differences = await db_session.run_sync( lambda session: compare_metadata(MigrationContext.configure(session.connection()), Base.metadata) ) + assert differences == [] + constraints = await db_session.run_sync( lambda session: inspect(session.connection()).get_check_constraints("bookmarks") ) + assert {constraint["name"] for constraint in constraints} == {"ck_bookmarks_position_range"} await db_session.commit() diff --git a/tests/test_env_files.py b/tests/test_env_files.py index fcf6214..1e77ac0 100644 --- a/tests/test_env_files.py +++ b/tests/test_env_files.py @@ -34,13 +34,11 @@ def test_local_env_keys_match_example_when_env_exists() -> None: example_keys = _env_keys(REPO_ROOT / ".env.example") env_keys = _env_keys(env_path) - assert env_keys == example_keys def test_managed_acquisition_settings_have_safe_defaults() -> None: fields = Settings.model_fields - assert fields["acquisition_import_root"].default is None assert fields["acquisition_monitor_active_interval_seconds"].default == 2 assert fields["acquisition_monitor_idle_interval_seconds"].default == 10 diff --git a/tests/test_library_migration.py b/tests/test_library_migration.py index 62c1917..f319283 100644 --- a/tests/test_library_migration.py +++ b/tests/test_library_migration.py @@ -24,13 +24,16 @@ def migrate(session, fn): await db_session.run_sync(lambda session: migrate(session, revision.downgrade)) envelope = '{"publication_date":"2020-01-02T03:04:05Z","file_size":42,"is_physical":true,"series_id":"descriptor","custom_metadata":{"keep":"value"}}' + await db_session.execute( text( 'INSERT INTO books (book_id, owner_user_id, title, custom_metadata) VALUES (:id, :owner, \'Legacy\', CAST(:metadata AS jsonb)), (:bad_id, :owner, \'Invalid\', \'{"publication_date":"invalid","file_size":"nope"}\'::jsonb)' ), {"id": uuid4(), "bad_id": uuid4(), "owner": auth_user["user_id"], "metadata": envelope}, ) + await db_session.run_sync(lambda session: migrate(session, revision.upgrade)) + row = ( await db_session.execute( text( @@ -38,13 +41,18 @@ def migrate(session, fn): ) ) ).one() + assert row == (42, True, "descriptor", {"keep": "value"}) + invalid = ( await db_session.execute(text("SELECT publication_date, file_size FROM books WHERE title = 'Invalid'")) ).one() + assert invalid == (None, None) + differences = await db_session.run_sync( lambda session: compare_metadata(MigrationContext.configure(session.connection()), Base.metadata) ) + assert differences == [] await db_session.commit() diff --git a/tests/test_models.py b/tests/test_models.py index ada8282..69f4e2d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -21,7 +21,6 @@ def test_managed_acquisition_models_expose_download_lifecycle() -> None: endpoint_table = AcquisitionEndpoint.__table__ job_table = AcquisitionJob.__table__ - assert "download_root" in endpoint_table.columns assert endpoint_table.c.download_root.nullable is True @@ -45,19 +44,23 @@ def test_managed_acquisition_models_expose_download_lifecycle() -> None: "completed_at", "cancelled_at", }.issubset(job_table.columns.keys()) + assert job_table.c.download_url.nullable is True + assert {index.name for index in job_table.indexes}.issuperset( { "ix_acquisition_jobs_owner_status", "ix_acquisition_jobs_next_poll_at", } ) + assert any(foreign_key.target_fullname == "books.book_id" for foreign_key in job_table.c.book_id.foreign_keys) def test_media_asset_kind_is_unique_per_book() -> None: """Ensure concurrent replacements cannot leave duplicate book media.""" table = MediaAsset.__table__ + unique_columns = { tuple(constraint.columns.keys()) for constraint in table.constraints if isinstance(constraint, UniqueConstraint) } @@ -94,6 +97,7 @@ def test_auth_models_are_registered_with_metadata() -> None: "last_login_at", "disabled_at", } + assert set(powersync_demo_items_table.columns.keys()) == { "item_id", "owner_user_id", @@ -102,7 +106,10 @@ def test_auth_models_are_registered_with_metadata() -> None: "created_at", "updated_at", } + assert {"book_id", "owner_user_id", "title", "updated_at"}.issubset(books_table.columns.keys()) + for table_name in ("shelves", "tags", "notes", "annotations", "book_shelves", "book_tags"): assert "owner_user_id" in Base.metadata.tables[table_name].columns + assert "reading_sessions" not in Base.metadata.tables diff --git a/tests/test_openapi_export.py b/tests/test_openapi_export.py index fdf41e7..9556dcc 100644 --- a/tests/test_openapi_export.py +++ b/tests/test_openapi_export.py @@ -12,6 +12,7 @@ def test_export_ignores_environment_and_dev_routes(tmp_path: Path) -> None: command = [sys.executable, "scripts/export_openapi.py", str(output)] subprocess.run(command, check=True) baseline = output.read_text() + environment = { **os.environ, "DEBUG": "true", @@ -19,6 +20,7 @@ def test_export_ignores_environment_and_dev_routes(tmp_path: Path) -> None: "PUBLIC_BASE_URL": "https://private-environment.example", "SECRET_KEY": "private-environment-secret", } + subprocess.run(command, env=environment, check=True) assert output.read_text() == baseline assert "private-environment" not in baseline @@ -26,5 +28,4 @@ def test_export_ignores_environment_and_dev_routes(tmp_path: Path) -> None: assert all(not path.startswith("/__dev") for path in schema["paths"]) assert {"/v1/books", "/v1/auth/login", "/v1/opds/relay", "/v1/sync/powersync-upload"} <= schema["paths"].keys() assert schema["info"]["contact"]["url"] == "https://github.com/PapyrusReader/papyrus" - subprocess.run([*command, "--check"], check=True) diff --git a/tests/test_powersync_sync_config.py b/tests/test_powersync_sync_config.py index 08624a5..0e7750c 100644 --- a/tests/test_powersync_sync_config.py +++ b/tests/test_powersync_sync_config.py @@ -4,7 +4,6 @@ def test_books_stream_downloads_media_references() -> None: config = (Path(__file__).parents[1] / "powersync" / "sync-config.yaml").read_text() books_select = config.split("streams:\n books:", 1)[1].split("FROM books", 1)[0] - assert "file_media_id" in books_select assert "cover_media_id" in books_select @@ -13,11 +12,13 @@ def test_library_streams_filter_owners_and_setup_publishes_tables() -> None: root = Path(__file__).parents[1] config = (root / "powersync/sync-config.yaml").read_text() setup = (root / "scripts/setup_local_powersync.sh").read_text() + for table in ("books", "shelves", "tags", "notes", "annotations", "bookmarks", "book_shelves", "book_tags"): stream = config.split(f" {table}:\n", 1)[1].split("\n\n", 1)[0] assert "auto_subscribe: true" in stream assert "WHERE owner_user_id::text = auth.user_id()" in stream assert f"public.{table}" in setup + assert " demo_items:" in config assert "sync_tombstones" not in config assert "file_path" not in config diff --git a/tests/test_tracking_migration.py b/tests/test_tracking_migration.py index fa2b3d7..9292562 100644 --- a/tests/test_tracking_migration.py +++ b/tests/test_tracking_migration.py @@ -24,6 +24,7 @@ def migrate(session, fn): fn() await db_session.run_sync(lambda session: migrate(session, revision.downgrade)) + owner = User( user_id=uuid4(), display_name="Reader", @@ -31,8 +32,10 @@ def migrate(session, fn): primary_email_verified=True, last_login_at=datetime.now(UTC), ) + db_session.add(owner) await db_session.flush() + book = SyncBook( book_id=uuid4(), owner_user_id=owner.user_id, @@ -41,15 +44,20 @@ def migrate(session, fn): added_at=datetime.now(UTC), updated_at=datetime.now(UTC), ) + identifier = book.book_id db_session.add(book) await db_session.flush() await db_session.run_sync(lambda session: migrate(session, revision.upgrade)) + differences = await db_session.run_sync( lambda session: compare_metadata(MigrationContext.configure(session.connection()), Base.metadata) ) + assert differences == [] assert (await db_session.get(SyncBook, identifier)).title == "Preserved" + for table in ("reading_goals", "reading_activities", "goal_periods"): assert await db_session.run_sync(lambda session, name=table: inspect(session.connection()).has_table(name)) + await db_session.commit()