Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8aa941d
refactor(bookfusion): clean up integration for ambient UX
serabi Apr 5, 2026
2687103
test(bookfusion): rewrite tests for new API contracts
serabi Apr 5, 2026
0c96e6b
fix(template): close comment tag in reading_detail.html
serabi Apr 5, 2026
04367ba
fix: restore BookFusion logo files
serabi Apr 5, 2026
28c5579
fix: remove matched_abs_id from upload (model no longer has field)
serabi Apr 5, 2026
7cf575c
fix(bookfusion): handle multiple ID formats when linking highlights
serabi Apr 5, 2026
0271ebe
fix(reading): render markdown journal entries
serabi Apr 5, 2026
63ac1ba
chore(test): pin pytest-asyncio
serabi Apr 5, 2026
6e5b02e
fix(kosync): normalize UTC timestamp response
serabi Apr 6, 2026
b890165
fix(review): address PR 56 bot feedback
serabi Apr 6, 2026
db819d6
fix(review): harden reading detail JS handling
serabi Apr 6, 2026
b9081fb
chore: align Python dependencies and Pyright config
serabi Apr 25, 2026
479ace6
fix: restore Alembic merge revision to resolve multiple heads
serabi Apr 25, 2026
566f583
refactor: split web server bootstrap into setup, runtime, and templat…
serabi Apr 25, 2026
f55eafb
refactor: extract sync manager startup and cache cleanup services
serabi Apr 25, 2026
b4480f4
refactor: extract KoSync progress GET/PUT flow into dedicated service
serabi Apr 25, 2026
17ba760
refactor: standardize route error responses and remove module-level r…
serabi Apr 25, 2026
a85ba72
refactor: add shared repository helpers and improve database facade e…
serabi Apr 25, 2026
f2add3b
refactor: extract shared JSON retry helper and reuse it in Hardcover …
serabi Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .opencode/plans/bookfusion-client-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# BookFusion Client Unit Tests Plan

## Overview

Add comprehensive unit tests for `src/api/bookfusion_client.py` — the only file in the BookFusion integration with zero test coverage. The existing `test_bookfusion_routes.py` only tests blueprint routes with mocked clients.

## Test File: `tests/test_bookfusion_client.py`

### Test Classes (75+ tests)

#### 1. `TestBuildMultipart` (12 tests)
Tests the Qt-compatible multipart/form-data builder — the most fragile code in the entire integration.

- `test_single_text_field` — basic text field with correct boundary
- `test_single_file_field` — file field with filename
- `test_multiple_text_fields` — multiple text fields separated correctly
- `test_mixed_text_and_file_fields` — text + file in same body
- `test_no_content_type_on_text_fields` — Qt format omits Content-Type
- `test_no_content_type_on_file_fields` — file parts also get no Content-Type
- `test_crlf_line_endings` — must use \r\n not \n
- `test_closing_boundary` — proper `--boundary--` terminator
- `test_binary_file_data` — all 256 byte values pass through
- `test_empty_text_value` — empty string field handled
- `test_unicode_text_value` — UTF-8 encoding preserved
- `test_deduplication_of_boundaries` — exactly N boundaries for N fields

#### 2. `TestCalibreDigest` (7 tests)
Tests SHA-256 digest matching the Calibre plugin's `calculate_digest`.

- `test_known_digest` — sha256(len + null + content)
- `test_empty_bytes` — edge case for zero-length file
- `test_large_file_chunks` — 100KB file (>64k chunk boundary)
- `test_exactly_64k_boundary` — exactly 65536 bytes
- `test_binary_data` — null bytes and high bytes
- `test_different_data_different_digests` — uniqueness
- `test_same_data_same_digest` — determinism

#### 3. `TestCalibreAuthHeader` (3 tests)
- `test_format` — `Basic base64(key:)`
- `test_empty_key` — `Basic base64(:)`
- `test_key_with_special_chars` — URL-safe chars in key

#### 4. `TestCalibreHeaders` (3 tests)
- `test_required_headers` — User-Agent, Authorization, Accept
- `test_extra_headers_merged` — Content-Type passthrough
- `test_extra_does_not_override_auth` — auth header always from key

#### 5. `TestParseFrontmatterTitle` (8 tests)
- `test_simple_title`, `test_quoted_title`, `test_single_quoted_title`
- `test_multiline_frontmatter`, `test_none_input`, `test_empty_input`
- `test_no_title_field`, `test_title_with_extra_whitespace`

#### 6. `TestParseFrontmatter` (6 tests)
- `test_all_fields` — title, author, tags, series
- `test_none_input`, `test_empty_input`
- `test_authors_plural` — "authors:" vs "author:"
- `test_missing_fields`, `test_quoted_values`

#### 7. `TestParseHighlightDate` (6 tests)
- `test_valid_date` — standard format
- `test_date_with_extra_content` — embedded in highlight markdown
- `test_no_date`, `test_invalid_date_format`, `test_empty_string`
- `test_extra_whitespace`

#### 8. `TestParseHighlightQuote` (6 tests)
- `test_single_quote`, `test_multiple_quote_lines`
- `test_mixed_content` — quote among other markdown
- `test_no_quote`, `test_empty_quote_line`, `test_empty_string`

#### 9. `TestBookFusionClientConfig` (6 tests)
- `test_is_configured_false_when_disabled` — BOOKFUSION_ENABLED=false
- `test_is_configured_true_with_highlights_key`
- `test_is_configured_true_with_upload_key`
- `test_is_configured_true_with_both_keys`
- `test_is_configured_ignores_enabled_false_with_keys`
- `test_is_configured_no_env_vars`

#### 10. `TestBookFusionClientConnection` (6 tests)
- `test_check_connection_success` — HTTP 200
- `test_check_connection_http_error` — HTTP 401
- `test_check_connection_no_key` — missing key
- `test_check_connection_network_error` — ConnectionError
- `test_check_upload_connection_success`
- `test_check_upload_connection_no_key`

#### 11. `TestBookFusionClientUpload` (7 tests)
Full 3-step upload flow coverage:

- `test_upload_book_no_api_key` — early return None
- `test_upload_book_already_exists` — check_exists returns data, skips upload
- `test_upload_book_init_fails` — POST /uploads/init returns 500
- `test_upload_book_init_success_no_s3_url` — init returns null URL
- `test_upload_book_full_flow` — init → S3 → finalize, all 200s
- `test_upload_book_s3_upload_fails` — S3 returns 500
- `test_upload_book_finalize_fails` — finalize returns 400

#### 12. `TestBookFusionClientLibrary` (5 tests)
- `test_fetch_library_no_api_key` — returns []
- `test_fetch_library_single_page` — <100 books, single request
- `test_fetch_library_pagination` — 101 books, 2 requests
- `test_fetch_library_http_error` — returns []
- `test_fetch_library_network_error` — returns []

#### 13. `TestBookFusionClientHighlights` (8 tests)
- `test_fetch_highlights_no_key_raises` — ValueError
- `test_fetch_highlights_success` — proper response structure
- `test_fetch_highlights_http_error` — raises HTTPError
- `test_sync_all_highlights_basic` — single page, saves highlights + books
- `test_sync_all_highlights_pagination` — multi-page sync
- `test_sync_all_highlights_pagination_loop_detection` — stops on same cursor
- `test_sync_all_highlights_skips_non_book_pages` — filters by type
- `test_sync_all_highlights_library_fetch_failure` — sync succeeds even if library fetch fails

#### 14. `TestBookFusionClientCheckExists` (4 tests)
- `test_no_api_key_returns_none`
- `test_existing_book` — HTTP 200 with data
- `test_nonexistent_book` — HTTP 404
- `test_network_error_returns_none`

## Testing Approach

- **Real client instances**: Uses `BookFusionClient()` directly, not Mock, so actual logic is exercised
- **Mocked network**: `requests.Session` patched at class level
- **Env var isolation**: `patch.dict(os.environ, ...)` for each test
- **Follows project patterns**: Matches `test_grimmory_client.py` and `test_storyteller_api_client.py` conventions

## What This Catches

1. Multipart encoding regressions that would break all book uploads
2. Digest calculation errors causing false "already exists" matches
3. Auth header format changes
4. Frontmatter parsing edge cases (quoted titles, missing fields)
5. Highlight date/quote extraction failures
6. Pagination loops in sync
7. All error paths in the 3-step upload flow
8. Network failure resilience
28 changes: 28 additions & 0 deletions alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Remove matched_abs_id columns from BookFusion tables.

Revision ID: r5s6t7u8v9w0
Revises: p6q7r8s9t0u1
Create Date: 2026-04-05
"""

import sqlalchemy as sa

from alembic import op
Comment on lines +8 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix import ordering to pass linting.

The imports are unsorted, causing the pipeline to fail.

Proposed fix
-import sqlalchemy as sa
 from alembic import op
+import sqlalchemy as sa

As per coding guidelines for alembic/versions/**: Migration files should include both upgrade() and downgrade() functions. ✓ Both are present.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import sqlalchemy as sa
from alembic import op
from alembic import op
import sqlalchemy as sa
🧰 Tools
🪛 GitHub Actions: Lint

[error] 8-8: Ruff check failed (I001): Import block is un-sorted or un-formatted.

🪛 GitHub Check: ruff

[failure] 8-9: ruff (I001)
alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py:8:1: I001 Import block is un-sorted or un-formatted
help: Organize imports

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py` around
lines 8 - 9, Imports are unsorted; swap and sort the two top-level imports so
third-party modules are alphabetized (place "from alembic import op" before
"import sqlalchemy as sa"), ensure there's a single blank line after the import
block if required by project linting, then run the project's import linter/isort
to confirm the ordering; verify upgrade() and downgrade() remain unchanged.


revision = "r5s6t7u8v9w0"
down_revision = "p6q7r8s9t0u1"
branch_labels = None
depends_on = None


def upgrade():
with op.batch_alter_table("bookfusion_books") as batch_op:
batch_op.drop_column("matched_abs_id")

with op.batch_alter_table("bookfusion_highlights") as batch_op:
batch_op.drop_column("matched_abs_id")


def downgrade():
op.add_column("bookfusion_books", sa.Column("matched_abs_id", sa.String(255), nullable=True))
op.add_column("bookfusion_highlights", sa.Column("matched_abs_id", sa.String(255), nullable=True))
21 changes: 21 additions & 0 deletions alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""merge grimmory rename and bookfusion cleanup heads

Revision ID: t9u0v1w2x3y4
Revises: 5308a8e2c930, r5s6t7u8v9w0
Create Date: 2026-04-25
"""

from collections.abc import Sequence

revision: str = "t9u0v1w2x3y4"
down_revision: str | Sequence[str] | None = ("5308a8e2c930", "r5s6t7u8v9w0")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Merge parallel heads into a single linear history."""


def downgrade() -> None:
"""Unmerge the parallel heads."""
25 changes: 25 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
[project]
name = "pagekeeper"
requires-python = ">=3.11"
dependencies = [
"requests==2.32.5",
"schedule==1.2.2",
"faster-whisper==1.2.1",
"deepgram-sdk==6.0.1",
"ebooklib==0.20",
"epubcfi==0.0.6",
"beautifulsoup4==4.14.3",
"rapidfuzz==3.14.3",
"fuzzysearch==0.8.1",
"tqdm==4.67.3",
"gTTS==2.5.4",
"flask==3.1.3",
"nh3==0.3.1",
"lxml==5.4.0",
"defusedxml==0.7.1",
"dependency-injector==4.48.3",
"sqlalchemy==2.0.48",
"alembic==1.18.4",
"python-socketio[client]==5.16.1",
"h11==0.16.0",
"mkdocs-material==9.7.4",
"mistune==3.2.0",
"pytest-asyncio==1.3.0",
]

[tool.pytest.ini_options]
markers = ["docker: tests requiring Docker (epubcfi, ffmpeg)"]
Expand Down
25 changes: 25 additions & 0 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"include": [
"src",
"tests"
],
"typeCheckingMode": "basic",
"reportMissingImports": "none",
"reportMissingModuleSource": "none",
"reportAttributeAccessIssue": "none",
"reportArgumentType": "none",
"reportOptionalMemberAccess": "none",
"reportOptionalOperand": "none",
"reportOptionalSubscript": "none",
"reportAssignmentType": "none",
"reportCallIssue": "none",
"reportIncompatibleMethodOverride": "none",
"executionEnvironments": [
{
"root": ".",
"extraPaths": [
"."
]
}
]
}
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ alembic==1.18.4
python-socketio[client]==5.16.1
h11==0.16.0
mkdocs-material==9.7.4
mistune==3.2.0
pytest-asyncio==1.3.0
38 changes: 16 additions & 22 deletions src/api/hardcover_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
import time
from datetime import date

import requests

Check failure on line 20 in src/api/hardcover_client.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (F401)

src/api/hardcover_client.py:20:8: F401 `requests` imported but unused help: Remove unused import: `requests`

Check failure on line 20 in src/api/hardcover_client.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (F401)

src/api/hardcover_client.py:20:8: F401 `requests` imported but unused help: Remove unused import: `requests`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

CI failure: requests is now unused.

Lint is breaking on F401. Now that the POST goes through post_json_with_retries, the local requests import isn't referenced anywhere in this module.

🔧 Fix
-import requests
-
 from src.api.http_client_base import JsonHttpClientBase
🧰 Tools
🪛 GitHub Actions: Lint

[error] 20-20: ruff check failed: F401 requests imported but unused

🪛 GitHub Check: ruff

[failure] 20-20: ruff (F401)
src/api/hardcover_client.py:20:8: F401 requests imported but unused
help: Remove unused import: requests

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/hardcover_client.py` at line 20, The file currently imports requests
but no longer uses it (causing F401); remove the unused import statement from
src/api/hardcover_client.py (delete the top-level "import requests") and ensure
any HTTP logic uses the existing post_json_with_retries function or other
referenced helpers instead of adding a new requests usage so no new unused
imports are introduced.


from src.api.http_client_base import JsonHttpClientBase
from src.utils.string_utils import calculate_similarity, clean_book_title

logger = logging.getLogger(__name__)


class HardcoverClient:
class HardcoverClient(JsonHttpClientBase):
def __init__(self):
self.api_url = "https://api.hardcover.app/v1/graphql"
self.user_id = None
Expand Down Expand Up @@ -88,33 +89,26 @@
self._rate_limit()

try:
r = requests.post(
payload = {"query": query, "variables": variables or {}}

def _mark_retry(_attempt, _response):
with self._rate_lock:
self._last_request_time = time.monotonic()

r = self.post_json_with_retries(
self.api_url,
json={"query": query, "variables": variables or {}},
json_body=payload,
headers=self.headers,
timeout=20,
max_retries=3,
retry_statuses={429},
backoff_seconds=5,
retry_label="Hardcover request",
on_retry=_mark_retry,
)

# Handle rate limiting (429) with exponential backoff
max_retries = 3
backoff = 5
attempt = 0
while r.status_code == 429 and attempt < max_retries:
attempt += 1
logger.warning(f"Hardcover rate limit hit (429), retry {attempt}/{max_retries} after {backoff}s")
time.sleep(backoff)
with self._rate_lock:
self._last_request_time = time.monotonic()
r = requests.post(
self.api_url,
json={"query": query, "variables": variables or {}},
headers=self.headers,
timeout=20,
)
backoff *= 2

if r.status_code == 429:
logger.error(f"Hardcover rate limit persisted after {max_retries} retries, giving up")
logger.error("Hardcover rate limit persisted after 3 retries, giving up")
return None

if r.status_code == 200:
Expand Down
Loading
Loading