feat: add KOSync server
- Add KOSync device management - Add API key auth middleware for devices to authenticate - Add KOSync-compatible progress sync endpoints - Add basic tests for KOSync compatible hashes
This commit is contained in:
@@ -6,3 +6,5 @@ from .author import AuthorController
|
|||||||
from .tag import TagController
|
from .tag import TagController
|
||||||
from .publisher import PublisherController
|
from .publisher import PublisherController
|
||||||
from .opds import OpdsController
|
from .opds import OpdsController
|
||||||
|
from .kosync_device import DeviceController
|
||||||
|
from .kosync_progress import KosyncController
|
||||||
|
|||||||
54
backend/src/chitai/controllers/kosync_device.py
Normal file
54
backend/src/chitai/controllers/kosync_device.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from advanced_alchemy.service.pagination import OffsetPagination
|
||||||
|
|
||||||
|
from chitai.database.models.kosync_device import KosyncDevice
|
||||||
|
from chitai.database.models.user import User
|
||||||
|
from chitai.schemas.kosync import KosyncDeviceCreate, KosyncDeviceRead
|
||||||
|
from chitai.services.kosync_device import KosyncDeviceService
|
||||||
|
from litestar import Controller, post, get, delete
|
||||||
|
from litestar.di import Provide
|
||||||
|
from chitai.services import dependencies as deps
|
||||||
|
|
||||||
|
class DeviceController(Controller):
|
||||||
|
""" Controller for managing KOReader devices."""
|
||||||
|
|
||||||
|
dependencies = {
|
||||||
|
"device_service": Provide(deps.provide_kosync_device_service)
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "/devices"
|
||||||
|
|
||||||
|
|
||||||
|
@get()
|
||||||
|
async def get_devices(self, device_service: KosyncDeviceService, current_user: User) -> OffsetPagination[KosyncDeviceRead]:
|
||||||
|
""" Return a list of all the user's devices."""
|
||||||
|
devices = await device_service.list(
|
||||||
|
KosyncDevice.user_id == current_user.id
|
||||||
|
)
|
||||||
|
return device_service.to_schema(devices, schema_type=KosyncDeviceRead)
|
||||||
|
|
||||||
|
@post()
|
||||||
|
async def create_device(self, data: KosyncDeviceCreate, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
|
||||||
|
device = await device_service.create({
|
||||||
|
'name': data.name,
|
||||||
|
'user_id': current_user.id
|
||||||
|
})
|
||||||
|
return device_service.to_schema(device, schema_type=KosyncDeviceRead)
|
||||||
|
|
||||||
|
@delete("/{device_id:int}")
|
||||||
|
async def delete_device(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> None:
|
||||||
|
# Ensure the device exists and is owned by the user
|
||||||
|
device = await device_service.get_one(
|
||||||
|
KosyncDevice.id == device_id,
|
||||||
|
KosyncDevice.user_id == current_user.id
|
||||||
|
)
|
||||||
|
await device_service.delete(device.id)
|
||||||
|
|
||||||
|
@get("/{device_id:int}/regenerate")
|
||||||
|
async def regenerate_device_api_key(self, device_id: int, device_service: KosyncDeviceService, current_user: User) -> KosyncDeviceRead:
|
||||||
|
# Ensure the device exists and is owned by the user
|
||||||
|
device = await device_service.get_one(
|
||||||
|
KosyncDevice.id == device_id,
|
||||||
|
KosyncDevice.user_id == current_user.id
|
||||||
|
)
|
||||||
|
updated_device = await device_service.regenerate_api_key(device.id)
|
||||||
|
return device_service.to_schema(updated_device, schema_type=KosyncDeviceRead)
|
||||||
90
backend/src/chitai/controllers/kosync_progress.py
Normal file
90
backend/src/chitai/controllers/kosync_progress.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from litestar import Controller, get, put
|
||||||
|
from litestar.exceptions import HTTPException
|
||||||
|
from litestar.status_codes import HTTP_403_FORBIDDEN
|
||||||
|
from litestar.response import Response
|
||||||
|
from litestar.params import Parameter
|
||||||
|
from litestar.di import Provide
|
||||||
|
|
||||||
|
from chitai.database import models as m
|
||||||
|
from chitai.schemas.kosync import KosyncProgressUpdate, KosyncProgressRead
|
||||||
|
from chitai.services.book import BookService
|
||||||
|
from chitai.services.kosync_progress import KosyncProgressService
|
||||||
|
from chitai.services.filters.book import FileHashFilter
|
||||||
|
from chitai.services import dependencies as deps
|
||||||
|
from chitai.middleware.kosync_auth import kosync_api_key_auth
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncController(Controller):
|
||||||
|
"""Controller for syncing progress with KOReader devices."""
|
||||||
|
|
||||||
|
middleware = [kosync_api_key_auth]
|
||||||
|
|
||||||
|
dependencies = {
|
||||||
|
"kosync_progress_service": Provide(deps.provide_kosync_progress_service),
|
||||||
|
"book_service": Provide(deps.provide_book_service),
|
||||||
|
"user": Provide(deps.provide_user_via_kosync_auth),
|
||||||
|
}
|
||||||
|
|
||||||
|
@put("/syncs/progress")
|
||||||
|
async def upload_progress(
|
||||||
|
self,
|
||||||
|
data: KosyncProgressUpdate,
|
||||||
|
book_service: BookService,
|
||||||
|
kosync_progress_service: KosyncProgressService,
|
||||||
|
user: m.User,
|
||||||
|
) -> None:
|
||||||
|
"""Upload book progress from a KOReader device."""
|
||||||
|
book = await book_service.get_one(FileHashFilter([data.document]))
|
||||||
|
|
||||||
|
await kosync_progress_service.upsert_progress(
|
||||||
|
user_id=user.id,
|
||||||
|
book_id=book.id,
|
||||||
|
document=data.document,
|
||||||
|
progress=data.progress,
|
||||||
|
percentage=data.percentage,
|
||||||
|
device=data.device,
|
||||||
|
device_id=data.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@get("/syncs/progress/{document_id:str}")
|
||||||
|
async def get_progress(
|
||||||
|
self,
|
||||||
|
document_id: str,
|
||||||
|
kosync_progress_service: KosyncProgressService,
|
||||||
|
user: m.User,
|
||||||
|
) -> KosyncProgressRead:
|
||||||
|
"""Return the Kosync progress record associated with the given document."""
|
||||||
|
progress = await kosync_progress_service.get_by_document_hash(user.id, document_id)
|
||||||
|
|
||||||
|
if not progress:
|
||||||
|
raise HTTPException(status_code=404, detail="No progress found for document")
|
||||||
|
|
||||||
|
return KosyncProgressRead(
|
||||||
|
document=progress.document,
|
||||||
|
progress=progress.progress,
|
||||||
|
percentage=progress.percentage,
|
||||||
|
device=progress.device,
|
||||||
|
device_id=progress.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@get("/users/auth")
|
||||||
|
async def authorize(
|
||||||
|
self, _api_key: Annotated[str, Parameter(header="X-AUTH-USER")]
|
||||||
|
) -> Response[dict[str, str]]:
|
||||||
|
"""Verify authentication (handled by middleware)."""
|
||||||
|
return Response(status_code=200, content={"authorized": "OK"})
|
||||||
|
|
||||||
|
@get("/users/register")
|
||||||
|
async def register(self) -> None:
|
||||||
|
"""User registration endpoint - disabled."""
|
||||||
|
raise HTTPException(
|
||||||
|
detail="User accounts must be created via the main application",
|
||||||
|
status_code=HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@ from .book import Book, Identifier, FileMetadata
|
|||||||
from .book_list import BookList, BookListLink
|
from .book_list import BookList, BookListLink
|
||||||
from .book_progress import BookProgress
|
from .book_progress import BookProgress
|
||||||
from .book_series import BookSeries
|
from .book_series import BookSeries
|
||||||
|
from .kosync_device import KosyncDevice
|
||||||
|
from .kosync_progress import KosyncProgress
|
||||||
from .library import Library
|
from .library import Library
|
||||||
from .publisher import Publisher
|
from .publisher import Publisher
|
||||||
from .tag import Tag, BookTagLink
|
from .tag import Tag, BookTagLink
|
||||||
|
|||||||
15
backend/src/chitai/database/models/kosync_device.py
Normal file
15
backend/src/chitai/database/models/kosync_device.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from sqlalchemy import ColumnElement, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped
|
||||||
|
from sqlalchemy.orm import mapped_column
|
||||||
|
|
||||||
|
from advanced_alchemy.base import BigIntAuditBase
|
||||||
|
|
||||||
|
class KosyncDevice(BigIntAuditBase):
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
|
api_key: Mapped[str] = mapped_column(unique=True)
|
||||||
|
name: Mapped[str]
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"KosyncDevice({self.name!r})"
|
||||||
26
backend/src/chitai/database/models/kosync_progress.py
Normal file
26
backend/src/chitai/database/models/kosync_progress.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from advanced_alchemy.base import BigIntAuditBase
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncProgress(BigIntAuditBase):
|
||||||
|
"""Progress tracking for KOReader devices, keyed by document hash."""
|
||||||
|
|
||||||
|
__tablename__ = "kosync_progress"
|
||||||
|
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
book_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("books.id", ondelete="cascade"), nullable=False
|
||||||
|
)
|
||||||
|
document: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
progress: Mapped[Optional[str]]
|
||||||
|
percentage: Mapped[Optional[float]]
|
||||||
|
device: Mapped[Optional[str]]
|
||||||
|
device_id: Mapped[Optional[str]]
|
||||||
37
backend/src/chitai/middleware/kosync_auth.py
Normal file
37
backend/src/chitai/middleware/kosync_auth.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from chitai.services.user import UserService
|
||||||
|
from chitai.services.kosync_device import KosyncDeviceService
|
||||||
|
from litestar.middleware import (
|
||||||
|
AbstractAuthenticationMiddleware,
|
||||||
|
AuthenticationResult,
|
||||||
|
DefineMiddleware
|
||||||
|
)
|
||||||
|
from litestar.connection import ASGIConnection
|
||||||
|
from litestar.exceptions import NotAuthorizedException, PermissionDeniedException
|
||||||
|
from chitai.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncAuthenticationMiddleware(AbstractAuthenticationMiddleware):
|
||||||
|
async def authenticate_request(self, connection: ASGIConnection) -> AuthenticationResult:
|
||||||
|
"""Given a request, parse the header for Base64 encoded basic auth credentials. """
|
||||||
|
|
||||||
|
# retrieve the auth header
|
||||||
|
api_key = connection.headers.get("X-AUTH-USER", None)
|
||||||
|
if not api_key:
|
||||||
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_session = settings.alchemy_config.provide_session(connection.app.state, connection.scope)
|
||||||
|
user_service = UserService(db_session)
|
||||||
|
device_service = KosyncDeviceService(db_session)
|
||||||
|
|
||||||
|
device = await device_service.get_by_api_key(api_key)
|
||||||
|
user = await user_service.get(device.user_id)
|
||||||
|
|
||||||
|
return AuthenticationResult(user=user, auth=None)
|
||||||
|
|
||||||
|
except PermissionDeniedException as exc:
|
||||||
|
print(exc)
|
||||||
|
raise NotAuthorizedException()
|
||||||
|
|
||||||
|
|
||||||
|
kosync_api_key_auth = DefineMiddleware(KosyncAuthenticationMiddleware)
|
||||||
36
backend/src/chitai/schemas/kosync.py
Normal file
36
backend/src/chitai/schemas/kosync.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncProgressUpdate(BaseModel):
|
||||||
|
"""Schema for uploading progress from KOReader."""
|
||||||
|
|
||||||
|
document: str
|
||||||
|
progress: str | None = None
|
||||||
|
percentage: float
|
||||||
|
device: str | None = None
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncProgressRead(BaseModel):
|
||||||
|
"""Schema for reading progress to KOReader."""
|
||||||
|
|
||||||
|
document: str
|
||||||
|
progress: str | None = None
|
||||||
|
percentage: float | None = None
|
||||||
|
device: str | None = None
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncDeviceRead(BaseModel):
|
||||||
|
"""Schema for reading device information."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
api_key: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncDeviceCreate(BaseModel):
|
||||||
|
"""Schema for creating a new device."""
|
||||||
|
|
||||||
|
name: str
|
||||||
@@ -6,3 +6,5 @@ from .author import AuthorService
|
|||||||
from .tag import TagService
|
from .tag import TagService
|
||||||
from .publisher import PublisherService
|
from .publisher import PublisherService
|
||||||
from .book_progress import BookProgressService
|
from .book_progress import BookProgressService
|
||||||
|
from .kosync_device import KosyncDeviceService
|
||||||
|
from .kosync_progress import KosyncProgressService
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from chitai.services import (
|
|||||||
TagService,
|
TagService,
|
||||||
AuthorService,
|
AuthorService,
|
||||||
PublisherService,
|
PublisherService,
|
||||||
|
KosyncDeviceService,
|
||||||
|
KosyncProgressService,
|
||||||
)
|
)
|
||||||
from chitai.config import settings
|
from chitai.config import settings
|
||||||
from chitai.services.filters.book import (
|
from chitai.services.filters.book import (
|
||||||
@@ -339,4 +341,13 @@ def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None
|
|||||||
|
|
||||||
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
|
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
|
||||||
return request.user
|
return request.user
|
||||||
|
|
||||||
|
|
||||||
|
async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m.User:
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
|
||||||
|
provide_kosync_device_service = create_service_provider(KosyncDeviceService)
|
||||||
|
|
||||||
|
provide_kosync_progress_service = create_service_provider(KosyncProgressService)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ class ProgressFilter(StatementFilter):
|
|||||||
m.BookProgress.completed == False,
|
m.BookProgress.completed == False,
|
||||||
m.BookProgress.completed.is_(None),
|
m.BookProgress.completed.is_(None),
|
||||||
),
|
),
|
||||||
m.BookProgress.progress > 0,
|
m.BookProgress.percentage > 0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,7 +154,6 @@ class ProgressFilter(StatementFilter):
|
|||||||
@dataclass
|
@dataclass
|
||||||
class FileFilter(StatementFilter):
|
class FileFilter(StatementFilter):
|
||||||
"""Filter books that are related to the given files."""
|
"""Filter books that are related to the given files."""
|
||||||
|
|
||||||
file_ids: list[int]
|
file_ids: list[int]
|
||||||
|
|
||||||
def append_to_statement(
|
def append_to_statement(
|
||||||
@@ -166,6 +165,16 @@ class FileFilter(StatementFilter):
|
|||||||
|
|
||||||
return super().append_to_statement(statement, model, *args, **kwargs)
|
return super().append_to_statement(statement, model, *args, **kwargs)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileHashFilter(StatementFilter):
|
||||||
|
file_hashes: list[str]
|
||||||
|
|
||||||
|
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT], *args, **kwargs) -> StatementTypeT:
|
||||||
|
statement = statement.where(
|
||||||
|
m.Book.files.any(m.FileMetadata.hash.in_(self.file_hashes))
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().append_to_statement(statement, model, *args, **kwargs)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CustomOrderBy(StatementFilter):
|
class CustomOrderBy(StatementFilter):
|
||||||
|
|||||||
35
backend/src/chitai/services/kosync_device.py
Normal file
35
backend/src/chitai/services/kosync_device.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import secrets
|
||||||
|
from chitai.database.models.kosync_device import KosyncDevice
|
||||||
|
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService, ModelDictT, schema_dump
|
||||||
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
|
|
||||||
|
class KosyncDeviceService(SQLAlchemyAsyncRepositoryService[KosyncDevice]):
|
||||||
|
"""Service for managing KOReader devices."""
|
||||||
|
|
||||||
|
API_KEY_LENGTH_IN_BYTES = 8
|
||||||
|
|
||||||
|
class Repo(SQLAlchemyAsyncRepository[KosyncDevice]):
|
||||||
|
""" Repository for KosyncDevice entities."""
|
||||||
|
|
||||||
|
model_type = KosyncDevice
|
||||||
|
|
||||||
|
repository_type = Repo
|
||||||
|
|
||||||
|
async def create(self, data: ModelDictT[KosyncDevice], **kwargs) -> KosyncDevice:
|
||||||
|
data = schema_dump(data)
|
||||||
|
data['api_key'] = self._generate_api_key()
|
||||||
|
return await super().create(data, **kwargs)
|
||||||
|
|
||||||
|
async def get_by_api_key(self, api_key: str) -> KosyncDevice:
|
||||||
|
return await self.get_one(KosyncDevice.api_key == api_key)
|
||||||
|
|
||||||
|
async def regenerate_api_key(self, device_id: int) -> KosyncDevice:
|
||||||
|
device = await self.get(device_id)
|
||||||
|
api_key = self._generate_api_key()
|
||||||
|
device.api_key = api_key
|
||||||
|
return await self.update(device)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_api_key(self) -> str:
|
||||||
|
return secrets.token_hex(self.API_KEY_LENGTH_IN_BYTES)
|
||||||
51
backend/src/chitai/services/kosync_progress.py
Normal file
51
backend/src/chitai/services/kosync_progress.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService
|
||||||
|
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
|
||||||
|
|
||||||
|
from chitai.database.models.kosync_progress import KosyncProgress
|
||||||
|
|
||||||
|
|
||||||
|
class KosyncProgressService(SQLAlchemyAsyncRepositoryService[KosyncProgress]):
|
||||||
|
"""Service for managing KOReader sync progress."""
|
||||||
|
|
||||||
|
class Repo(SQLAlchemyAsyncRepository[KosyncProgress]):
|
||||||
|
"""Repository for KosyncProgress entities."""
|
||||||
|
|
||||||
|
model_type = KosyncProgress
|
||||||
|
|
||||||
|
repository_type = Repo
|
||||||
|
|
||||||
|
async def get_by_document_hash(self, user_id: int, document: str) -> KosyncProgress | None:
|
||||||
|
"""Get progress for a specific document and user."""
|
||||||
|
return await self.get_one_or_none(
|
||||||
|
KosyncProgress.user_id == user_id,
|
||||||
|
KosyncProgress.document == document,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert_progress(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
book_id: int,
|
||||||
|
document: str,
|
||||||
|
progress: str | None,
|
||||||
|
percentage: float,
|
||||||
|
device: str | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> KosyncProgress:
|
||||||
|
"""Create or update progress for a document."""
|
||||||
|
existing = await self.get_by_document_hash(user_id, document)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"book_id": book_id,
|
||||||
|
"document": document,
|
||||||
|
"progress": progress,
|
||||||
|
"percentage": percentage,
|
||||||
|
"device": device,
|
||||||
|
"device_id": device_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return await self.update(data, item_id=existing.id)
|
||||||
|
return await self.create(data)
|
||||||
98
backend/tests/integration/test_file_hash.py
Normal file
98
backend/tests/integration/test_file_hash.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests for KOReader-compatible file hash generation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Known KOReader hashes for test files
|
||||||
|
TEST_FILES = {
|
||||||
|
"Moby Dick; Or, The Whale - Herman Melville.epub": {
|
||||||
|
"path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
|
||||||
|
"hash": "ceeef909ec65653ba77e1380dff998fb",
|
||||||
|
"content_type": "application/epub+zip",
|
||||||
|
},
|
||||||
|
"Calculus Made Easy - Silvanus Thompson.pdf": {
|
||||||
|
"path": Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
||||||
|
"hash": "ace67d512efd1efdea20f3c2436b6075",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("book_name",),
|
||||||
|
[(name,) for name in TEST_FILES.keys()],
|
||||||
|
)
|
||||||
|
async def test_upload_book_generates_correct_hash(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
book_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Test that uploading a book generates the correct KOReader-compatible hash."""
|
||||||
|
book_info = TEST_FILES[book_name]
|
||||||
|
file_content = book_info["path"].read_bytes()
|
||||||
|
|
||||||
|
files = [("files", (book_name, file_content, book_info["content_type"]))]
|
||||||
|
data = {"library_id": "1"}
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
book_data = response.json()
|
||||||
|
|
||||||
|
assert len(book_data["files"]) == 1
|
||||||
|
file_metadata = book_data["files"][0]
|
||||||
|
|
||||||
|
assert "hash" in file_metadata
|
||||||
|
assert file_metadata["hash"] == book_info["hash"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_file_to_book_generates_correct_hash(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""Test that adding a file to an existing book generates the correct hash."""
|
||||||
|
# Create a book with the first file
|
||||||
|
first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"]
|
||||||
|
first_content = first_book["path"].read_bytes()
|
||||||
|
|
||||||
|
files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))]
|
||||||
|
data = {"library_id": "1"}
|
||||||
|
|
||||||
|
create_response = await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
book_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
# Add the second file to the book
|
||||||
|
second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"]
|
||||||
|
second_content = second_book["path"].read_bytes()
|
||||||
|
|
||||||
|
add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))]
|
||||||
|
|
||||||
|
add_response = await authenticated_client.post(
|
||||||
|
f"/books/{book_id}/files",
|
||||||
|
files=add_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert add_response.status_code == 201
|
||||||
|
updated_book = add_response.json()
|
||||||
|
|
||||||
|
# Verify both files have correct hashes
|
||||||
|
assert len(updated_book["files"]) == 2
|
||||||
|
|
||||||
|
for file_metadata in updated_book["files"]:
|
||||||
|
assert "hash" in file_metadata
|
||||||
|
|
||||||
|
epub_file = next(f for f in updated_book["files"] if f["path"].endswith(".epub"))
|
||||||
|
pdf_file = next(f for f in updated_book["files"] if f["path"].endswith(".pdf"))
|
||||||
|
|
||||||
|
assert epub_file["hash"] == first_book["hash"]
|
||||||
|
assert pdf_file["hash"] == second_book["hash"]
|
||||||
Reference in New Issue
Block a user