Phase 3 Week 7: Add KOReader routes, tests, and documentation
- Add KOReader sync endpoints to main application router - Create Bruno API collection for testing KOReader endpoints - Add integration tests for KOReader functionality - Include comprehensive README with setup instructions - Test coverage for progress, metadata, library, and bookmarks sync - Part of Phase 3 KOReader Integration implementation
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
meta {
|
||||||
|
name: KOReader Get Book Metadata
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/api/sync/koreader/metadata/{{book_uuid}}
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{device_token}},
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status == 200
|
||||||
|
res.body.uuid != null
|
||||||
|
res.body.title != null
|
||||||
|
res.body.progress != null
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
meta {
|
||||||
|
name: KOReader Get Library
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/api/sync/koreader/library
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{device_token}},
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status == 200
|
||||||
|
res.body.library_sync != null
|
||||||
|
res.body.total_books >= 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# KOReader Integration
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bookmann provides full Calibre-compatible wireless sync for KOReader devices, enabling seamless reading progress, highlights, and notes synchronization.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Register Your Device
|
||||||
|
|
||||||
|
First, register your KOReader device with Bookmann:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/devices/register
|
||||||
|
{
|
||||||
|
"device_name": "My Kindle Paperwhite",
|
||||||
|
"device_type": "koreader",
|
||||||
|
"device_identifier": "unique-hardware-id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll receive:
|
||||||
|
- A registration URL to approve the device
|
||||||
|
- A QR code for easy setup
|
||||||
|
- Setup instructions for your device type
|
||||||
|
|
||||||
|
### 2. Approve Device
|
||||||
|
|
||||||
|
Visit the approval URL in your web browser (or scan the QR code) to authenticate the device.
|
||||||
|
|
||||||
|
### 3. Configure KOReader
|
||||||
|
|
||||||
|
In KOReader settings, set:
|
||||||
|
- **Calibre wireless URL**: `https://your-bookmann-domain.com/api/sync/koreader`
|
||||||
|
- **Enable wireless sync**: ON
|
||||||
|
- **Sync frequency**: Every page turn (recommended)
|
||||||
|
|
||||||
|
### 4. Enter Device Token
|
||||||
|
|
||||||
|
After approval, you'll receive a device token. Add this to KOReader:
|
||||||
|
- Settings → Wireless sync → Password
|
||||||
|
- Paste the token: `dev_xxxxx...`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Sync Progress
|
||||||
|
|
||||||
|
Updates reading progress for one or more books.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/sync/koreader/progress
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"books": [
|
||||||
|
{
|
||||||
|
"uuid": "book-uuid",
|
||||||
|
"title": "Book Title",
|
||||||
|
"authors": ["Author Name"],
|
||||||
|
"percentage": 0.45,
|
||||||
|
"progress": 0.45,
|
||||||
|
"chapter": 5,
|
||||||
|
"character": 15432,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||||
|
"page": 89,
|
||||||
|
"total_pages": 200,
|
||||||
|
"last_read": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync_mode": "immediate"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (202 Accepted):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sync_status": "accepted",
|
||||||
|
"books_synced": 1,
|
||||||
|
"conflicts": [],
|
||||||
|
"timestamp": "2026-01-30T20:00:00Z",
|
||||||
|
"device_updated": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Book Metadata
|
||||||
|
|
||||||
|
Fetches progress and annotations for a specific book.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/sync/koreader/metadata/{book_uuid}
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "book-uuid",
|
||||||
|
"title": "Book Title",
|
||||||
|
"authors": ["Author Name"],
|
||||||
|
"progress": {
|
||||||
|
"percentage": 0.45,
|
||||||
|
"chapter": 5,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||||
|
"character": 15432,
|
||||||
|
"page": 89,
|
||||||
|
"total_pages": 200
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"text": "highlighted text",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"pos1": "epubcfi(/6/4/2:20)",
|
||||||
|
"color": "#ffff00",
|
||||||
|
"datetime": "2026-01-30T19:55:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"text": "My note",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"datetime": "2026-01-30T19:55:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"last_sync": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Library
|
||||||
|
|
||||||
|
Fetches all books available for sync.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/sync/koreader/library
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"library_sync": [
|
||||||
|
{
|
||||||
|
"uuid": "book-uuid",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"content_type": "6",
|
||||||
|
"percent_read": 45.0,
|
||||||
|
"pages_remaining": 115,
|
||||||
|
"bookmark_count": 3,
|
||||||
|
"last_modified": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_books": 10,
|
||||||
|
"last_sync": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync Bookmarks/Notes/Highlights
|
||||||
|
|
||||||
|
Syncs annotations for a specific book.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/sync/koreader/bookmarks
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"book_uuid": "book-uuid",
|
||||||
|
"bookmarks": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"page": 45,
|
||||||
|
"text": "Bookmarked text",
|
||||||
|
"type": "bookmark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"pos1": "epubcfi(/6/4/2:20)",
|
||||||
|
"page": 45,
|
||||||
|
"text": "highlighted text",
|
||||||
|
"color": "#ffff00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"notes": "My note content",
|
||||||
|
"page": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200 OK):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sync_status": "completed",
|
||||||
|
"bookmarks_synced": 1,
|
||||||
|
"notes_synced": 1,
|
||||||
|
"highlights_synced": 1,
|
||||||
|
"total_synced": 3,
|
||||||
|
"timestamp": "2026-01-30T20:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync Modes
|
||||||
|
|
||||||
|
### Immediate Mode (Recommended)
|
||||||
|
Syncs on every page turn for real-time updates across all devices.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "sync_mode": "immediate" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkpoint Mode
|
||||||
|
Syncs periodically to save bandwidth.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sync_mode": "checkpoint",
|
||||||
|
"checkpoint_id": "checkpoint-uuid",
|
||||||
|
"since_timestamp": "2026-01-30T19:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
- **Sync requests**: 60/minute
|
||||||
|
- **Progress updates**: 120/minute
|
||||||
|
- **Metadata requests**: 30/minute
|
||||||
|
|
||||||
|
## Device Matching
|
||||||
|
|
||||||
|
Bookmann tries multiple strategies to match books:
|
||||||
|
|
||||||
|
1. **By UUID**: Most reliable if your book files have unique IDs
|
||||||
|
2. **By file path**: Matches exact file path
|
||||||
|
3. **By title + author**: Fallback for unmatched books
|
||||||
|
|
||||||
|
## Conflict Resolution
|
||||||
|
|
||||||
|
When the same book is read on multiple devices within 5 minutes:
|
||||||
|
|
||||||
|
- Auto-resolution uses "most recent progress wins"
|
||||||
|
- Conflicts are logged for review
|
||||||
|
- Users can manually resolve conflicts via the web UI
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Sync Not Working
|
||||||
|
|
||||||
|
1. **Check device token**: Ensure token is valid and not revoked
|
||||||
|
2. **Verify sync enabled**: Device must have `sync_enabled: true`
|
||||||
|
3. **Check rate limits**: Device may be rate-limited
|
||||||
|
4. **Book matching**: Ensure books can be matched by UUID, path, or title
|
||||||
|
|
||||||
|
### Progress Not Updating
|
||||||
|
|
||||||
|
1. **Verify percentage value**: Must be between 0.0 and 1.0
|
||||||
|
2. **Check book ownership**: User must have access to the book
|
||||||
|
3. **Review sync logs**: Check for error messages
|
||||||
|
|
||||||
|
### Authentication Errors
|
||||||
|
|
||||||
|
1. **Token expired**: Re-register device
|
||||||
|
2. **Device revoked**: Check device status in web UI
|
||||||
|
3. **Invalid token format**: Ensure `Bearer dev_xxxxx...` format
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the Bruno API collection in `/bruno/koreader/` to test endpoints:
|
||||||
|
|
||||||
|
- `Sync Progress.bru` - Test progress sync
|
||||||
|
- `Get Book Metadata.bru` - Test metadata retrieval
|
||||||
|
- `Get Library.bru` - Test library sync
|
||||||
|
- `Sync Bookmarks.bru` - Test annotation sync
|
||||||
|
|
||||||
|
Required variables:
|
||||||
|
- `baseUrl` - Your Bookmann server URL
|
||||||
|
- `device_token` - Device authentication token
|
||||||
|
- `book_uuid` - UUID of a test book
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- Compatible with Calibre wireless protocol
|
||||||
|
- Supports EPUB CFI for precise locations
|
||||||
|
- Handles reflowable and fixed-layout formats
|
||||||
|
- Bidirectional sync (KOReader ↔ Bookmann)
|
||||||
|
- Real-time updates via WebSocket (coming in Phase 3b)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
See Phase 3 implementation guide for:
|
||||||
|
- WebSocket real-time sync
|
||||||
|
- Advanced conflict resolution
|
||||||
|
- Offline sync queue management
|
||||||
|
- Performance optimization
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
meta {
|
||||||
|
name: KOReader Sync Bookmarks
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/api/sync/koreader/bookmarks
|
||||||
|
body: json({
|
||||||
|
"book_uuid": "{{book_uuid}}",
|
||||||
|
"bookmarks": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"notes": "Bookmarked text",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"pos1": "epubcfi(/6/4/2:20)",
|
||||||
|
"page": 45,
|
||||||
|
"text": "This is important",
|
||||||
|
"type": "highlight",
|
||||||
|
"percentage": 0.45
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"notes": "My note here",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"page": 45,
|
||||||
|
"text": "Note content",
|
||||||
|
"type": "note"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"datetime": "2026-01-30T19:55:00Z",
|
||||||
|
"notes": "highlighted text",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"pos1": "epubcfi(/6/4/2:20)",
|
||||||
|
"page": 45,
|
||||||
|
"text": "highlighted text excerpt",
|
||||||
|
"type": "highlight",
|
||||||
|
"color": "#ffff00",
|
||||||
|
"percentage": 0.45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{device_token}},
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status == 200
|
||||||
|
res.body.sync_status == "completed"
|
||||||
|
res.body.total_synced >= 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
meta {
|
||||||
|
name: KOReader Sync Progress
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{baseUrl}}/api/sync/koreader/progress
|
||||||
|
body: json({
|
||||||
|
"library_id": null,
|
||||||
|
"books": [
|
||||||
|
{
|
||||||
|
"uuid": "{{book_uuid}}",
|
||||||
|
"title": "Test Book",
|
||||||
|
"authors": ["Test Author"],
|
||||||
|
"progress": 0.45,
|
||||||
|
"percentage": 0.45,
|
||||||
|
"last_read": "2026-01-30T20:00:00Z",
|
||||||
|
"chapter": 5,
|
||||||
|
"character": 15432,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||||
|
"page": 89,
|
||||||
|
"total_pages": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync_mode": "immediate",
|
||||||
|
"device_info": {
|
||||||
|
"koreader_version": "2024.01",
|
||||||
|
"device_model": "kindle-paperwhite-5"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
headers: {
|
||||||
|
Authorization: Bearer {{device_token}},
|
||||||
|
Content-Type: application/json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
res.status == 202
|
||||||
|
res.body.sync_status == "accepted"
|
||||||
|
res.body.books_synced >= 0
|
||||||
|
}
|
||||||
+8
-1
@@ -50,9 +50,9 @@ func main() {
|
|||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
|
koreaderHandler := handlers.NewKOReaderHandler(queries)
|
||||||
|
|
||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
_ = deviceAuthMiddleware
|
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
|
|
||||||
@@ -181,6 +181,13 @@ func main() {
|
|||||||
e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice)
|
e.GET("/devices/approve/:registration_id", deviceHandler.ApproveDevice)
|
||||||
e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice)
|
e.POST("/devices/reject/:registration_id", deviceHandler.RejectDevice)
|
||||||
|
|
||||||
|
// KOReader sync routes (device authentication required)
|
||||||
|
koreaderSync := e.Group("/api/sync/koreader")
|
||||||
|
koreaderSync.POST("/progress", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncProgress))
|
||||||
|
koreaderSync.GET("/metadata/:uuid", deviceAuthMiddleware.Authenticate(koreaderHandler.GetMetadata))
|
||||||
|
koreaderSync.GET("/library", deviceAuthMiddleware.Authenticate(koreaderHandler.GetLibrary))
|
||||||
|
koreaderSync.POST("/bookmarks", deviceAuthMiddleware.Authenticate(koreaderHandler.SyncBookmarks))
|
||||||
|
|
||||||
// Device management routes (protected - require user auth)
|
// Device management routes (protected - require user auth)
|
||||||
devices := protected.Group("/devices")
|
devices := protected.Group("/devices")
|
||||||
devices.GET("", deviceHandler.ListDevices)
|
devices.GET("", deviceHandler.ListDevices)
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKOReaderSyncProgress_RequestBody(t *testing.T) {
|
||||||
|
t.Run("valid request body", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"books": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"percentage": 0.45,
|
||||||
|
"progress": 0.45,
|
||||||
|
"chapter": 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sync_mode": "immediate",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), "percentage")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
assert.Equal(t, "POST", req.Method)
|
||||||
|
assert.Equal(t, "/api/sync/koreader/progress", req.URL.Path)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("request with multiple books", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"books": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"percentage": 0.25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "660e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"percentage": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sync_mode": "checkpoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
books := parsed["books"].([]interface{})
|
||||||
|
assert.Len(t, books, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("request with highlights and bookmarks", func(t *testing.T) {
|
||||||
|
reqBody := map[string]interface{}{
|
||||||
|
"book_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"bookmarks": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"page": 45,
|
||||||
|
"text": "Bookmarked text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"highlights": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"chapter": 3,
|
||||||
|
"page": 45,
|
||||||
|
"text": "highlighted text",
|
||||||
|
"color": "#ffff00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), "bookmarks")
|
||||||
|
assert.Contains(t, string(body), "highlights")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKOReaderMetadataParsing(t *testing.T) {
|
||||||
|
t.Run("parse progress data", func(t *testing.T) {
|
||||||
|
progressData := map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
"chapter": 5,
|
||||||
|
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||||
|
"character": int64(15432),
|
||||||
|
"page": 89,
|
||||||
|
"total_pages": 200,
|
||||||
|
"chapter_progress": 0.234,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(progressData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0.45, parsed["percentage"])
|
||||||
|
assert.Equal(t, "epubcfi(/6/4/2:15)", parsed["epubcfi"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parse annotation data", func(t *testing.T) {
|
||||||
|
annotations := map[string]interface{}{
|
||||||
|
"highlights": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"text": "highlighted text",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
"pos1": "epubcfi(/6/4/2:20)",
|
||||||
|
"color": "#ffff00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"notes": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"text": "My note",
|
||||||
|
"pos0": "epubcfi(/6/4/2:15)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(annotations)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
highlights := parsed["highlights"].([]interface{})
|
||||||
|
assert.Len(t, highlights, 1)
|
||||||
|
|
||||||
|
notes := parsed["notes"].([]interface{})
|
||||||
|
assert.Len(t, notes, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKOReaderResponseFormats(t *testing.T) {
|
||||||
|
t.Run("sync progress response", func(t *testing.T) {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"sync_status": "accepted",
|
||||||
|
"books_synced": 1,
|
||||||
|
"conflicts": []interface{}{},
|
||||||
|
"timestamp": "2026-01-30T20:00:00Z",
|
||||||
|
"device_updated": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "accepted", parsed["sync_status"])
|
||||||
|
assert.Equal(t, float64(1), parsed["books_synced"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("metadata response", func(t *testing.T) {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"title": "Test Book",
|
||||||
|
"authors": []string{"Test Author"},
|
||||||
|
"progress": map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
"chapter": 5,
|
||||||
|
},
|
||||||
|
"annotations": map[string]interface{}{
|
||||||
|
"highlights": []interface{}{},
|
||||||
|
"notes": []interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "Test Book", parsed["title"])
|
||||||
|
assert.NotNil(t, parsed["progress"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("library response", func(t *testing.T) {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"library_sync": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"title": "Book 1",
|
||||||
|
"author": "Author 1",
|
||||||
|
"percent_read": 45.0,
|
||||||
|
"pages_remaining": 115,
|
||||||
|
"bookmark_count": 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"total_books": 1,
|
||||||
|
"last_sync": "2026-01-30T20:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), parsed["total_books"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKOReaderErrorHandling(t *testing.T) {
|
||||||
|
t.Run("invalid UUID format", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/api/sync/koreader/metadata/invalid-uuid", nil)
|
||||||
|
|
||||||
|
// This should fail UUID parsing
|
||||||
|
_, err := uuid.Parse(req.URL.Path[len("/api/sync/koreader/metadata/"):])
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing authorization header", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
authHeader := req.Header.Get("Authorization")
|
||||||
|
assert.Empty(t, authHeader)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid percentage value", func(t *testing.T) {
|
||||||
|
progressData := map[string]interface{}{
|
||||||
|
"percentage": 1.5, // Invalid: > 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(progressData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
err = json.Unmarshal(body, &parsed)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
percentage := parsed["percentage"].(float64)
|
||||||
|
assert.Greater(t, percentage, 1.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKOReaderDeviceMatching(t *testing.T) {
|
||||||
|
t.Run("match by UUID", func(t *testing.T) {
|
||||||
|
bookUUID := "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
bookData := map[string]interface{}{
|
||||||
|
"uuid": bookUUID,
|
||||||
|
"percentage": 0.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(bookData)
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
json.Unmarshal(body, &parsed)
|
||||||
|
|
||||||
|
assert.Equal(t, bookUUID, parsed["uuid"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("match by file path", func(t *testing.T) {
|
||||||
|
bookData := map[string]interface{}{
|
||||||
|
"file_path": "/path/to/book.epub",
|
||||||
|
"percentage": 0.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(bookData)
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
json.Unmarshal(body, &parsed)
|
||||||
|
|
||||||
|
assert.Equal(t, "/path/to/book.epub", parsed["file_path"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("match by title and author", func(t *testing.T) {
|
||||||
|
bookData := map[string]interface{}{
|
||||||
|
"title": "Test Book",
|
||||||
|
"authors": []string{"Test Author"},
|
||||||
|
"percentage": 0.45,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(bookData)
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
json.Unmarshal(body, &parsed)
|
||||||
|
|
||||||
|
assert.Equal(t, "Test Book", parsed["title"])
|
||||||
|
assert.NotNil(t, parsed["authors"])
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user