From ee4471435e6f3d62a8cdfff1351fa2a5b0dcc4c3 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Tue, 6 Oct 2026 14:28:43 +0300 Subject: [PATCH 01/11] chore: introduce development integration workflow --- .github/workflows/ci.yml | 2 +- .github/workflows/release-checks.yml | 2 +- deploy/README.md | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51c97dc..e877020 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master] + branches: [development, master] pull_request: jobs: diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index 5db3a52..a8f968d 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -4,7 +4,7 @@ on: pull_request: paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**'] push: - branches: [master] + branches: [development, master] paths: ['tools/**', '.github/workflows/**', 'pyproject.toml', 'deploy/**'] permissions: diff --git a/deploy/README.md b/deploy/README.md index 891ffd1..a486c62 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,6 +10,16 @@ metadata comes from the installed Python package. GitHub releases record the image digest; deploy a recorded digest instead of a mutable tag when stronger artifact pinning is needed. The runtime uses UID/GID 10001, not root. +## Branch workflow + +Feature/fix PRs target the default `development` branch and leave versions +unchanged. CI checks integration work without publishing a container. When ready, +prepare the coordinated version bump on `development`, then promote it to +`master` with a release PR using **Create a merge commit**. The existing version +gate publishes only from `master`. Bring `master` back into `development` after +the release, and deploy additive server changes before a client that needs them. +See the workspace `RELEASING.md` for coordination and merge order. + ## Host and domains Use a VM with Docker Engine/Compose v2 and Python 3.12+, enough disk for uploaded From dd1de157695e62445ea4ca9f96ea34a9ee8902b8 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Tue, 6 Oct 2026 16:32:52 +0300 Subject: [PATCH 02/11] feat: persist owned goals and reading activity with derived progress --- ...d7e8f901_add_reading_activity_and_goals.py | 34 ++ deploy/bootstrap-powersync.sql | 4 +- docs-tracking.md | 37 ++ papyrus/api/routes/goals.py | 146 +---- papyrus/api/routes/progress.py | 140 +---- papyrus/models/__init__.py | 4 + papyrus/models/tracking.py | 27 + papyrus/schemas/goal.py | 14 + papyrus/schemas/progress.py | 6 +- papyrus/schemas/sync.py | 20 +- papyrus/schemas/tracking.py | 151 +++++ papyrus/services/goal_history.py | 55 ++ papyrus/services/goal_progress.py | 167 ++++++ papyrus/services/goals.py | 163 +++++ papyrus/services/library_sync.py | 22 + papyrus/services/progress.py | 275 +++++++++ papyrus/services/tracking_validation.py | 132 ++++ powersync/sync-config.yaml | 24 + scripts/setup_local_powersync.sh | 6 +- tests/api/routes/test_goals.py | 80 ++- tests/api/routes/test_progress.py | 49 ++ tests/api/routes/test_sync.py | 1 + tests/fixtures/tracking_projections.json | 562 ++++++++++++++++++ tests/services/test_goal_progress.py | 22 + tests/services/test_tracking.py | 156 +++++ tests/test_tracking_migration.py | 55 ++ 26 files changed, 2070 insertions(+), 282 deletions(-) create mode 100644 alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py create mode 100644 docs-tracking.md create mode 100644 papyrus/models/tracking.py create mode 100644 papyrus/schemas/tracking.py create mode 100644 papyrus/services/goal_history.py create mode 100644 papyrus/services/goal_progress.py create mode 100644 papyrus/services/goals.py create mode 100644 papyrus/services/progress.py create mode 100644 papyrus/services/tracking_validation.py create mode 100644 tests/fixtures/tracking_projections.json create mode 100644 tests/services/test_goal_progress.py create mode 100644 tests/services/test_tracking.py create mode 100644 tests/test_tracking_migration.py diff --git a/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py b/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py new file mode 100644 index 0000000..3c1e0a5 --- /dev/null +++ b/alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py @@ -0,0 +1,34 @@ +"""Add owned, durable goals and append-only activity without changing book data.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "b5c6d7e8f901" +down_revision: str | Sequence[str] | None = "af0fea8d6317" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + for table in ("reading_goals", "reading_activities", "goal_periods"): + op.create_table( + table, + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("payload", postgresql.JSONB(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["owner_user_id"], ["users.user_id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(f"ix_{table}_owner_user_id", table, ["owner_user_id"]) + + +def downgrade() -> None: + for table in ("goal_periods", "reading_activities", "reading_goals"): + op.drop_index(f"ix_{table}_owner_user_id", table_name=table) + op.drop_table(table) diff --git a/deploy/bootstrap-powersync.sql b/deploy/bootstrap-powersync.sql index 9a86f74..64bdcc9 100644 --- a/deploy/bootstrap-powersync.sql +++ b/deploy/bootstrap-powersync.sql @@ -5,8 +5,8 @@ WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'powersync_role') \gexec ALTER ROLE powersync_role WITH REPLICATION BYPASSRLS LOGIN PASSWORD :'source_password'; GRANT USAGE ON SCHEMA public TO powersync_role; -GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO powersync_role; +GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items TO powersync_role; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO powersync_role; SELECT 'CREATE PUBLICATION powersync' WHERE NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') \gexec -ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; +ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; diff --git a/docs-tracking.md b/docs-tracking.md new file mode 100644 index 0000000..e16a0ef --- /dev/null +++ b/docs-tracking.md @@ -0,0 +1,37 @@ +# Reading tracking contract and rollout + +Tracking schema version 1 is advertised additively by `GET /v1/sync/settings`. +Goals/session/statistics REST endpoints now return owned persisted records and +ledger-derived progress. Existing metric strings remain valid; `reading_days` is +additive. Goal definition, activity, and period payloads are validated in the same +transaction/owner lock used by ordinary PowerSync uploads. + +`reading_goals`, `reading_activities`, and `goal_periods` store owner-scoped JSONB +payloads with UUID identities. Activity is immutable: correction/undo appends a +reversal referencing an owned original. Retries are idempotent. Shelf membership +and book-title snapshots survive book deletion. Activity/period deletion through +sync is rejected; deleting a goal retains rule/period history. Account deletion +still removes owned data. + +Progress unions overlapping time intervals and per-book/day content coverage, +counts distinct confirmed books, and applies creation cutoffs, pauses, captured +IANA timezone, Monday weeks, and historical rule revisions. Period history holds +rules rather than mutable counters, allowing delayed corrections to reproject it. +Shared fixtures in `tests/fixtures/tracking_projections.json` match Dart tests. + +## Deployment order + +1. Apply Alembic revision `b5c6d7e8f901` (`uv run alembic upgrade head`). This is + additive and preserves existing library tables/data. +2. Add the three tracking tables to the PostgreSQL PowerSync publication and grants. + Updated `deploy/bootstrap-powersync.sql` and `scripts/setup_local_powersync.sh` + include them. Existing deployments must run the updated bootstrap; creating + database tables alone does not update an existing publication. +3. Deploy the updated PowerSync stream configuration and server capability. +4. Release the integrated client after verifying the capability and transport. + +Older servers are supported by client local staging; unsupported tracking never +blocks ordinary library uploads. Versions remain unchanged in these development +PRs. Run server tests only against a separate test database; the fixtures recreate +tables. The tracking migration test verifies existing books survive and Alembic +metadata matches the upgraded schema. diff --git a/papyrus/api/routes/goals.py b/papyrus/api/routes/goals.py index d0c1170..eb4e814 100644 --- a/papyrus/api/routes/goals.py +++ b/papyrus/api/routes/goals.py @@ -1,139 +1,41 @@ -"""Goal routes.""" +"""Goal routes backed by owned definitions and real activity.""" -from datetime import UTC, date, datetime -from uuid import UUID, uuid4 +from typing import Annotated +from uuid import UUID -from fastapi import APIRouter, Response, status +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId -from papyrus.schemas.goal import ( - CreateGoalRequest, - Goal, - GoalList, - GoalType, - TimePeriod, - UpdateGoalRequest, -) +from papyrus.core.database import get_db +from papyrus.schemas.goal import CreateGoalRequest, Goal, GoalList, UpdateGoalRequest +from papyrus.services import goals as service router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] -def _example_goal(goal_id: UUID | None = None) -> Goal: - """Create an example goal for responses.""" - today = date.today() +@router.get("", response_model=GoalList, summary="List all goals") +async def list_goals(user_id: CurrentUserId, db: DBSession, is_active: bool | None = None) -> GoalList: + return GoalList(goals=await service.list_goals(db, user_id, is_active)) - return Goal( - goal_id=goal_id or uuid4(), - title="Read 12 books this year", - description="My annual reading goal", - goal_type=GoalType.BOOKS_COUNT, - target_value=12, - current_value=5, - progress_percentage=41.67, - time_period=TimePeriod.YEARLY, - start_date=today.replace(month=1, day=1), - end_date=today.replace(month=12, day=31), - is_active=True, - is_completed=False, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) +@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) -@router.get( - "", - response_model=GoalList, - summary="List all goals", -) -async def list_goals( - user_id: CurrentUserId, - is_active: bool | None = None, -) -> GoalList: - """Return all goals for the user.""" - return GoalList(goals=[_example_goal()]) +@router.get("/{goal_id}", response_model=Goal, summary="Get goal details") +async def get_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Goal: + return await service.get_goal(db, user_id, goal_id) -@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, -) -> Goal: - """Create a new reading goal.""" - return Goal( - goal_id=uuid4(), - title=request.title, - description=request.description, - goal_type=request.goal_type, - target_value=request.target_value, - current_value=0, - progress_percentage=0.0, - time_period=request.time_period, - start_date=request.start_date, - end_date=request.end_date, - is_active=True, - is_completed=False, - created_at=datetime.now(UTC), - updated_at=datetime.now(UTC), - ) +@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) -@router.get( - "/{goal_id}", - response_model=Goal, - summary="Get goal details", -) -async def get_goal( - user_id: CurrentUserId, - goal_id: UUID, -) -> Goal: - """Return detailed information about a goal.""" - return _example_goal(goal_id) - -@router.patch( - "/{goal_id}", - response_model=Goal, - summary="Update goal", -) -async def update_goal( - user_id: CurrentUserId, - goal_id: UUID, - request: UpdateGoalRequest, -) -> Goal: - """Update goal properties.""" - goal = _example_goal(goal_id) - - if request.title is not None: - goal.title = request.title - - if request.description is not None: - goal.description = request.description - - if request.target_value is not None: - goal.target_value = request.target_value - - if request.end_date is not None: - goal.end_date = request.end_date - - if request.is_active is not None: - goal.is_active = request.is_active - - return goal - - -@router.delete( - "/{goal_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete goal", -) -async def delete_goal( - user_id: CurrentUserId, - goal_id: UUID, -) -> Response: - """Delete a goal.""" +@router.delete("/{goal_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete goal") +async def delete_goal(user_id: CurrentUserId, goal_id: UUID, db: DBSession) -> Response: + await service.delete_goal(db, user_id, goal_id) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/papyrus/api/routes/progress.py b/papyrus/api/routes/progress.py index a23154e..114789f 100644 --- a/papyrus/api/routes/progress.py +++ b/papyrus/api/routes/progress.py @@ -1,146 +1,42 @@ -"""Reading progress and statistics routes.""" +"""Owned activity-backed reading progress and statistics routes.""" -from datetime import UTC, date, datetime +from datetime import date from typing import Annotated -from uuid import UUID, uuid4 +from uuid import UUID -from fastapi import APIRouter, Query +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession from papyrus.api.deps import CurrentUserId, Pagination -from papyrus.schemas.common import Pagination as PaginationSchema -from papyrus.schemas.progress import ( - BookBreakdown, - CreateReadingSessionRequest, - DailyBreakdown, - ReadingSession, - ReadingSessionList, - ReadingStatistics, - StatisticsPeriod, - StatisticsTotals, -) +from papyrus.core.database import get_db +from papyrus.schemas.progress import CreateReadingSessionRequest, ReadingSession, ReadingSessionList, ReadingStatistics +from papyrus.services import progress as service router = APIRouter() +DBSession = Annotated[AsyncSession, Depends(get_db)] -def _example_session(session_id: UUID | None = None, book_id: UUID | None = None) -> ReadingSession: - """Create an example reading session for responses.""" - return ReadingSession( - session_id=session_id or uuid4(), - book_id=book_id or uuid4(), - book_title="Example Book", - start_time=datetime.now(UTC), - end_time=datetime.now(UTC), - start_position=0.25, - end_position=0.35, - pages_read=20, - duration_minutes=30, - device_type="tablet", - device_name="iPad Pro", - created_at=datetime.now(UTC), - ) - - -@router.get( - "/sessions", - response_model=ReadingSessionList, - summary="List reading sessions", -) +@router.get("/sessions", response_model=ReadingSessionList, summary="List reading sessions") async def list_reading_sessions( user_id: CurrentUserId, pagination: Pagination, + db: DBSession, book_id: UUID | None = None, start_date: date | None = None, end_date: date | None = None, ) -> ReadingSessionList: - """Return paginated list of reading sessions.""" - return ReadingSessionList( - sessions=[_example_session()], - pagination=PaginationSchema( - page=pagination.page, - limit=pagination.limit, - total=1, - total_pages=1, - has_next=False, - has_prev=False, - ), - ) + 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", -) +@router.post("/sessions", response_model=ReadingSession, summary="Record reading session") async def create_reading_session( - user_id: CurrentUserId, - request: CreateReadingSessionRequest, + user_id: CurrentUserId, request: CreateReadingSessionRequest, db: DBSession ) -> ReadingSession: - """Record a new reading session.""" - duration = None - - if request.end_time and request.start_time: - duration = int((request.end_time - request.start_time).total_seconds() / 60) + return await service.create_session(db, user_id, request) - return ReadingSession( - session_id=uuid4(), - book_id=request.book_id, - start_time=request.start_time, - end_time=request.end_time, - start_position=request.start_position, - end_position=request.end_position, - pages_read=request.pages_read, - duration_minutes=duration, - device_type=request.device_type, - device_name=request.device_name, - created_at=datetime.now(UTC), - ) - -@router.get( - "/statistics", - response_model=ReadingStatistics, - summary="Get reading statistics", -) +@router.get("/statistics", response_model=ReadingStatistics, summary="Get reading statistics") async def get_reading_statistics( - user_id: CurrentUserId, - start_date: Annotated[date | None, Query(description="Start date for statistics period")] = None, - end_date: Annotated[date | None, Query(description="End date for statistics period")] = None, + user_id: CurrentUserId, db: DBSession, start_date: date | None = None, end_date: date | None = None ) -> ReadingStatistics: - """Return reading statistics for the specified period.""" - today = date.today() - period_start = start_date or today.replace(day=1) - period_end = end_date or today - - return ReadingStatistics( - period=StatisticsPeriod( - start_date=period_start, - end_date=period_end, - ), - totals=StatisticsTotals( - reading_time_minutes=450, - pages_read=150, - books_completed=2, - sessions_count=15, - average_session_minutes=30.0, - reading_days=10, - current_streak=5, - longest_streak=7, - ), - daily_breakdown=[ - DailyBreakdown( - date=today, - reading_time_minutes=45, - pages_read=15, - sessions_count=2, - ) - ], - books_breakdown=[ - BookBreakdown( - book_id=uuid4(), - title="Example Book", - reading_time_minutes=200, - pages_read=80, - sessions_count=7, - ) - ], - ) + return await service.statistics(db, user_id, start_date, end_date) diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py index 843fe4f..1d8c4d2 100644 --- a/papyrus/models/__init__.py +++ b/papyrus/models/__init__.py @@ -14,9 +14,13 @@ from papyrus.models.media import MediaAsset from papyrus.models.powersync_demo import PowerSyncDemoItem from papyrus.models.sync import SyncBook +from papyrus.models.tracking import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal from papyrus.models.user import User __all__ = [ + "SyncReadingGoal", + "SyncReadingActivity", + "SyncGoalPeriod", "SyncShelf", "SyncTag", "SyncNote", diff --git a/papyrus/models/tracking.py b/papyrus/models/tracking.py new file mode 100644 index 0000000..70e6390 --- /dev/null +++ b/papyrus/models/tracking.py @@ -0,0 +1,27 @@ +"""Owned tracking records survive library entity deletion.""" + +from uuid import UUID, uuid4 + +from sqlalchemy import Uuid +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base +from papyrus.models.library import LibraryEntity + + +class TrackingRecord(LibraryEntity): + id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4) + payload: Mapped[dict[str, object]] = mapped_column(JSONB) + + +class SyncReadingGoal(TrackingRecord, Base): + __tablename__ = "reading_goals" + + +class SyncReadingActivity(TrackingRecord, Base): + __tablename__ = "reading_activities" + + +class SyncGoalPeriod(TrackingRecord, Base): + __tablename__ = "goal_periods" diff --git a/papyrus/schemas/goal.py b/papyrus/schemas/goal.py index 5ae7e25..59d0a31 100644 --- a/papyrus/schemas/goal.py +++ b/papyrus/schemas/goal.py @@ -2,6 +2,7 @@ from datetime import date, datetime from enum import StrEnum +from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field @@ -13,6 +14,7 @@ class GoalType(StrEnum): BOOKS_COUNT = "books_count" PAGES_COUNT = "pages_count" READING_TIME = "reading_time" + READING_DAYS = "reading_days" class TimePeriod(StrEnum): @@ -40,6 +42,12 @@ class Goal(BaseModel): time_period: TimePeriod start_date: date end_date: date + is_recurring: bool = True + is_archived: bool = False + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + minimum_minutes: int = 5 is_active: bool = True is_completed: bool = False completed_at: datetime | None = None @@ -56,6 +64,11 @@ class GoalList(BaseModel): class CreateGoalRequest(BaseModel): """Goal creation request.""" + is_recurring: bool = True + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + minimum_minutes: int = Field(default=5, ge=1, le=1440) title: str = Field(..., max_length=255) description: str | None = None goal_type: GoalType @@ -73,3 +86,4 @@ class UpdateGoalRequest(BaseModel): target_value: int | None = Field(None, ge=1) end_date: date | None = None is_active: bool | None = None + is_archived: bool | None = None diff --git a/papyrus/schemas/progress.py b/papyrus/schemas/progress.py index d6e7651..18499c2 100644 --- a/papyrus/schemas/progress.py +++ b/papyrus/schemas/progress.py @@ -35,14 +35,16 @@ class ReadingSessionList(BaseModel): class CreateReadingSessionRequest(BaseModel): - """Reading session creation request.""" + """Reading session creation request; optional id supports retry-safe uploads.""" + + session_id: UUID | None = None book_id: UUID start_time: datetime end_time: datetime | None = None start_position: float | None = Field(None, ge=0, le=1) end_position: float | None = Field(None, ge=0, le=1) - pages_read: int | None = None + pages_read: int | None = Field(default=None, ge=0) device_type: str | None = None device_name: str | None = None diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 1f098b6..2adf30a 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -53,6 +53,9 @@ ) ENTITY_FIELDS = frozenset({"owner_user_id", "created_at", "updated_at"}) UPLOAD_FIELDS = { + "reading_goals": ENTITY_FIELDS | {"payload"}, + "reading_activities": ENTITY_FIELDS | {"payload"}, + "goal_periods": ENTITY_FIELDS | {"payload"}, "books": BOOK_UPLOAD_FIELDS, "shelves": ENTITY_FIELDS | { @@ -82,9 +85,19 @@ class PowerSyncCrudMutation(BaseModel): model_config = ConfigDict(populate_by_name=True) - table: Literal["books", "shelves", "tags", "notes", "annotations", "bookmarks", "book_shelves", "book_tags"] = ( - Field(alias="type") - ) + table: Literal[ + "books", + "shelves", + "tags", + "notes", + "annotations", + "bookmarks", + "book_shelves", + "book_tags", + "reading_goals", + "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") @@ -123,5 +136,6 @@ class FileStorageSettings(BaseModel): class DataSyncSettingsResponse(BaseModel): """Public sync settings used by clients for custom server discovery.""" + tracking_schema_version: int = 1 data_sync_url: str file_storage: FileStorageSettings diff --git a/papyrus/schemas/tracking.py b/papyrus/schemas/tracking.py new file mode 100644 index 0000000..ffe8318 --- /dev/null +++ b/papyrus/schemas/tracking.py @@ -0,0 +1,151 @@ +"""Version-one tracking payloads shared by REST and PowerSync.""" + +from typing import Literal +from uuid import UUID +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator + +from papyrus.schemas.goal import GoalType, TimePeriod + + +class TrackingPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + id: UUID + + +class GoalRule(BaseModel): + model_config = ConfigDict(extra="forbid") + at: AwareDatetime + target: int = Field(ge=1) + title: str | None = Field(default=None, max_length=255) + active: bool = True + archived: bool = False + + +class GoalDefinition(TrackingPayload): + title: str | None = Field(default=None, max_length=255) + description: str | None = None + goal_type: GoalType + target_value: int = Field(ge=1) + time_period: TimePeriod + start_date: AwareDatetime + end_date: AwareDatetime + created_at: AwareDatetime + timezone: str = "UTC" + scope: Literal["library", "book", "shelf"] = "library" + scope_id: UUID | None = None + 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 + is_archived: bool = False + + @field_validator("timezone") + @classmethod + def valid_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError("timezone must be an IANA identifier") from exc + + return value + + @model_validator(mode="after") + def valid_rules(self) -> "GoalDefinition": + if self.end_date <= self.start_date: + raise ValueError("end_date must follow start_date") + + if (self.scope == "library") != (self.scope_id is None): + raise ValueError("Only book and shelf scopes require scope_id") + + if self.time_period == TimePeriod.CUSTOM and self.is_recurring: + raise ValueError("Custom deadlines cannot recur") + + dates = [rule.at for rule in self.rules] + + if dates != sorted(set(dates)): + raise ValueError("Rule revisions must have unique ascending timestamps") + + if dates and dates[0] != self.created_at: + raise ValueError("First rule must begin at goal creation") + + if self.rules: + last = self.rules[-1] + + if (last.target, last.title, last.active, last.archived) != ( + self.target_value, + self.title, + self.is_active, + self.is_archived, + ): + raise ValueError("Current settings must match the last rule") + + return self + + +class PageCoverage(BaseModel): + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) + key: str = Field(min_length=1, max_length=500) + start: float = Field(ge=0, le=1) + end: float = Field(ge=0, le=1) + pages_per_unit: float = Field(gt=0, le=100000) + estimated: bool = False + + @model_validator(mode="after") + def ordered(self) -> "PageCoverage": + if self.end <= self.start: + raise ValueError("Coverage must have positive extent") + + return self + + +class Activity(TrackingPayload): + session_id: UUID | None = None + book_id: UUID + book_title: str = Field(min_length=1, max_length=500) + start_time: AwareDatetime + end_time: AwareDatetime + 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) + shelf_ids: list[UUID] = Field(default_factory=list, max_length=1000) + 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 + + @model_validator(mode="after") + def valid_activity(self) -> "Activity": + if self.end_time < self.start_time: + raise ValueError("end_time must not precede start_time") + + if self.end_time > self.created_at: + raise ValueError("Reading activity cannot be in the future") + + if (self.kind == "reversal") != (self.correction_of is not None): + raise ValueError("Only reversals require correction_of") + + if self.kind != "reading" and (self.pages or self.coverage or self.end_time != self.start_time): + raise ValueError("Completion/reversal records are points without pages") + + if self.source == "manual" and self.coverage: + raise ValueError("Manual pages must use pages, not reader coverage") + + if self.source == "reader" and self.pages: + raise ValueError("Reader pages require document coverage") + + return self + + +class PeriodRecord(TrackingPayload): + goal_id: UUID + definition: GoalDefinition + + @model_validator(mode="after") + def valid_snapshot(self) -> "PeriodRecord": + if self.goal_id != self.definition.id or self.definition.is_recurring: + raise ValueError("Period snapshots must identify their non-recurring definition") + + return self diff --git a/papyrus/services/goal_history.py b/papyrus/services/goal_history.py new file mode 100644 index 0000000..df9d8f3 --- /dev/null +++ b/papyrus/services/goal_history.py @@ -0,0 +1,55 @@ +"""Retain period rules when a goal definition is deleted.""" + +from datetime import UTC, datetime, timedelta +from uuid import NAMESPACE_URL, UUID, uuid5 + +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.models import SyncGoalPeriod, SyncReadingGoal +from papyrus.schemas.tracking import GoalDefinition, GoalRule, PeriodRecord +from papyrus.services.goal_progress import calendar_period, rule_at + + +async def preserve_goal_history(session: AsyncSession, user_id: UUID, row: SyncReadingGoal) -> None: + goal = GoalDefinition.model_validate(row.payload) + now = datetime.now(UTC) + boundary = min(now, goal.rules[-1].at) if goal.is_archived and goal.rules else now + start, end = goal.start_date, goal.end_date + + while start < boundary: + stop = min(end, boundary) + at = stop - timedelta(microseconds=1) + rule = rule_at(goal, at) + 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) + + if existing is None: + revisions = [revision for revision in goal.rules if revision.at < stop] + archived = end > boundary + + if archived: + revisions.append(GoalRule(at=stop, target=rule.target, title=rule.title, active=False, archived=True)) + + definition = goal.model_copy( + update={ + "start_date": start, + "end_date": stop, + "target_value": rule.target, + "title": rule.title, + "is_recurring": False, + "is_active": False if archived else rule.active, + "is_archived": archived or rule.archived, + "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"))) + await session.flush() + + if not goal.is_recurring or end >= boundary: + break + + start, end = calendar_period(goal, end) diff --git a/papyrus/services/goal_progress.py b/papyrus/services/goal_progress.py new file mode 100644 index 0000000..04256b1 --- /dev/null +++ b/papyrus/services/goal_progress.py @@ -0,0 +1,167 @@ +"""Deterministic goal projections over owned activity; no shared counters.""" + +from collections import defaultdict +from datetime import UTC, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule + + +def calendar_period(goal: GoalDefinition, at: datetime) -> tuple[datetime, datetime]: + if goal.is_archived and goal.rules: + at = min(at, goal.rules[-1].at) + + local = at.astimezone(ZoneInfo(goal.timezone)) + day = local.replace(hour=0, minute=0, second=0, microsecond=0) + period = goal.time_period + + if period == "daily": + start, end = day, day + timedelta(days=1) + elif period == "weekly": + start = day - timedelta(days=day.weekday()) + end = start + timedelta(days=7) + elif period == "monthly": + start = day.replace(day=1) + end = start.replace(year=start.year + (start.month == 12), month=start.month % 12 + 1) + elif period == "yearly": + start = day.replace(month=1, day=1) + end = start.replace(year=start.year + 1) + else: + return goal.start_date, goal.end_date + + return start.astimezone(UTC), end.astimezone(UTC) + + +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 + ) + + for revision in goal.rules: + if revision.at > at: + break + + rule = revision + + return rule + + +def union_length(ranges: list[tuple[float, float]]) -> float: + if not ranges: + return 0 + + ranges = sorted(ranges) + start, end = ranges[0] + total = 0.0 + + for left, right in ranges[1:]: + if left <= end: + end = max(end, right) + else: + total += end - start + start, end = left, right + + return total + end - start + + +def effective_activities(ledger: list[Activity]) -> list[Activity]: + unique = {activity.id: activity for activity in ledger} + reversed_ids = {activity.correction_of for activity in unique.values() if activity.kind == "reversal"} + return [activity for activity in unique.values() if activity.kind != "reversal" and activity.id not in reversed_ids] + + +def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> dict[str, Any]: + 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) + times: dict[datetime, list[tuple[float, float]]] = defaultdict(list) + coverage: dict[tuple[object, ...], list[tuple[float, float]]] = defaultdict(list) + scales: dict[tuple[object, ...], float] = {} + finished = set() + manual_pages = 0 + estimated = False + counted = [] + + for activity in sorted(effective_activities(ledger), key=lambda activity: (activity.end_time, str(activity.id))): + if goal.scope == "book" and activity.book_id != goal.scope_id: + continue + + if goal.scope == "shelf" and goal.scope_id not in activity.shelf_ids: + 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: + if activity.kind == "completion": + finished.add(activity.book_id) + contributed = True + elif activity.kind == "reading": + manual_pages += activity.pages + contributed = bool(activity.pages or activity.coverage) + day = point.astimezone(zone).date() + + for extent in activity.coverage: + key = (day, activity.book_id, extent.key) + coverage[key].append((extent.start, extent.end)) + scales.setdefault(key, extent.pages_per_unit) + estimated |= extent.estimated + + if activity.kind == "reading": + cursor = max(activity.start_time, cutoff) + stop_at = min(activity.end_time, limit) + + while cursor < stop_at: + local = cursor.astimezone(zone) + 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) + + if active.active and not active.archived: + times[midnight.astimezone(UTC)].append((cursor.timestamp(), stop.timestamp())) + contributed = True + + cursor = stop + + if contributed: + counted.append(str(activity.id)) + + daily_seconds = {day: union_length(intervals) for day, intervals in times.items()} + qualified = {day for day, seconds in daily_seconds.items() if seconds >= goal.minimum_minutes * 60} + 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 + ) + + return { + "current_value": value, + "progress_percentage": min(100, fractional / rule.target * 100), + "target_value": rule.target, + "title": rule.title, + "start_date": start, + "end_date": end, + "seconds": seconds, + "pages": pages, + "finished_books": len(finished), + "days": len(qualified), + "estimated": estimated, + "activity_ids": counted, + } diff --git a/papyrus/services/goals.py b/papyrus/services/goals.py new file mode 100644 index 0000000..b6e2e48 --- /dev/null +++ b/papyrus/services/goals.py @@ -0,0 +1,163 @@ +"""Owned goal definitions and derived progress exposed through REST.""" + +from datetime import UTC, datetime, time, timedelta +from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import ValidationError as PayloadError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.core.exceptions import NotFoundError, ValidationError +from papyrus.models import SyncReadingActivity, SyncReadingGoal +from papyrus.schemas.goal import CreateGoalRequest, Goal, UpdateGoalRequest +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule +from papyrus.services.goal_progress import calendar_period, project_goal +from papyrus.services.library_sync import owned_row +from papyrus.services.sync import apply_powersync_upload_batch + + +async def activities(session: AsyncSession, user_id: UUID) -> list[Activity]: + result = await session.execute(select(SyncReadingActivity).where(SyncReadingActivity.owner_user_id == user_id)) + return [Activity.model_validate(row.payload) for row in result.scalars()] + + +def goal_response(definition: GoalDefinition, ledger: list[Activity]) -> Goal: + now = datetime.now(UTC) + progress = project_goal(definition, ledger, now) + zone = ZoneInfo(definition.timezone) + + return Goal( + goal_id=definition.id, + title=progress["title"] or definition.title or "Reading goal", + description=definition.description, + goal_type=definition.goal_type, + target_value=progress["target_value"], + current_value=progress["current_value"], + progress_percentage=progress["progress_percentage"], + time_period=definition.time_period, + start_date=progress["start_date"].astimezone(zone).date(), + end_date=(progress["end_date"] - timedelta(microseconds=1)).astimezone(zone).date(), + is_active=definition.is_active, + is_archived=definition.is_archived, + is_recurring=definition.is_recurring, + timezone=definition.timezone, + scope=definition.scope, + scope_id=definition.scope_id, + minimum_minutes=definition.minimum_minutes, + is_completed=progress["progress_percentage"] >= 100, + created_at=definition.created_at, + ) + + +async def list_goals(session: AsyncSession, user_id: UUID, active: bool | None) -> list[Goal]: + result = await session.execute(select(SyncReadingGoal).where(SyncReadingGoal.owner_user_id == user_id)) + definitions = [GoalDefinition.model_validate(row.payload) for row in result.scalars()] + ledger = await activities(session, user_id) + return [goal_response(goal, ledger) for goal in definitions if active is None or goal.is_active == active] + + +async def get_definition(session: AsyncSession, user_id: UUID, goal_id: UUID) -> GoalDefinition: + row = await owned_row(session, user_id, "reading_goals", goal_id) + + if row is None: + raise NotFoundError("Goal was not found") + + return GoalDefinition.model_validate(row.payload) + + +async def get_goal(session: AsyncSession, user_id: UUID, goal_id: UUID) -> Goal: + return goal_response(await get_definition(session, user_id, goal_id), await activities(session, user_id)) + + +async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalRequest) -> Goal: + now = datetime.now(UTC) + + try: + zone = ZoneInfo(request.timezone) + definition = GoalDefinition( + id=uuid4(), + title=request.title, + description=request.description, + goal_type=request.goal_type, + target_value=request.target_value, + time_period=request.time_period, + start_date=datetime.combine(request.start_date, time.min, zone).astimezone(UTC), + end_date=datetime.combine(request.end_date + timedelta(days=1), time.min, zone).astimezone(UTC), + created_at=now, + timezone=request.timezone, + scope=request.scope, + scope_id=request.scope_id, + 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)], + ) + except (PayloadError, ZoneInfoNotFoundError) as exc: + raise ValidationError(str(exc)) from exc + + if definition.time_period != "custom": + definition.start_date, definition.end_date = calendar_period(definition, now) + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_goals", + op="PUT", + id=str(definition.id), + op_data={"payload": definition.model_dump(mode="json")}, + ) + ], + ) + return goal_response(definition, []) + + +async def update_goal(session: AsyncSession, user_id: UUID, goal_id: UUID, request: UpdateGoalRequest) -> Goal: + definition = await get_definition(session, user_id, goal_id) + + if request.end_date is not None: + raise ValidationError("Create a replacement goal to change its deadline") + + now = max( + datetime.now(UTC), + definition.rules[-1].at + timedelta(microseconds=1) + 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 + ) + initial = definition.rules or [ + GoalRule( + at=definition.created_at, + title=definition.title, + target=definition.target_value, + active=definition.is_active, + archived=definition.is_archived, + ) + ] + updated.rules = [*initial, rule] + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + 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))] + ) diff --git a/papyrus/services/library_sync.py b/papyrus/services/library_sync.py index ef2f5dd..a3a33cb 100644 --- a/papyrus/services/library_sync.py +++ b/papyrus/services/library_sync.py @@ -15,7 +15,10 @@ SyncBookmark, SyncBookShelf, SyncBookTag, + SyncGoalPeriod, SyncNote, + SyncReadingActivity, + SyncReadingGoal, SyncShelf, SyncTag, SyncTombstone, @@ -25,6 +28,9 @@ from papyrus.services.library_validation import convert_value, normalize_book_payload, uuid_value MODELS: dict[str, Any] = { + "reading_goals": SyncReadingGoal, + "reading_activities": SyncReadingActivity, + "goal_periods": SyncGoalPeriod, "books": SyncBook, "shelves": SyncShelf, "tags": SyncTag, @@ -35,6 +41,9 @@ "book_tags": SyncBookTag, } PRIMARY_KEYS = { + "reading_goals": "id", + "reading_activities": "id", + "goal_periods": "id", "books": "book_id", "shelves": "shelf_id", "tags": "tag_id", @@ -196,6 +205,14 @@ async def apply_library_mutation( return 0, [] if mutation.op.upper() == "DELETE": + if table in {"reading_activities", "goal_periods"}: + raise ValidationError("Retain history and append an activity correction instead") + + if table == "reading_goals" and row is not None: + from papyrus.services.goal_history import preserve_goal_history + + await preserve_goal_history(session, user_id, row) + if is_membership: await validate_references(session, user_id, table, row_id, payload, row, deleting=True) @@ -207,6 +224,11 @@ async def apply_library_mutation( 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) + 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) diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py new file mode 100644 index 0000000..300aff6 --- /dev/null +++ b/papyrus/services/progress.py @@ -0,0 +1,275 @@ +"""Real activity-backed sessions and cross-device statistics.""" + +from datetime import UTC, date, datetime, time, timedelta +from uuid import UUID, uuid4 + +from pydantic import ValidationError as PayloadError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.api.deps import PaginationParams +from papyrus.core.exceptions import NotFoundError, ValidationError +from papyrus.models import SyncBookShelf +from papyrus.schemas.common import Pagination +from papyrus.schemas.goal import GoalType, TimePeriod +from papyrus.schemas.progress import ( + BookBreakdown, + CreateReadingSessionRequest, + DailyBreakdown, + ReadingSession, + ReadingSessionList, + ReadingStatistics, + StatisticsPeriod, + StatisticsTotals, +) +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition +from papyrus.services.goal_progress import effective_activities, project_goal, union_length +from papyrus.services.goals import activities +from papyrus.services.library_sync import owned_row +from papyrus.services.sync import apply_powersync_upload_batch + + +def session_response(activity: Activity) -> ReadingSession: + return ReadingSession( + session_id=activity.id, + book_id=activity.book_id, + book_title=activity.book_title, + start_time=activity.start_time, + end_time=activity.end_time, + pages_read=activity.pages, + duration_minutes=int((activity.end_time - activity.start_time).total_seconds()) // 60, + device_type=activity.source, + device_name=activity.device_id, + created_at=activity.created_at, + ) + + +def grouped_sessions(entries: list[Activity]) -> list[ReadingSession]: + groups: dict[tuple[UUID, UUID], list[Activity]] = {} + + for activity in entries: + groups.setdefault((activity.book_id, activity.session_id or activity.id), []).append(activity) + + sessions = [] + + for group in groups.values(): + first = min(group, key=lambda entry: entry.start_time) + 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) + ) + result.pages_read = int(projection["pages"]) + sessions.append(result) + + return sorted(sessions, key=lambda entry: entry.start_time, reverse=True) + + +async def list_sessions( + session: AsyncSession, + user_id: UUID, + pagination: PaginationParams, + book_id: UUID | None, + start_date: date | None, + end_date: date | None, +) -> ReadingSessionList: + ledger = effective_activities(await activities(session, user_id)) + entries = sorted( + ( + entry + for entry in ledger + if entry.kind == "reading" + and (book_id is None or entry.book_id == book_id) + and (start_date is None or entry.end_time.date() >= start_date) + and (end_date is None or entry.start_time.date() <= end_date) + ), + key=lambda entry: entry.start_time, + reverse=True, + ) + sessions = grouped_sessions(entries) + total = len(sessions) + pages = (total + pagination.limit - 1) // pagination.limit + + return ReadingSessionList( + sessions=sessions[pagination.offset : pagination.offset + pagination.limit], + pagination=Pagination( + page=pagination.page, + limit=pagination.limit, + total=total, + total_pages=pages, + has_next=pagination.page < pages, + has_prev=pagination.page > 1, + ), + ) + + +async def create_session(session: AsyncSession, user_id: UUID, request: CreateReadingSessionRequest) -> ReadingSession: + 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) + + if existing is not None: + original = Activity.model_validate(existing.payload) + start = request.start_time.replace(tzinfo=UTC) if request.start_time.tzinfo is None else request.start_time + requested_end = request.end_time + + if requested_end is not None and requested_end.tzinfo is None: + requested_end = requested_end.replace(tzinfo=UTC) + + if ( + original.book_id != request.book_id + or original.start_time != start + or (requested_end is not None and original.end_time != requested_end) + or original.pages != (request.pages_read or 0) + or original.device_id != (request.device_name or "manual") + ): + raise ValidationError("Session id already identifies different activity") + + return session_response(original) + + result = await session.execute( + select(SyncBookShelf.shelf_id).where( + 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, + book_title=book.title, + start_time=start, + end_time=end, + created_at=datetime.now(UTC), + pages=request.pages_read or 0, + device_id=request.device_name or "manual", + shelf_ids=shelves, + ) + except PayloadError as exc: + raise ValidationError(str(exc)) from exc + + await apply_powersync_upload_batch( + session, + user_id, + [ + PowerSyncCrudMutation( + table="reading_activities", + op="PUT", + id=str(activity.id), + op_data={"payload": activity.model_dump(mode="json")}, + ) + ], + ) + return session_response(activity) + + +def totals_definition(start: datetime, end: datetime, book_id: UUID | None = None) -> GoalDefinition: + return GoalDefinition( + id=UUID(int=0), + goal_type=GoalType.READING_TIME, + target_value=1, + time_period=TimePeriod.CUSTOM, + start_date=start, + end_date=end, + created_at=start, + is_recurring=False, + scope="library" if book_id is None else "book", + scope_id=book_id, + ) + + +async def statistics( + session: AsyncSession, user_id: UUID, start_date: date | None, end_date: date | None +) -> ReadingStatistics: + today = datetime.now(UTC).date() + first, last = start_date or today.replace(day=1), end_date or today + + if last < first or (last - first).days > 3660: + raise ValidationError("Choose an ordered statistics period of at most ten years") + + start = datetime.combine(first, time.min, UTC) + end = datetime.combine(last + timedelta(days=1), time.min, UTC) + ledger = await activities(session, user_id) + 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 + + for index in range((last - first).days + 1): + 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(), + reading_time_minutes=values["seconds"] // 60, + pages_read=int(values["pages"]), + sessions_count=count, + ) + ) + + if values["seconds"] >= 300: + reading_dates.append(day.date()) + streak += 1 + longest = max(longest, streak) + else: + streak = 0 + + current = 0 + cursor = today if today in reading_dates else today - timedelta(days=1) + + while cursor in reading_dates: + current += 1 + cursor -= timedelta(days=1) + + books = {entry.book_id: entry.book_title for entry in counted} + breakdown = [] + + 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, + title=title, + reading_time_minutes=values["seconds"] // 60, + pages_read=int(values["pages"]), + sessions_count=sum(entry.book_id == book_id for entry in counted), + ) + ) + + return ReadingStatistics( + period=StatisticsPeriod(start_date=first, end_date=last), + totals=StatisticsTotals( + reading_time_minutes=total["seconds"] // 60, + pages_read=int(total["pages"]), + books_completed=total["finished_books"], + sessions_count=len({entry.session_id or entry.id for entry in counted}), + average_session_minutes=total["seconds"] / 60 / len(counted) if counted else 0, + reading_days=total["days"], + current_streak=current, + longest_streak=longest, + ), + daily_breakdown=daily, + books_breakdown=breakdown, + ) diff --git a/papyrus/services/tracking_validation.py b/papyrus/services/tracking_validation.py new file mode 100644 index 0000000..8dd9701 --- /dev/null +++ b/papyrus/services/tracking_validation.py @@ -0,0 +1,132 @@ +"""Tracking ownership, immutable activity, and rule-history validation.""" + +from datetime import UTC, datetime, timedelta +from typing import Any +from uuid import UUID + +from pydantic import ValidationError as PayloadError +from sqlalchemy.ext.asyncio import AsyncSession + +from papyrus.core.exceptions import ValidationError +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule, PeriodRecord +from papyrus.services.library_sync import owned_row, tombstoned + + +async def validate_tracking_mutation( + session: AsyncSession, + user_id: UUID, + table: str, + row_id: UUID | str, + values: dict[str, Any], + row: Any, +) -> dict[str, Any]: + schemas: dict[str, type[GoalDefinition] | type[Activity] | type[PeriodRecord]] = { + "reading_goals": GoalDefinition, + "reading_activities": Activity, + "goal_periods": PeriodRecord, + } + schema = schemas[table] + raw = values.get("payload", row.payload if row is not None else None) + + try: + parsed = schema.model_validate(raw) + except PayloadError as exc: + raise ValidationError(str(exc)) from exc + + if parsed.id != row_id: + raise ValidationError("Payload id must match row id") + + if isinstance(parsed, GoalDefinition) and not parsed.rules: + parsed.rules = [ + GoalRule( + at=parsed.created_at, + target=parsed.target_value, + title=parsed.title, + active=parsed.is_active, + archived=parsed.is_archived, + ) + ] + + payload = parsed.model_dump(mode="json") + + if table == "reading_activities" and row is not None and schema.model_validate(row.payload) != parsed: + raise ValidationError("Activity and period history are immutable; append a correction") + + if isinstance(parsed, GoalDefinition): + if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): + raise ValidationError("Goal creation cannot be in the future") + + if row is not None: + old = GoalDefinition.model_validate(row.payload) + fixed = ( + "goal_type", + "time_period", + "scope", + "scope_id", + "timezone", + "created_at", + "start_date", + "end_date", + "is_recurring", + "minimum_minutes", + ) + + if any(getattr(old, field) != getattr(parsed, field) for field in fixed): + raise ValidationError("Create a replacement to change goal metric, schedule, or scope") + + revisions = {revision.at: revision for revision in old.rules} + + for revision in parsed.rules: + if revision.at in revisions and revisions[revision.at] != revision: + raise ValidationError("Existing rule history cannot be rewritten") + + revisions[revision.at] = revision + + parsed.rules = sorted(revisions.values(), key=lambda revision: revision.at) + latest = parsed.rules[-1] + parsed.title = latest.title + parsed.target_value = latest.target + parsed.is_active = latest.active + parsed.is_archived = latest.archived + payload = parsed.model_dump(mode="json") + + if parsed.scope_id is not None: + await check_reference(session, user_id, "books" if parsed.scope == "book" else "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) + + for shelf_id in parsed.shelf_ids: + 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) + + if ( + original is None + or original.payload["kind"] == "reversal" + or str(parsed.book_id) != original.payload["book_id"] + ): + raise ValidationError("Correction must reference an owned original activity for the same book") + + if isinstance(parsed, PeriodRecord): + if row is not None: + existing = PeriodRecord.model_validate(row.payload) + + 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) + + 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) + + if parent is None and not deleted: + raise ValidationError(f"Tracking reference {table} was not found") diff --git a/powersync/sync-config.yaml b/powersync/sync-config.yaml index 7b53085..0662c2a 100644 --- a/powersync/sync-config.yaml +++ b/powersync/sync-config.yaml @@ -170,3 +170,27 @@ streams: updated_at::text AS updated_at FROM powersync_demo_items WHERE owner_user_id::text = auth.user_id() + + reading_goals: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM reading_goals + WHERE owner_user_id::text = auth.user_id() + + reading_activities: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM reading_activities + WHERE owner_user_id::text = auth.user_id() + + goal_periods: + auto_subscribe: true + query: | + SELECT id, owner_user_id::text AS owner_user_id, payload::text AS payload, + created_at::text AS created_at, updated_at::text AS updated_at + FROM goal_periods + WHERE owner_user_id::text = auth.user_id() diff --git a/scripts/setup_local_powersync.sh b/scripts/setup_local_powersync.sh index 7d120c0..e9b84cc 100755 --- a/scripts/setup_local_powersync.sh +++ b/scripts/setup_local_powersync.sh @@ -29,15 +29,15 @@ END \$\$; GRANT USAGE ON SCHEMA public TO "${POWERSYNC_SOURCE_ROLE}"; -GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items TO "${POWERSYNC_SOURCE_ROLE}"; +GRANT SELECT ON TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items TO "${POWERSYNC_SOURCE_ROLE}"; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO "${POWERSYNC_SOURCE_ROLE}"; DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'powersync') THEN - CREATE PUBLICATION powersync FOR TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; + CREATE PUBLICATION powersync FOR TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; ELSE - ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.powersync_demo_items; + ALTER PUBLICATION powersync SET TABLE public.books, public.shelves, public.tags, public.notes, public.annotations, public.bookmarks, public.book_shelves, public.book_tags, public.reading_goals, public.reading_activities, public.goal_periods, public.powersync_demo_items; END IF; END \$\$; diff --git a/tests/api/routes/test_goals.py b/tests/api/routes/test_goals.py index d5cd5a5..2976116 100644 --- a/tests/api/routes/test_goals.py +++ b/tests/api/routes/test_goals.py @@ -1,18 +1,15 @@ -"""Tests for goal endpoints.""" - -from httpx import AsyncClient +"""Owned persisted goal REST contract, including new reading-day metric.""" +from datetime import UTC, datetime +from uuid import uuid4 -async def test_list_goals(client: AsyncClient, auth_headers: dict[str, str]): - """Test listing goals.""" - response = await client.get("/v1/goals", headers=auth_headers) - assert response.status_code == 200 - data = response.json() - assert "goals" in data +import pytest +from httpx import AsyncClient -async def test_create_goal(client: AsyncClient, auth_headers: dict[str, str]): - """Test creating a goal.""" +@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, @@ -21,32 +18,59 @@ async def test_create_goal(client: AsyncClient, auth_headers: dict[str, str]): "goal_type": "books_count", "target_value": 12, "time_period": "yearly", - "start_date": "2024-01-01", - "end_date": "2024-12-31", + "start_date": f"{today.year}-01-01", + "end_date": f"{today.year}-12-31", }, ) assert response.status_code == 201 - data = response.json() - assert data["title"] == "Read 12 books this year" + return response.json()["goal_id"] -async def test_get_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test getting a goal by ID.""" +async def test_empty_goals_have_no_examples(client, auth_headers): + response = await client.get("/v1/goals", headers=auth_headers) + assert response.status_code == 200 + assert response.json()["goals"] == [] + + +async def test_get_goal(client, auth_headers, goal_id): response = await client.get(f"/v1/goals/{goal_id}", headers=auth_headers) assert response.status_code == 200 + assert response.json()["current_value"] == 0 + assert response.json()["goal_type"] == "books_count" -async def test_update_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test updating a goal.""" - response = await client.patch( - f"/v1/goals/{goal_id}", - headers=auth_headers, - json={"target_value": 15}, - ) +async def test_update_goal(client, auth_headers, goal_id): + response = await client.patch(f"/v1/goals/{goal_id}", headers=auth_headers, json={"target_value": 15}) assert response.status_code == 200 + assert response.json()["target_value"] == 15 + assert (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).json()["target_value"] == 15 -async def test_delete_goal(client: AsyncClient, auth_headers: dict[str, str], goal_id: str): - """Test deleting a goal.""" - response = await client.delete(f"/v1/goals/{goal_id}", headers=auth_headers) - assert response.status_code == 204 +async def test_delete_goal(client, auth_headers, goal_id): + assert (await client.delete(f"/v1/goals/{goal_id}", headers=auth_headers)).status_code == 204 + assert (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).status_code == 404 + + +async def test_unknown_goal_is_not_an_example(client, auth_headers): + assert (await client.get(f"/v1/goals/{uuid4()}", headers=auth_headers)).status_code == 404 + + +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, + json={ + "title": "Read on five days", + "goal_type": "reading_days", + "target_value": 5, + "time_period": "weekly", + "start_date": today, + "end_date": "2027-12-31", + "timezone": "Europe/Vilnius", + "minimum_minutes": 5, + }, + ) + assert response.status_code == 201 + assert response.json()["goal_type"] == "reading_days" + assert response.json()["timezone"] == "Europe/Vilnius" diff --git a/tests/api/routes/test_progress.py b/tests/api/routes/test_progress.py index fbfa1a9..4bb6678 100644 --- a/tests/api/routes/test_progress.py +++ b/tests/api/routes/test_progress.py @@ -1,10 +1,28 @@ """Tests for reading progress and statistics endpoints.""" from datetime import UTC, datetime +from uuid import uuid4 +import pytest from httpx import AsyncClient +@pytest.fixture +async def book_id(client, auth_headers): + identifier = str(uuid4()) + result = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + {"type": "books", "op": "PUT", "id": identifier, "data": {"title": "Reading book", "author": "Author"}} + ] + }, + ) + assert result.status_code == 200 + return identifier + + async def test_list_reading_sessions(client: AsyncClient, auth_headers: dict[str, str]): """Test listing reading sessions.""" response = await client.get("/v1/progress/sessions", headers=auth_headers) @@ -55,3 +73,34 @@ async def test_get_reading_statistics_with_date_range(client: AsyncClient, auth_ }, ) assert response.status_code == 200 + + +async def test_manual_session_retry_and_real_statistics(client, auth_headers, book_id): + from datetime import timedelta + + end = datetime.now(UTC) + payload = { + "session_id": str(uuid4()), + "book_id": book_id, + "start_time": (end - timedelta(minutes=20)).isoformat(), + "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 + assert first.json()["session_id"] == second.json()["session_id"] + sessions = (await client.get("/v1/progress/sessions", headers=auth_headers)).json() + assert sessions["pagination"]["total"] == 1 + stats = (await client.get("/v1/progress/statistics", headers=auth_headers)).json()["totals"] + assert stats["reading_time_minutes"] == 20 + assert stats["pages_read"] == 15 + + +async def test_missing_book_is_rejected(client, auth_headers): + response = await client.post( + "/v1/progress/sessions", + headers=auth_headers, + json={"book_id": str(uuid4()), "start_time": datetime.now(UTC).isoformat(), "pages_read": 2}, + ) + assert response.status_code == 404 diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index 57abac0..bc8b6c0 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -26,6 +26,7 @@ async def test_sync_settings_are_public_and_hide_implementation_details(client: assert response.status_code == 200 assert response.json() == { + "tracking_schema_version": 1, "data_sync_url": "https://sync.papyrus.test", "file_storage": { "supported": True, diff --git a/tests/fixtures/tracking_projections.json b/tests/fixtures/tracking_projections.json new file mode 100644 index 0000000..36d8a8d --- /dev/null +++ b/tests/fixtures/tracking_projections.json @@ -0,0 +1,562 @@ +[ + { + "name": "creation cutoff and overlapping devices", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 20 + } + }, + { + "name": "pause excludes intervals without extending period", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [ + { + "at": "2026-10-05T10:00:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + }, + { + "at": "2026-10-05T10:05:00Z", + "target": 30, + "title": null, + "active": false, + "archived": false + }, + { + "at": "2026-10-05T10:15:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + } + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 10 + } + }, + { + "name": "coverage union uses stable calibration", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000d", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:10:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0.25, + "end": 0.75, + "pages_per_unit": 40, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 15, + "finished_books": 0, + "days": 1, + "current_value": 15 + } + }, + { + "name": "distinct books and completion undo", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 1, + "days": 0, + "current_value": 1 + } + }, + { + "name": "undo last completion", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + }, + { + "id": "00000000-0000-0000-0000-000000000011", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000f" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 0, + "days": 0, + "current_value": 0 + } + }, + { + "name": "midnight checkpoint credits exposed prior-day PDF page", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000012", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T23:59:50Z", + "end_time": "2026-10-06T00:00:00Z", + "created_at": "2026-10-06T00:00:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "pdf:9", + "start": 0, + "end": 1, + "pages_per_unit": 1, + "estimated": false + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 10, + "pages": 1, + "finished_books": 0, + "days": 0, + "current_value": 1 + } + }, + { + "name": "spring DST day has twenty-three hours", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "daily", + "start_date": "2026-03-28T22:00:00Z", + "end_date": "2026-03-29T21:00:00Z", + "created_at": "2026-03-28T22:00:00Z", + "timezone": "Europe/Vilnius", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": true, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000013", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-03-28T22:00:00Z", + "end_time": "2026-03-29T21:00:00Z", + "created_at": "2026-03-29T21:00:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-03-29T20:59:59.999999Z", + "expected": { + "seconds": 82799, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1379 + } + }, + { + "name": "captured shelf scope and reading-day threshold", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_days", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "shelf", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000003" + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000004" + ], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1 + } + } +] diff --git a/tests/services/test_goal_progress.py b/tests/services/test_goal_progress.py new file mode 100644 index 0000000..ab51048 --- /dev/null +++ b/tests/services/test_goal_progress.py @@ -0,0 +1,22 @@ +"""Projection fixtures shared byte-for-byte with the Dart client.""" + +import json +from datetime import datetime +from pathlib import Path + +import pytest + +from papyrus.schemas.tracking import Activity, GoalDefinition +from papyrus.services.goal_progress import project_goal + +FIXTURES = json.loads((Path(__file__).parents[1] / "fixtures/tracking_projections.json").read_text()) + + +@pytest.mark.parametrize("fixture", FIXTURES, ids=lambda value: value["name"]) +def test_matching_client_projection(fixture): + goal = GoalDefinition.model_validate(fixture["goal"]) + ledger = [Activity.model_validate(value) for value in fixture["activities"]] + result = project_goal(goal, ledger, datetime.fromisoformat(fixture["now"])) + + for key, value in fixture["expected"].items(): + assert result[key] == pytest.approx(value) diff --git a/tests/services/test_tracking.py b/tests/services/test_tracking.py new file mode 100644 index 0000000..4cc2c1f --- /dev/null +++ b/tests/services/test_tracking.py @@ -0,0 +1,156 @@ +"""Owned immutable activity, concurrent rules, and retained deletion history.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import pytest +from sqlalchemy import select + +from papyrus.core.exceptions import ForbiddenError, ValidationError +from papyrus.models import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal +from papyrus.schemas.sync import PowerSyncCrudMutation +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule +from papyrus.services.sync import apply_powersync_upload_batch +from tests.services.test_sync import _create_book, _create_user + + +def mutation(table, payload): + return PowerSyncCrudMutation( + table=table, op="PUT", id=str(payload.id), op_data={"payload": payload.model_dump(mode="json")} + ) + + +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), + created_at=now, + rules=[GoalRule(at=now, target=30)], + ) + activity = Activity( + id=uuid4(), + book_id=book.book_id, + book_title=book.title, + start_time=now, + end_time=now + timedelta(minutes=10), + created_at=now + timedelta(minutes=10), + ) + await session.commit() + return owner.user_id, book.book_id, goal, activity + + +async def test_tracking_retries_are_idempotent_and_mutation_is_atomic(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, activity = await setup(session) + batch = [mutation("reading_goals", goal), mutation("reading_activities", activity)] + await apply_powersync_upload_batch(session, owner, batch) + await apply_powersync_upload_batch(session, owner, batch) + assert len((await session.execute(select(SyncReadingActivity))).scalars().all()) == 1 + changed = activity.model_copy(update={"pages": 99}) + + with pytest.raises(ValidationError): + await apply_powersync_upload_batch( + session, + owner, + [ + PowerSyncCrudMutation( + table="books", op="PATCH", id=str(book_id), op_data={"title": "Should roll back"} + ), + mutation("reading_activities", changed), + ], + ) + + assert (await session.get(SyncReadingActivity, activity.id)).payload["pages"] == 0 + from papyrus.models import SyncBook + + assert (await session.get(SyncBook, book_id)).title == activity.book_title + + +async def test_tracking_ownership_applies_to_payload_references(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, activity = await setup(session) + intruder = await _create_user(session, f"{uuid4()}@example.com") + intruder_id = intruder.user_id + await session.commit() + + with pytest.raises(ForbiddenError): + 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)] + ) + + with pytest.raises(ForbiddenError): + await apply_powersync_upload_batch(session, intruder_id, [mutation("reading_goals", goal)]) + + +async def test_goal_rule_branches_merge_without_rewriting_existing_history(test_session_maker): + 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) + result = GoalDefinition.model_validate(row.payload) + assert [rule.target for rule in result.rules] == [30, 20, 40] + assert result.target_value == 40 + rewrite = goal.model_copy(update={"target_value": 10, "rules": [GoalRule(at=goal.created_at, target=10)]}) + + with pytest.raises(ValidationError): + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", rewrite)]) + + +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)] + ) + 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)), + ], + ) + assert await session.get(SyncReadingActivity, activity.id) is not None + periods = (await session.execute(select(SyncGoalPeriod))).scalars().all() + assert len(periods) == 1 + assert periods[0].payload["definition"]["is_archived"] + assert await session.get(SyncReadingGoal, goal.id) is None + 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, + book_title=activity.book_title, + start_time=at, + end_time=at, + created_at=at, + 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 diff --git a/tests/test_tracking_migration.py b/tests/test_tracking_migration.py new file mode 100644 index 0000000..fa2b3d7 --- /dev/null +++ b/tests/test_tracking_migration.py @@ -0,0 +1,55 @@ +"""Tracking tables are additive and preserve an existing owned library.""" + +import importlib.util +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import inspect + +from papyrus.models import Base, SyncBook, User + + +async def test_tracking_upgrade_preserves_library_and_matches_metadata(db_session): + path = Path(__file__).parents[1] / "alembic/versions/b5c6d7e8f901_add_reading_activity_and_goals.py" + spec = importlib.util.spec_from_file_location("tracking_revision", path) + revision = importlib.util.module_from_spec(spec) + spec.loader.exec_module(revision) + + def migrate(session, fn): + with Operations.context(MigrationContext.configure(session.connection())): + fn() + + await db_session.run_sync(lambda session: migrate(session, revision.downgrade)) + owner = User( + user_id=uuid4(), + display_name="Reader", + primary_email="migration@example.com", + 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="Preserved", + author="Author", + 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() From 5d7f2bb09053e462062e84a74296b92f829db45b Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 8 Oct 2026 02:18:44 +0300 Subject: [PATCH 03/11] feat: support multiple-book goals with versioned tracking --- docs-tracking.md | 17 +- papyrus/schemas/goal.py | 2 + papyrus/schemas/sync.py | 2 +- papyrus/schemas/tracking.py | 21 +- papyrus/services/goal_progress.py | 2 +- papyrus/services/goals.py | 5 +- papyrus/services/tracking_validation.py | 18 +- tests/api/routes/test_goals.py | 50 +++ tests/api/routes/test_sync.py | 2 +- tests/fixtures/tracking_projections.json | 404 +++++++++++++++++++++++ tests/services/test_tracking.py | 80 ++++- 11 files changed, 594 insertions(+), 9 deletions(-) diff --git a/docs-tracking.md b/docs-tracking.md index e16a0ef..c499e13 100644 --- a/docs-tracking.md +++ b/docs-tracking.md @@ -1,6 +1,6 @@ # Reading tracking contract and rollout -Tracking schema version 1 is advertised additively by `GET /v1/sync/settings`. +Tracking schema version 2 is advertised additively by `GET /v1/sync/settings`. Goals/session/statistics REST endpoints now return owned persisted records and ledger-derived progress. Existing metric strings remain valid; `reading_days` is additive. Goal definition, activity, and period payloads are validated in the same @@ -35,3 +35,18 @@ blocks ordinary library uploads. Versions remain unchanged in these development PRs. Run server tests only against a separate test database; the fixtures recreate tables. The tracking migration test verifies existing books survive and Alembic metadata matches the upgraded schema. + +## Selected books (schema v2) + +A goal with `scope: "book"` may include `book_ids`, a canonical list of selected +book UUIDs. `scope_id` remains a selected book for legacy payload compatibility. +Omitting `book_ids` retains the original single-book meaning. Every selected book +is ownership-checked; changing the selection requires a replacement goal. +Completion targets cannot exceed the number of distinct selected books. + +The client omits the additive field for single-book goals and stages multiple-book +goals and their period snapshots separately on v1 servers. Ordinary tracking and +library uploads continue. Staged records promote when v2 is discovered. Deploy +the v2 server before releasing this client; use the updated client on all devices +to calculate multiple-book progress. Existing JSONB and SQLite payload columns +carry the selection; no additional database migration or stream changes are needed. diff --git a/papyrus/schemas/goal.py b/papyrus/schemas/goal.py index 59d0a31..3a06507 100644 --- a/papyrus/schemas/goal.py +++ b/papyrus/schemas/goal.py @@ -47,6 +47,7 @@ class Goal(BaseModel): timezone: str = "UTC" 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 = 5 is_active: bool = True is_completed: bool = False @@ -68,6 +69,7 @@ class CreateGoalRequest(BaseModel): timezone: str = "UTC" 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) title: str = Field(..., max_length=255) description: str | None = None diff --git a/papyrus/schemas/sync.py b/papyrus/schemas/sync.py index 2adf30a..3b417d0 100644 --- a/papyrus/schemas/sync.py +++ b/papyrus/schemas/sync.py @@ -136,6 +136,6 @@ class FileStorageSettings(BaseModel): class DataSyncSettingsResponse(BaseModel): """Public sync settings used by clients for custom server discovery.""" - tracking_schema_version: int = 1 + tracking_schema_version: int = 2 data_sync_url: str file_storage: FileStorageSettings diff --git a/papyrus/schemas/tracking.py b/papyrus/schemas/tracking.py index ffe8318..fa1fc8b 100644 --- a/papyrus/schemas/tracking.py +++ b/papyrus/schemas/tracking.py @@ -1,4 +1,4 @@ -"""Version-one tracking payloads shared by REST and PowerSync.""" +"""Tracking payloads shared by REST and PowerSync.""" from typing import Literal from uuid import UUID @@ -35,12 +35,22 @@ class GoalDefinition(TrackingPayload): timezone: str = "UTC" 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) rules: list[GoalRule] = Field(default_factory=list, max_length=10000) is_active: bool = True is_recurring: bool = True is_archived: bool = False + @field_validator("book_ids") + @classmethod + def unique_books(cls, value: list[UUID]) -> list[UUID]: + return sorted(set(value), key=str) + + @property + def selected_book_ids(self) -> list[UUID]: + return self.book_ids or ([self.scope_id] if self.scope == "book" and self.scope_id else []) + @field_validator("timezone") @classmethod def valid_timezone(cls, value: str) -> str: @@ -59,6 +69,15 @@ def valid_rules(self) -> "GoalDefinition": if (self.scope == "library") != (self.scope_id is None): raise ValueError("Only book and shelf scopes require scope_id") + if self.book_ids and (self.scope != "book" or self.scope_id not in self.book_ids): + raise ValueError("book_ids require a book scope containing scope_id") + + if self.book_ids and self.goal_type == GoalType.BOOKS_COUNT and self.target_value > len(self.book_ids): + raise ValueError("Target cannot exceed the number of selected books") + + if len(self.book_ids) == 1: + self.book_ids = [] + if self.time_period == TimePeriod.CUSTOM and self.is_recurring: raise ValueError("Custom deadlines cannot recur") diff --git a/papyrus/services/goal_progress.py b/papyrus/services/goal_progress.py index 04256b1..f0fd589 100644 --- a/papyrus/services/goal_progress.py +++ b/papyrus/services/goal_progress.py @@ -87,7 +87,7 @@ def project_goal(goal: GoalDefinition, ledger: list[Activity], now: datetime) -> counted = [] for activity in sorted(effective_activities(ledger), key=lambda activity: (activity.end_time, str(activity.id))): - if goal.scope == "book" and activity.book_id != goal.scope_id: + if goal.scope == "book" and activity.book_id not in goal.selected_book_ids: continue if goal.scope == "shelf" and goal.scope_id not in activity.shelf_ids: diff --git a/papyrus/services/goals.py b/papyrus/services/goals.py index b6e2e48..665bb7e 100644 --- a/papyrus/services/goals.py +++ b/papyrus/services/goals.py @@ -45,6 +45,7 @@ def goal_response(definition: GoalDefinition, ledger: list[Activity]) -> Goal: timezone=definition.timezone, scope=definition.scope, scope_id=definition.scope_id, + book_ids=definition.book_ids, minimum_minutes=definition.minimum_minutes, is_completed=progress["progress_percentage"] >= 100, created_at=definition.created_at, @@ -88,7 +89,9 @@ async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalR created_at=now, timezone=request.timezone, scope=request.scope, - scope_id=request.scope_id, + scope_id=request.scope_id + or (min(request.book_ids, key=str) if request.scope == "book" and request.book_ids else None), + 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)], diff --git a/papyrus/services/tracking_validation.py b/papyrus/services/tracking_validation.py index 8dd9701..f94b83f 100644 --- a/papyrus/services/tracking_validation.py +++ b/papyrus/services/tracking_validation.py @@ -56,6 +56,12 @@ async def validate_tracking_mutation( if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): raise ValidationError("Goal creation cannot be in the future") + if parsed.scope == "book" and parsed.goal_type.value == "books_count": + old_target = row.payload["target_value"] if row is not None else None + + if parsed.target_value > len(parsed.selected_book_ids) and parsed.target_value != old_target: + raise ValidationError("Target cannot exceed the number of selected books") + if row is not None: old = GoalDefinition.model_validate(row.payload) fixed = ( @@ -63,6 +69,7 @@ async def validate_tracking_mutation( "time_period", "scope", "scope_id", + "book_ids", "timezone", "created_at", "start_date", @@ -90,8 +97,12 @@ async def validate_tracking_mutation( parsed.is_archived = latest.archived payload = parsed.model_dump(mode="json") - if parsed.scope_id is not None: - await check_reference(session, user_id, "books" if parsed.scope == "book" else "shelves", parsed.scope_id) + if parsed.scope == "book": + for book_id in parsed.selected_book_ids: + 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) if isinstance(parsed, Activity): if parsed.created_at > datetime.now(UTC) + timedelta(minutes=5): @@ -121,6 +132,9 @@ async def validate_tracking_mutation( 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) + return {**values, "payload": payload} diff --git a/tests/api/routes/test_goals.py b/tests/api/routes/test_goals.py index 2976116..4ec7cbb 100644 --- a/tests/api/routes/test_goals.py +++ b/tests/api/routes/test_goals.py @@ -74,3 +74,53 @@ async def test_create_reading_days_goal(client, auth_headers): assert response.status_code == 201 assert response.json()["goal_type"] == "reading_days" assert response.json()["timezone"] == "Europe/Vilnius" + + +async def test_create_selected_books_goal_round_trips_and_bounds_target(client, auth_headers): + books = [] + + for title in ["First selected book", "Second selected book"]: + book_id = str(uuid4()) + response = await client.post( + "/v1/sync/powersync-upload", + headers=auth_headers, + json={ + "batch": [ + {"type": "books", "op": "PUT", "id": book_id, "data": {"title": title, "author": "Test Author"}} + ] + }, + ) + 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", + "target_value": 2, + "time_period": "daily", + "start_date": today, + "end_date": today, + "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() + assert set(goal["book_ids"]) == set(books) + assert goal["scope_id"] in books + result = await client.get(f"/v1/goals/{goal['goal_id']}", headers=auth_headers) + 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} + ) + 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"} + ) + assert saved.status_code == 200 + assert saved.json()["scope_id"] == books[0] diff --git a/tests/api/routes/test_sync.py b/tests/api/routes/test_sync.py index bc8b6c0..8aee3ed 100644 --- a/tests/api/routes/test_sync.py +++ b/tests/api/routes/test_sync.py @@ -26,7 +26,7 @@ async def test_sync_settings_are_public_and_hide_implementation_details(client: assert response.status_code == 200 assert response.json() == { - "tracking_schema_version": 1, + "tracking_schema_version": 2, "data_sync_url": "https://sync.papyrus.test", "file_storage": { "supported": True, diff --git a/tests/fixtures/tracking_projections.json b/tests/fixtures/tracking_projections.json index 36d8a8d..7080622 100644 --- a/tests/fixtures/tracking_projections.json +++ b/tests/fixtures/tracking_projections.json @@ -558,5 +558,409 @@ "days": 1, "current_value": 1 } + }, + { + "name": "multiple selected books reading_time", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 10 + } + }, + { + "name": "multiple selected books pages_count", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 10, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 5 + } + }, + { + "name": "multiple selected books reading_days", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_days", + "target_value": 1, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 1 + } + }, + { + "name": "selected book completions deduplicate and undo", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 2, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000031", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:01:00Z", + "created_at": "2026-10-05T10:01:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000032", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:02:00Z", + "end_time": "2026-10-05T10:02:00Z", + "created_at": "2026-10-05T10:02:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000033", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:03:00Z", + "end_time": "2026-10-05T10:03:00Z", + "created_at": "2026-10-05T10:03:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000034", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:04:00Z", + "end_time": "2026-10-05T10:04:00Z", + "created_at": "2026-10-05T10:04:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000035", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:05:00Z", + "created_at": "2026-10-05T10:05:00Z", + "kind": "reversal", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-000000000032" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 1, + "days": 0, + "current_value": 1 + } + }, + { + "name": "identical manual sessions only count once after goal creation", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T00:35:30Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-05T00:36:00Z", + "expected": { + "seconds": 30, + "pages": 0, + "finished_books": 0, + "days": 0, + "current_value": 0 + } } ] diff --git a/tests/services/test_tracking.py b/tests/services/test_tracking.py index 4cc2c1f..345aa5f 100644 --- a/tests/services/test_tracking.py +++ b/tests/services/test_tracking.py @@ -8,8 +8,9 @@ from papyrus.core.exceptions import ForbiddenError, ValidationError from papyrus.models import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal +from papyrus.schemas.goal import GoalType from papyrus.schemas.sync import PowerSyncCrudMutation -from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule +from papyrus.schemas.tracking import Activity, GoalDefinition, GoalRule, PeriodRecord from papyrus.services.sync import apply_powersync_upload_batch from tests.services.test_sync import _create_book, _create_user @@ -154,3 +155,80 @@ async def test_book_goal_deletion_preserves_activity_and_periods(test_session_ma ) await apply_powersync_upload_batch(session, owner, [mutation("reading_activities", reverse)]) assert await session.get(SyncReadingActivity, reverse.id) is not None + + +async def test_selected_book_goals_validate_every_owner_and_keep_selection_immutable(test_session_maker): + async with test_session_maker() as session: + owner, book_id, goal, _ = await setup(session) + from papyrus.models import User + + user = await session.get(User, owner) + other = await _create_book(session, user) + foreign_owner = await _create_user(session, f"{uuid4()}@example.com") + foreign = await _create_book(session, foreign_owner) + other_id, foreign_id = other.book_id, foreign.book_id + await session.commit() + selected = goal.model_copy(update={"scope": "book", "scope_id": book_id, "book_ids": [book_id, other_id]}) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", selected)]) + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", selected)]) + persisted = GoalDefinition.model_validate((await session.get(SyncReadingGoal, goal.id)).payload) + assert set(persisted.selected_book_ids) == {book_id, other_id} + changed = selected.model_copy(update={"book_ids": [book_id]}) + + with pytest.raises(ValidationError, match="replacement"): + 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}) + ) + 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(), + "definition": history.definition.model_copy(update={"book_ids": [book_id, foreign_id]}), + } + ) + + with pytest.raises(ForbiddenError): + await apply_powersync_upload_batch(session, owner, [mutation("goal_periods", foreign_history)]) + + invalid = selected.model_copy(update={"id": uuid4(), "book_ids": [book_id, foreign_id]}) + + with pytest.raises(ForbiddenError): + 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, + } + + +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", + "scope_id": book_id, + "book_ids": [book_id, book_id], + "goal_type": GoalType.BOOKS_COUNT, + "target_value": 2, + "rules": [GoalRule(at=goal.created_at, target=2)], + } + ) + + with pytest.raises(ValidationError, match="number of selected books"): + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", invalid)]) + + single = invalid.model_copy(update={"book_ids": []}) + + with pytest.raises(ValidationError, match="number of selected books"): + await apply_powersync_upload_batch(session, owner, [mutation("reading_goals", single)]) + + assert await session.get(SyncReadingGoal, goal.id) is None From de204bea6a133bb5ffba5dc6b5838e36fa01f0f7 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 9 Oct 2026 01:59:56 +0300 Subject: [PATCH 04/11] Preserve goal history and correct grouped session statistics --- papyrus/schemas/goal.py | 11 +++++- papyrus/services/goals.py | 2 +- papyrus/services/progress.py | 8 +++-- papyrus/services/tracking_validation.py | 6 +++- tests/api/routes/test_goals.py | 44 +++++++++++++++++++++++ tests/api/routes/test_progress.py | 47 ++++++++++++++++++++++++- tests/services/test_tracking.py | 26 ++++++++++++++ 7 files changed, 137 insertions(+), 7 deletions(-) diff --git a/papyrus/schemas/goal.py b/papyrus/schemas/goal.py index 3a06507..fdaf9bd 100644 --- a/papyrus/schemas/goal.py +++ b/papyrus/schemas/goal.py @@ -5,7 +5,7 @@ from typing import Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class GoalType(StrEnum): @@ -89,3 +89,12 @@ class UpdateGoalRequest(BaseModel): end_date: date | None = None is_active: bool | None = None is_archived: bool | None = None + + @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.""" + if value is None: + raise ValueError("Goal settings cannot be null") + + return value diff --git a/papyrus/services/goals.py b/papyrus/services/goals.py index 665bb7e..d38cb2d 100644 --- a/papyrus/services/goals.py +++ b/papyrus/services/goals.py @@ -99,7 +99,7 @@ async def create_goal(session: AsyncSession, user_id: UUID, request: CreateGoalR except (PayloadError, ZoneInfoNotFoundError) as exc: raise ValidationError(str(exc)) from exc - if definition.time_period != "custom": + if definition.is_recurring and definition.time_period != "custom": definition.start_date, definition.end_date = calendar_period(definition, now) await apply_powersync_upload_batch( diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py index 300aff6..644a8c3 100644 --- a/papyrus/services/progress.py +++ b/papyrus/services/progress.py @@ -254,18 +254,20 @@ async def statistics( title=title, reading_time_minutes=values["seconds"] // 60, pages_read=int(values["pages"]), - sessions_count=sum(entry.book_id == book_id for entry in counted), + sessions_count=len({entry.session_id or entry.id for entry in counted if entry.book_id == book_id}), ) ) + sessions_count = len({entry.session_id or entry.id for entry in counted}) + return ReadingStatistics( period=StatisticsPeriod(start_date=first, end_date=last), totals=StatisticsTotals( reading_time_minutes=total["seconds"] // 60, pages_read=int(total["pages"]), books_completed=total["finished_books"], - sessions_count=len({entry.session_id or entry.id for entry in counted}), - average_session_minutes=total["seconds"] / 60 / len(counted) if counted else 0, + sessions_count=sessions_count, + average_session_minutes=total["seconds"] / 60 / sessions_count if sessions_count else 0, reading_days=total["days"], current_streak=current, longest_streak=longest, diff --git a/papyrus/services/tracking_validation.py b/papyrus/services/tracking_validation.py index f94b83f..1730bdb 100644 --- a/papyrus/services/tracking_validation.py +++ b/papyrus/services/tracking_validation.py @@ -49,7 +49,11 @@ async def validate_tracking_mutation( payload = parsed.model_dump(mode="json") - if table == "reading_activities" and row is not None and schema.model_validate(row.payload) != parsed: + if ( + table in {"reading_activities", "goal_periods"} + and row is not None + and schema.model_validate(row.payload) != parsed + ): raise ValidationError("Activity and period history are immutable; append a correction") if isinstance(parsed, GoalDefinition): diff --git a/tests/api/routes/test_goals.py b/tests/api/routes/test_goals.py index 4ec7cbb..d40caf9 100644 --- a/tests/api/routes/test_goals.py +++ b/tests/api/routes/test_goals.py @@ -55,6 +55,50 @@ async def test_unknown_goal_is_not_an_example(client, auth_headers): assert (await client.get(f"/v1/goals/{uuid4()}", headers=auth_headers)).status_code == 404 +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, + json={ + "title": "Next year's books", + "goal_type": "books_count", + "target_value": 12, + "time_period": "yearly", + "is_recurring": False, + "timezone": "Europe/Vilnius", + "start_date": f"{year}-01-01", + "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() + + for goal in [saved, fetched]: + assert goal["start_date"] == f"{year}-01-01" + assert goal["end_date"] == f"{year}-12-31" + assert goal["is_recurring"] is False + + +@pytest.mark.parametrize("field", ["title", "target_value", "is_active", "is_archived"]) +async def test_null_goal_settings_are_rejected_without_modification(client, auth_headers, goal_id, field): + before = (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).json() + response = await client.patch(f"/v1/goals/{goal_id}", headers=auth_headers, json={field: None}) + assert response.status_code == 422 + after = (await client.get(f"/v1/goals/{goal_id}", headers=auth_headers)).json() + assert after == before + + +async def test_goal_description_can_be_cleared(client, auth_headers, goal_id): + response = await client.patch(f"/v1/goals/{goal_id}", headers=auth_headers, json={"description": "A note"}) + assert response.status_code == 200 + assert response.json()["description"] == "A note" + response = await client.patch(f"/v1/goals/{goal_id}", headers=auth_headers, json={"description": None}) + assert response.status_code == 200 + assert response.json()["description"] is None + + async def test_create_reading_days_goal(client, auth_headers): today = datetime.now(UTC).date().isoformat() response = await client.post( diff --git a/tests/api/routes/test_progress.py b/tests/api/routes/test_progress.py index 4bb6678..90626d8 100644 --- a/tests/api/routes/test_progress.py +++ b/tests/api/routes/test_progress.py @@ -1,6 +1,6 @@ """Tests for reading progress and statistics endpoints.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from uuid import uuid4 import pytest @@ -104,3 +104,48 @@ async def test_missing_book_is_rejected(client, 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, + json={ + "batch": [ + { + "type": "reading_activities", + "op": "PUT", + "id": identifier, + "data": { + "payload": { + "id": identifier, + "session_id": session_id, + "book_id": book_id, + "book_title": "Reading book", + "source": "reader", + "start_time": (end - timedelta(minutes=20 - index * 10)).isoformat(), + "end_time": (end - timedelta(minutes=10 - index * 10)).isoformat(), + "created_at": end.isoformat(), + } + }, + } + for index, identifier in enumerate([str(uuid4()), str(uuid4())]) + ] + }, + ) + 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 + assert statistics["totals"]["average_session_minutes"] == 20 + assert statistics["books_breakdown"][0]["sessions_count"] == 1 diff --git a/tests/services/test_tracking.py b/tests/services/test_tracking.py index 345aa5f..b2a3b7b 100644 --- a/tests/services/test_tracking.py +++ b/tests/services/test_tracking.py @@ -93,6 +93,32 @@ async def test_tracking_ownership_applies_to_payload_references(test_session_mak await apply_powersync_upload_batch(session, intruder_id, [mutation("reading_goals", goal)]) +@pytest.mark.parametrize("operation", ["PUT", "PATCH"]) +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})) + await apply_powersync_upload_batch( + 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( + update={"target_value": 50, "rules": [GoalRule(at=goal.created_at, target=50)]} + ) + } + ) + rewrite = mutation("goal_periods", changed).model_copy(update={"op": operation}) + + with pytest.raises(ValidationError, match="immutable"): + await apply_powersync_upload_batch(session, owner, [rewrite]) + + persisted = await session.get(SyncGoalPeriod, period.id) + assert PeriodRecord.model_validate(persisted.payload) == period + + async def test_goal_rule_branches_merge_without_rewriting_existing_history(test_session_maker): async with test_session_maker() as session: owner, _, goal, _ = await setup(session) From 7e45e6b9ce8f2462f9325aaaf2b7f8f4caf69c68 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 10 Oct 2026 02:27:15 +0300 Subject: [PATCH 05/11] 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() From 5275361640bb1e3642c26525aa578311f2e60f0b Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sat, 10 Oct 2026 21:23:45 +0300 Subject: [PATCH 06/11] refactor: remove legacy library payload promotion --- papyrus/services/library_sync.py | 5 +-- papyrus/services/library_validation.py | 45 +---------------------- tests/api/routes/test_library_sync.py | 23 +++++------- tests/services/test_library_validation.py | 16 +------- 4 files changed, 13 insertions(+), 76 deletions(-) diff --git a/papyrus/services/library_sync.py b/papyrus/services/library_sync.py index b8ece19..f9bf3c5 100644 --- a/papyrus/services/library_sync.py +++ b/papyrus/services/library_sync.py @@ -25,7 +25,7 @@ ) from papyrus.schemas.sync import PowerSyncCrudMutation from papyrus.services import media as media_service -from papyrus.services.library_validation import convert_value, normalize_book_payload, uuid_value +from papyrus.services.library_validation import convert_value, uuid_value MODELS: dict[str, Any] = { "reading_goals": SyncReadingGoal, @@ -233,9 +233,6 @@ async def apply_library_mutation( payload = dict(mutation.op_data or {}) payload = {key: value for key, value in payload.items() if key not in {"owner_user_id", "updated_at"}} - if table == "books": - payload = normalize_book_payload(payload) - if is_membership: payload = membership_values(table, mutation.id, payload) diff --git a/papyrus/services/library_validation.py b/papyrus/services/library_validation.py index dc68380..21f3bef 100644 --- a/papyrus/services/library_validation.py +++ b/papyrus/services/library_validation.py @@ -1,4 +1,4 @@ -"""Type conversion and backwards compatibility for library queue payloads.""" +"""Type conversion and validation for library queue payloads.""" from datetime import UTC, datetime from math import isfinite @@ -9,26 +9,6 @@ from sqlalchemy.dialects.postgresql import JSONB from papyrus.core.exceptions import ValidationError -from papyrus.models.sync import SyncBook - -PROMOTED_BOOK_FIELDS = frozenset( - { - "publication_date", - "file_format", - "file_size", - "file_hash", - "is_physical", - "physical_location", - "lent_to", - "lent_at", - "series_id", - "series_name", - "series_number", - "started_at", - "completed_at", - "last_read_at", - } -) def uuid_value(value: object, name: str) -> UUID: @@ -38,29 +18,6 @@ def uuid_value(value: object, name: str) -> UUID: raise ValidationError(f"{name} must be a valid UUID") from exc -def normalize_book_payload(payload: dict[str, Any]) -> dict[str, Any]: - """Keep the legacy envelope intact while promoting queued legacy values.""" - envelope = payload.get("custom_metadata") - - if isinstance(envelope, dict): - promoted = {} - - for key, value in envelope.items(): - if key not in PROMOTED_BOOK_FIELDS or key in payload: - continue - - try: - convert_value(SyncBook.__table__.columns[key], value) - except ValidationError: - continue - - promoted[key] = value - - return {**promoted, **payload} - - return payload - - def finite_number(value: Any) -> bool: if not isinstance(value, int | float) or isinstance(value, bool): return False diff --git a/tests/api/routes/test_library_sync.py b/tests/api/routes/test_library_sync.py index 52a1c92..4dd41ca 100644 --- a/tests/api/routes/test_library_sync.py +++ b/tests/api/routes/test_library_sync.py @@ -155,24 +155,21 @@ async def test_membership_remove_readd_and_pair_validation(client, auth_headers) ).status_code == 400 -async def test_legacy_envelope_normalizes_and_preserves_metadata(client, auth_headers, db_session): - envelope = { - "publication_date": "2020-01-01T00:00:00Z", - "series_id": "old-id", - "file_size": 42, - "is_physical": True, - "custom_metadata": {"key": "value"}, - } - +async def test_custom_metadata_does_not_override_book_fields(client, auth_headers, db_session): + metadata = {"file_size": 42, "is_physical": True, "reader_locator": {"version": 1}, "key": "value"} response = await upload( client, auth_headers, - mutation("books", uuid4(), {"title": "Book", "custom_metadata": envelope, "series_id": "explicit"}), + mutation( + "books", + uuid4(), + {"title": "Book", "custom_metadata": metadata, "series_id": "series", "file_size": 100}, + ), ) 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) + assert row == ("series", 100, False, metadata) async def test_foreign_reference_rolls_back_mixed_batch(client, auth_headers, db_session): @@ -286,7 +283,7 @@ async def test_concurrent_patches_preserve_unrelated_values(client, auth_headers mutation( "books", book, - {"title": "Book", "author": "Author", "custom_metadata": {"custom_metadata": {"keep": True}}}, + {"title": "Book", "author": "Author", "custom_metadata": {"keep": True}}, ), ) ).status_code == 200 @@ -316,7 +313,7 @@ async def test_concurrent_patches_preserve_unrelated_values(client, auth_headers 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}}) + assert row == ("New title", "New author", {"keep": True}) async def test_library_field_validation_is_atomic(client, auth_headers, db_session): diff --git a/tests/services/test_library_validation.py b/tests/services/test_library_validation.py index 1c6d6f5..70164e7 100644 --- a/tests/services/test_library_validation.py +++ b/tests/services/test_library_validation.py @@ -2,7 +2,7 @@ from papyrus.core.exceptions import ValidationError from papyrus.models import SyncAnnotation, SyncBook -from papyrus.services.library_validation import convert_value, normalize_book_payload +from papyrus.services.library_validation import convert_value @pytest.mark.parametrize( @@ -16,17 +16,3 @@ def test_overflows_are_controlled_validation_errors(column, value): with pytest.raises(ValidationError): convert_value(column, value) - - -def test_legacy_invalid_values_remain_in_envelope_without_blocking_queue(): - envelope = {"is_physical": None, "series_number": "invalid", "file_size": 10**500, "lent_to": "Reader"} - normalized = normalize_book_payload({"custom_metadata": envelope}) - assert normalized == {"custom_metadata": envelope, "lent_to": "Reader"} - - -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"]) From 5a53e386f9988ab6ff7d8a85cd2de4e94d19651f Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 00:05:52 +0300 Subject: [PATCH 07/11] feat: serve immutable web releases through atomic current link --- deploy/Caddyfile | 15 +++++++-- deploy/README.md | 21 ++++++++++++- deploy/migrate_web_layout.py | 56 ++++++++++++++++++++++++++++++++++ deploy/validate_config.py | 6 ++-- tools/tests/test_web_layout.py | 43 ++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 deploy/migrate_web_layout.py create mode 100644 tools/tests/test_web_layout.py diff --git a/deploy/Caddyfile b/deploy/Caddyfile index df8ee1d..1815357 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,5 +1,14 @@ :8080 { - root * /srv/web - try_files {path} /index.html - file_server + root * /srv/web/current + header Cache-Control "no-cache" + @metadata path /release.json + handle @metadata { + file_server + } + handle { + try_files {path} /index.html + file_server { + hide .artifact-sha256 + } + } } diff --git a/deploy/README.md b/deploy/README.md index a486c62..a7753e6 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -75,7 +75,9 @@ are assembled from them. Do not rotate the JWT private key on ordinary deploys. Configure a real SMTP provider with TLS, verified sender, and its credentials; Mailpit is for local development. Add Google OAuth credentials if testing Google sign-in. Extract the matching client's `web-release` artifact into `web/`, so -`web/index.html` exists. The mobile app does not require visiting the web app for +`web/index.html` exists, then run `python3 migrate_web_layout.py` to initialize +`web/current/index.html`. This preserves the flat files and snapshots the initial +release. Subsequent client releases activate versioned directories atomically. The mobile app does not require visiting the web app for ordinary reading, but registration verification/reset emails use its routes. If the GHCR package is private, authenticate Docker on the VM with a restricted @@ -137,3 +139,20 @@ References: - [Hetzner Cloud firewalls](https://docs.hetzner.com/cloud/firewalls/overview/) - [Docker Compose deployment](https://docs.docker.com/compose/how-tos/production/) - [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https) + +## Updating the web delivery layout + +For an existing deployment, run `python3 migrate_web_layout.py` from `deploy/` +before installing the updated Caddyfile. It leaves the running flat directory +intact. Copy the new Caddyfile into the existing bind-mounted file (preserve its +inode), then validate and reload only the web process: + +```sh +docker compose --env-file production.env exec -T web caddy validate --config /etc/caddy/Caddyfile +docker compose --env-file production.env exec -T web caddy reload --config /etc/caddy/Caddyfile +``` + +Keep the parent `web` directory mounted at `/srv/web`. Check `/` and `/login` after +migration. Client delivery credentials and the restricted receiver are documented +in the client's `docs/RELEASING.md`. This migration needs no database changes and +must not run the full server deployment script or restart API/PowerSync services. diff --git a/deploy/migrate_web_layout.py b/deploy/migrate_web_layout.py new file mode 100644 index 0000000..eb30830 --- /dev/null +++ b/deploy/migrate_web_layout.py @@ -0,0 +1,56 @@ +"""Snapshot the existing flat web deployment without deleting or modifying its files.""" + +import argparse +import os +import shutil +import tempfile +from pathlib import Path + + +def migrate(root: Path) -> None: + current = root / "current" + + if current.is_symlink() and (current / "index.html").is_file(): + return + + if current.exists() or current.is_symlink(): + raise ValueError("An invalid current entry already exists; inspect it before migrating") + + if not (root / "index.html").is_file(): + raise ValueError("Extract an initial web release into this directory before migrating") + + sources = [path for path in root.iterdir() if path.name != "releases" and not path.name.startswith(".")] + + if any(path.is_symlink() or any(child.is_symlink() for child in path.rglob("*")) for path in sources): + raise ValueError("The initial web deployment must not contain symlinks") + + releases = root / "releases" + releases.mkdir(exist_ok=True) + snapshot = Path(tempfile.mkdtemp(prefix="initial-", dir=releases)) + + try: + for source in sources: + if source.is_dir(): + shutil.copytree(source, snapshot / source.name) + else: + shutil.copy2(source, snapshot / source.name) + + snapshot.chmod(0o755) + link = root / ".current-next" + link.symlink_to(f"releases/{snapshot.name}") + os.replace(link, current) + except BaseException: + shutil.rmtree(snapshot) + raise + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent / "web") + args = parser.parse_args() + migrate(args.root) + print("Versioned web layout ready; original flat files remain intact") + + +if __name__ == "__main__": + main() diff --git a/deploy/validate_config.py b/deploy/validate_config.py index bd0d28f..82198af 100644 --- a/deploy/validate_config.py +++ b/deploy/validate_config.py @@ -48,8 +48,10 @@ def main() -> None: for name in ("powersync-private.pem", "powersync-public.pem"): if not (directory / "secrets" / name).is_file(): raise ValueError(f"Missing secrets/{name}; see the deployment runbook") - if not (directory / "web/index.html").is_file(): - raise ValueError("Missing web/index.html; extract the client web-release artifact into deploy/web") + if not (directory / "web/current/index.html").is_file(): + raise ValueError( + "Missing web/current/index.html; initialize the versioned web layout in the deployment runbook" + ) print("Deployment configuration validated") diff --git a/tools/tests/test_web_layout.py b/tools/tests/test_web_layout.py new file mode 100644 index 0000000..f612942 --- /dev/null +++ b/tools/tests/test_web_layout.py @@ -0,0 +1,43 @@ +"""The web layout migration preserves the deployed app and is safe to repeat.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location( + "migrate_web_layout", Path(__file__).resolve().parents[2] / "deploy/migrate_web_layout.py" +) +assert spec is not None and spec.loader is not None +layout = importlib.util.module_from_spec(spec) +spec.loader.exec_module(layout) + + +class WebLayoutTest(unittest.TestCase): + def test_snapshot_preserves_original_files_and_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.html").write_text("existing release") + (root / "assets").mkdir() + (root / "assets/book.js").write_text("book") + layout.migrate(root) + target = (root / "current").resolve() + layout.migrate(root) + self.assertEqual((root / "current").resolve(), target) + self.assertEqual((root / "index.html").read_text(), "existing release") + self.assertEqual((root / "current/assets/book.js").read_text(), "book") + + def test_missing_initial_release_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory, self.assertRaises(ValueError): + layout.migrate(Path(directory)) + + def test_symlink_content_rejected_without_changes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.html").write_text("existing release") + (root / "escape").symlink_to("/tmp") + + with self.assertRaises(ValueError): + layout.migrate(root) + + self.assertFalse((root / "current").exists()) From 9155347ae47ea2d9257179e52a5f36f70d5e99b9 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 00:07:20 +0300 Subject: [PATCH 08/11] docs: specify production compose file for web reload --- deploy/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index a7753e6..e0c709e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -148,8 +148,8 @@ intact. Copy the new Caddyfile into the existing bind-mounted file (preserve its inode), then validate and reload only the web process: ```sh -docker compose --env-file production.env exec -T web caddy validate --config /etc/caddy/Caddyfile -docker compose --env-file production.env exec -T web caddy reload --config /etc/caddy/Caddyfile +docker compose --env-file production.env -f compose.yml exec -T web caddy validate --config /etc/caddy/Caddyfile +docker compose --env-file production.env -f compose.yml exec -T web caddy reload --config /etc/caddy/Caddyfile ``` Keep the parent `web` directory mounted at `/srv/web`. Check `/` and `/login` after From 27be59d4525180bef5d0b9e79474d478c42a66d6 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 01:25:40 +0300 Subject: [PATCH 09/11] chore: prepare coordinated server release 0.0.2 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d0212c0..3699f1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "papyrus-server" -version = "0.0.1" +version = "0.0.2" description = "REST API server for Papyrus, a cross platform book management application." requires-python = ">=3.12" license = { text = "AGPL-3.0" } diff --git a/uv.lock b/uv.lock index 7dbb896..3eaa350 100644 --- a/uv.lock +++ b/uv.lock @@ -789,7 +789,7 @@ wheels = [ [[package]] name = "papyrus-server" -version = "0.0.1" +version = "0.0.2" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 3daea7c2e0c3aa0640e1b4ebf470f7d5b48d6f75 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 01:44:29 +0300 Subject: [PATCH 10/11] fix: preserve session metadata and serialize progress retries --- papyrus/schemas/tracking.py | 3 + papyrus/services/progress.py | 74 ++++++++++++----- papyrus/services/sync.py | 7 +- tests/api/routes/test_progress.py | 128 ++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 20 deletions(-) diff --git a/papyrus/schemas/tracking.py b/papyrus/schemas/tracking.py index d07f659..9e24e38 100644 --- a/papyrus/schemas/tracking.py +++ b/papyrus/schemas/tracking.py @@ -127,6 +127,9 @@ def ordered(self) -> "PageCoverage": class Activity(TrackingPayload): session_id: UUID | None = None + start_position: float | None = Field(default=None, ge=0, le=1) + end_position: float | None = Field(default=None, ge=0, le=1) + device_type: str | None = None book_id: UUID book_title: str = Field(min_length=1, max_length=500) start_time: AwareDatetime diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py index 8b87706..3139a5f 100644 --- a/papyrus/services/progress.py +++ b/papyrus/services/progress.py @@ -1,5 +1,6 @@ """Real activity-backed sessions and cross-device statistics.""" +from collections import defaultdict from datetime import UTC, date, datetime, time, timedelta from uuid import UUID, uuid4 @@ -27,7 +28,7 @@ from papyrus.services.goal_progress import effective_activities, project_goal, union_length from papyrus.services.goals import activities from papyrus.services.library_sync import owned_row -from papyrus.services.sync import apply_powersync_upload_batch +from papyrus.services.sync import apply_powersync_upload_batch, lock_owner_mutations def session_response(activity: Activity) -> ReadingSession: @@ -39,7 +40,9 @@ def session_response(activity: Activity) -> ReadingSession: end_time=activity.end_time, pages_read=activity.pages, duration_minutes=int((activity.end_time - activity.start_time).total_seconds()) // 60, - device_type=activity.source, + start_position=activity.start_position, + end_position=activity.end_position, + device_type=activity.device_type if activity.device_type is not None else activity.source, device_name=activity.device_id, created_at=activity.created_at, ) @@ -55,9 +58,11 @@ def grouped_sessions(entries: list[Activity]) -> list[ReadingSession]: for group in groups.values(): first = min(group, key=lambda entry: entry.start_time) - end = max(entry.end_time for entry in group) + last = max(group, key=lambda entry: entry.end_time) + end = last.end_time result = session_response(first) result.end_time = end + result.end_position = last.end_position result.duration_minutes = ( int(union_length([(entry.start_time.timestamp(), entry.end_time.timestamp()) for entry in group])) // 60 @@ -116,6 +121,8 @@ async def list_sessions( async def create_session(session: AsyncSession, user_id: UUID, request: CreateReadingSessionRequest) -> ReadingSession: + await lock_owner_mutations(session, user_id) + book = await owned_row( session, user_id, @@ -146,6 +153,9 @@ async def create_session(session: AsyncSession, user_id: UUID, request: CreateRe original.book_id != request.book_id or original.start_time != start or (requested_end is not None and original.end_time != requested_end) + or original.start_position != request.start_position + or original.end_position != request.end_position + or original.device_type != request.device_type or original.pages != (request.pages_read or 0) or original.device_id != (request.device_name or "manual") ): @@ -173,6 +183,9 @@ async def create_session(session: AsyncSession, user_id: UUID, request: CreateRe start_time=start, end_time=end, created_at=datetime.now(UTC), + start_position=request.start_position, + end_position=request.end_position, + device_type=request.device_type, pages=request.pages_read or 0, device_id=request.device_name or "manual", shelf_ids=shelves, @@ -211,6 +224,42 @@ def totals_definition(start: datetime, end: datetime, book_id: UUID | None = Non ) +def overlaps_period(activity: Activity, start: datetime, end: datetime) -> bool: + if activity.start_time == activity.end_time: + return start <= activity.start_time < end + + return activity.start_time < end and activity.end_time > start + + +def current_reading_streak(ledger: list[Activity], now: datetime) -> int: + """Count qualified UTC days across the full effective ledger, independent of report bounds.""" + daily_intervals: dict[date, list[tuple[float, float]]] = defaultdict(list) + + for activity in ledger: + if activity.kind != "reading": + continue + + cursor = activity.start_time.astimezone(UTC) + end = min(activity.end_time, now) + + while cursor < end: + next_day = datetime.combine(cursor.date() + timedelta(days=1), time.min, UTC) + stop = min(next_day, end) + daily_intervals[cursor.date()].append((cursor.timestamp(), stop.timestamp())) + cursor = stop + + qualified = {day for day, intervals in daily_intervals.items() if union_length(intervals) >= 300} + today = now.date() + cursor_date = today if today in qualified else today - timedelta(days=1) + streak = 0 + + while cursor_date in qualified: + streak += 1 + cursor_date -= timedelta(days=1) + + return streak + + async def statistics( session: AsyncSession, user_id: UUID, start_date: date | None, end_date: date | None ) -> ReadingStatistics: @@ -227,12 +276,9 @@ async def statistics( 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 - ] + counted = [entry for entry in real if entry.kind == "reading" and overlaps_period(entry, start, end)] daily = [] - reading_dates = [] longest = streak = 0 for index in range((last - first).days + 1): @@ -240,9 +286,7 @@ async def statistics( 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} - ) + count = len({entry.session_id or entry.id for entry in counted if overlaps_period(entry, day, next_day)}) daily.append( DailyBreakdown( @@ -254,19 +298,11 @@ async def statistics( ) if values["seconds"] >= 300: - reading_dates.append(day.date()) streak += 1 longest = max(longest, streak) else: streak = 0 - current = 0 - cursor = today if today in reading_dates else today - timedelta(days=1) - - while cursor in reading_dates: - current += 1 - cursor -= timedelta(days=1) - books = {entry.book_id: entry.book_title for entry in counted} breakdown = [] @@ -294,7 +330,7 @@ async def statistics( sessions_count=sessions_count, average_session_minutes=total["seconds"] / 60 / sessions_count if sessions_count else 0, reading_days=total["days"], - current_streak=current, + current_streak=current_reading_streak(real, now), longest_streak=longest, ), daily_breakdown=daily, diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py index 87d283c..b7d4bd5 100644 --- a/papyrus/services/sync.py +++ b/papyrus/services/sync.py @@ -12,6 +12,11 @@ from papyrus.services.library_sync import apply_library_mutation +async def lock_owner_mutations(session: AsyncSession, user_id: UUID) -> None: + """Hold the owner's mutation lock until the surrounding transaction ends.""" + await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + + async def apply_powersync_upload_batch( session: AsyncSession, user_id: UUID, @@ -22,7 +27,7 @@ async def apply_powersync_upload_batch( media_paths_to_delete: list[Path] = [] try: - await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update()) + await lock_owner_mutations(session, user_id) for mutation in batch: applied, deleted_media_paths = await apply_library_mutation(session, user_id, mutation) diff --git a/tests/api/routes/test_progress.py b/tests/api/routes/test_progress.py index ccae388..c4fc869 100644 --- a/tests/api/routes/test_progress.py +++ b/tests/api/routes/test_progress.py @@ -46,6 +46,7 @@ async def test_create_reading_session(client: AsyncClient, auth_headers: dict[st "start_time": now.isoformat(), "start_position": 0.25, "end_position": 0.35, + "device_type": "android", "pages_read": 20, }, ) @@ -53,6 +54,11 @@ async def test_create_reading_session(client: AsyncClient, auth_headers: dict[st assert response.status_code == 200 data = response.json() assert data["book_id"] == book_id + assert data["start_position"] == 0.25 + assert data["end_position"] == 0.35 + assert data["device_type"] == "android" + sessions = (await client.get("/v1/progress/sessions", headers=auth_headers)).json() + assert sessions["sessions"][0] == data async def test_get_reading_statistics(client: AsyncClient, auth_headers: dict[str, str]): @@ -134,6 +140,9 @@ async def test_statistics_group_reader_checkpoints_as_one_session(client, auth_h "book_id": book_id, "book_title": "Reading book", "source": "reader", + "start_position": index / 10, + "end_position": (index + 1) / 10, + "device_type": "linux", "start_time": (end - timedelta(minutes=20 - index * 10)).isoformat(), "end_time": (end - timedelta(minutes=10 - index * 10)).isoformat(), "created_at": end.isoformat(), @@ -149,6 +158,9 @@ async def test_statistics_group_reader_checkpoints_as_one_session(client, auth_h sessions = (await client.get("/v1/progress/sessions", headers=auth_headers)).json() assert sessions["pagination"]["total"] == 1 assert sessions["sessions"][0]["duration_minutes"] == 20 + assert sessions["sessions"][0]["start_position"] == 0 + assert sessions["sessions"][0]["end_position"] == 0.2 + assert sessions["sessions"][0]["device_type"] == "linux" result = await client.get( "/v1/progress/statistics", @@ -161,3 +173,119 @@ async def test_statistics_group_reader_checkpoints_as_one_session(client, auth_h assert statistics["totals"]["sessions_count"] == 1 assert statistics["totals"]["average_session_minutes"] == 20 assert statistics["books_breakdown"][0]["sessions_count"] == 1 + + +async def test_concurrent_session_retry_reuses_generated_timestamps(client, auth_headers, book_id, monkeypatch): + import asyncio + + from papyrus.services import progress + + upload = progress.apply_powersync_upload_batch + + async def delayed_upload(*args, **kwargs): + await asyncio.sleep(0.1) + return await upload(*args, **kwargs) + + monkeypatch.setattr(progress, "apply_powersync_upload_batch", delayed_upload) + + payload = { + "session_id": str(uuid4()), + "book_id": book_id, + "start_time": (datetime.now(UTC) - timedelta(minutes=10)).isoformat(), + "start_position": 0.2, + "end_position": 0.4, + "device_type": "android", + "pages_read": 12, + } + + first, second = await asyncio.gather( + client.post("/v1/progress/sessions", headers=auth_headers, json=payload), + client.post("/v1/progress/sessions", headers=auth_headers, json=payload), + ) + + assert first.status_code == second.status_code == 200 + assert first.json() == second.json() + + for field, value in [("start_position", 0.3), ("end_position", 0.5), ("device_type", "windows")]: + response = await client.post("/v1/progress/sessions", headers=auth_headers, json={**payload, field: value}) + + assert response.status_code == 400 + + +async def test_statistics_excludes_sessions_ending_at_period_start(client, auth_headers, book_id): + boundary = datetime(2026, 1, 2, tzinfo=UTC) + + response = await client.post( + "/v1/progress/sessions", + headers=auth_headers, + json={ + "book_id": book_id, + "start_time": (boundary - timedelta(minutes=10)).isoformat(), + "end_time": boundary.isoformat(), + "pages_read": 5, + }, + ) + + assert response.status_code == 200 + params = {"start_date": "2026-01-02", "end_date": "2026-01-02"} + result = await client.get("/v1/progress/statistics", headers=auth_headers, params=params) + statistics = result.json() + assert statistics["totals"]["sessions_count"] == 0 + assert statistics["totals"]["pages_read"] == 0 + assert statistics["totals"]["reading_time_minutes"] == 0 + assert statistics["books_breakdown"] == [] + assert statistics["daily_breakdown"][0]["sessions_count"] == 0 + + response = await client.post( + "/v1/progress/sessions", + headers=auth_headers, + json={ + "book_id": book_id, + "start_time": boundary.isoformat(), + "end_time": boundary.isoformat(), + "pages_read": 3, + }, + ) + + assert response.status_code == 200 + result = await client.get("/v1/progress/statistics", headers=auth_headers, params=params) + statistics = result.json() + assert statistics["totals"]["sessions_count"] == 1 + assert statistics["totals"]["pages_read"] == 3 + assert statistics["daily_breakdown"][0]["sessions_count"] == 1 + + +async def test_current_streak_is_independent_of_statistics_period(client, auth_headers, book_id, monkeypatch): + from papyrus.services import progress + + now = datetime(2026, 3, 1, 12, tzinfo=UTC) + + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): + return now + + monkeypatch.setattr(progress, "datetime", FrozenDateTime) + + for days_ago in range(3): + end = now - timedelta(days=days_ago) + + response = await client.post( + "/v1/progress/sessions", + headers=auth_headers, + json={ + "book_id": book_id, + "start_time": (end - timedelta(minutes=10)).isoformat(), + "end_time": end.isoformat(), + }, + ) + + assert response.status_code == 200 + + for params in [{}, {"start_date": "2026-02-01", "end_date": "2026-02-01"}]: + response = await client.get("/v1/progress/statistics", headers=auth_headers, params=params) + assert response.status_code == 200 + assert response.json()["totals"]["current_streak"] == 3 + + current = (await client.get("/v1/progress/statistics", headers=auth_headers)).json() + assert current["totals"]["longest_streak"] == 1 From dbd4f28a79f1e3b4919bfbe1ba77f37198cdab0f Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 11 Oct 2026 01:50:49 +0300 Subject: [PATCH 11/11] fix: calculate streaks without expanding long activity ranges --- papyrus/services/progress.py | 53 ++++++++++++++++++--------- tests/services/test_progress.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 tests/services/test_progress.py diff --git a/papyrus/services/progress.py b/papyrus/services/progress.py index 3139a5f..d980ada 100644 --- a/papyrus/services/progress.py +++ b/papyrus/services/progress.py @@ -232,32 +232,51 @@ def overlaps_period(activity: Activity, start: datetime, end: datetime) -> bool: def current_reading_streak(ledger: list[Activity], now: datetime) -> int: - """Count qualified UTC days across the full effective ledger, independent of report bounds.""" - daily_intervals: dict[date, list[tuple[float, float]]] = defaultdict(list) + """Union complete day ranges and partial boundary days without walking long activities.""" + boundaries: dict[date, list[tuple[float, float]]] = defaultdict(list) + qualified_ranges: list[tuple[int, int]] = [] for activity in ledger: if activity.kind != "reading": continue - cursor = activity.start_time.astimezone(UTC) - end = min(activity.end_time, now) + start = activity.start_time.astimezone(UTC) + end = min(activity.end_time, now).astimezone(UTC) - while cursor < end: - next_day = datetime.combine(cursor.date() + timedelta(days=1), time.min, UTC) - stop = min(next_day, end) - daily_intervals[cursor.date()].append((cursor.timestamp(), stop.timestamp())) - cursor = stop + if start >= end: + continue + + if start.date() == end.date(): + boundaries[start.date()].append((start.timestamp(), end.timestamp())) + continue + + next_day = datetime.combine(start.date() + timedelta(days=1), time.min, UTC) + last_day = datetime.combine(end.date(), time.min, UTC) + boundaries[start.date()].append((start.timestamp(), next_day.timestamp())) + boundaries[end.date()].append((last_day.timestamp(), end.timestamp())) + first_complete, last_complete = start.date().toordinal() + 1, end.date().toordinal() - 1 + + if first_complete <= last_complete: + qualified_ranges.append((first_complete, last_complete)) + + for day, intervals in boundaries.items(): + if union_length(intervals) >= 300: + qualified_ranges.append((day.toordinal(), day.toordinal())) + + merged: list[tuple[int, int]] = [] + + for first, last in sorted(qualified_ranges): + if merged and first <= merged[-1][1] + 1: + merged[-1] = (merged[-1][0], max(merged[-1][1], last)) + else: + merged.append((first, last)) - qualified = {day for day, intervals in daily_intervals.items() if union_length(intervals) >= 300} - today = now.date() - cursor_date = today if today in qualified else today - timedelta(days=1) - streak = 0 + today = now.astimezone(UTC).date().toordinal() - while cursor_date in qualified: - streak += 1 - cursor_date -= timedelta(days=1) + if merged and merged[-1][1] in {today, today - 1}: + return merged[-1][1] - merged[-1][0] + 1 - return streak + return 0 async def statistics( diff --git a/tests/services/test_progress.py b/tests/services/test_progress.py new file mode 100644 index 0000000..27226e8 --- /dev/null +++ b/tests/services/test_progress.py @@ -0,0 +1,63 @@ +"""Statistics streaks union activity boundaries without expanding every calendar day.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from papyrus.schemas.tracking import Activity +from papyrus.services import progress + + +def activity(start: datetime, end: datetime) -> Activity: + return Activity( + id=uuid4(), + book_id=uuid4(), + book_title="Book", + start_time=start, + end_time=end, + created_at=end, + ) + + +def test_streak_unions_partial_days_and_does_not_double_count_overlaps(): + today = datetime(2026, 3, 1, tzinfo=UTC) + now = today + timedelta(hours=12) + + entries = [ + activity(today - timedelta(days=2), today - timedelta(days=2) + timedelta(minutes=5)), + activity(today - timedelta(minutes=2), today + timedelta(minutes=2)), + activity(today - timedelta(minutes=2), today + timedelta(minutes=2)), + activity(today - timedelta(minutes=5), today - timedelta(minutes=2)), + activity(today + timedelta(hours=1), today + timedelta(hours=1, minutes=3)), + ] + + assert progress.current_reading_streak(entries, now) == 3 + assert progress.current_reading_streak(entries[:-1], now) == 2 + assert progress.current_reading_streak(entries, now + timedelta(days=2)) == 0 + + +def test_streak_clips_future_time_and_ignores_non_reading_records(): + today = datetime(2026, 3, 1, tzinfo=UTC) + entry = activity(today, today + timedelta(days=5)) + assert progress.current_reading_streak([entry], today + timedelta(minutes=4)) == 0 + assert progress.current_reading_streak([entry], today + timedelta(minutes=5)) == 1 + completion = entry.model_copy(update={"kind": "completion"}) + assert progress.current_reading_streak([completion], today + timedelta(days=5)) == 0 + + +def test_streak_cost_does_not_scale_with_activity_duration(monkeypatch): + now = datetime(2026, 3, 1, 12, tzinfo=UTC) + start = datetime(1, 1, 1, tzinfo=UTC) + combines = 0 + + class CountingDateTime(datetime): + @classmethod + def combine(cls, *args, **kwargs): + nonlocal combines + combines += 1 + assert combines <= 4 + return datetime.combine(*args, **kwargs) + + monkeypatch.setattr(progress, "datetime", CountingDateTime) + entries = [activity(start, now), activity(start, now)] + assert progress.current_reading_streak(entries, now) == now.date().toordinal() + assert combines == 4