Files
bookhoard/internal/handlers/koreader_test.go
T
john-okeefe 1f5c5a28bd feat(sync): propagate KOReader annotation deletions + history API
KOReader push (processBookAnnotations) accepts deleted_highlights and
deleted_bookmarks arrays of dedup keys and tombstones the matching rows,
after the upserts so a key present in both lists resolves to 'deleted'
(the newer intent). Deletions remain soft: rows stay restorable from the
history and echo to other devices as tombstones on their next pull. A
stale device replay of the annotation cannot resurrect the tombstone —
device pushes carry no modification timestamp, so the save loses to the
delete. Absence from these arrays is never a delete, keeping category
toggles safe.

New annotation-history endpoints (annotation_history.go, media.go):
  GET    /api/media-items/:id/annotations/deleted
  POST   /api/media-items/:id/annotations/:annotationId/restore
  DELETE /api/media-items/:id/annotations/:annotationId
All scoped to the authenticated user and the route's book; the DELETE is
the permanent purge (annotation_type required in query or body).

MediaDetail gains DeletedAnnotations, populated by the book page route
via the shared DeletedAnnotationsForBook builder, so the server-rendered
history ships with the page instead of requiring a client round-trip.

Binding tests cover the plugin's exact wire shape and the legacy
plugin case (arrays omitted -> empty).
2026-08-22 13:16:43 -04:00

53 lines
1.6 KiB
Go

package handlers
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
// The device pushes deletions as dedup-key arrays on the progress request.
// Verify the wire shape the plugin sends (lua json.encode of
// { deleted_highlights = { { dedup_key = "..." } } }) binds correctly.
func TestKOReaderProgressRequest_DeletedAnnotationsBinding(t *testing.T) {
payload := `{
"books": [{
"sha256": "d1b1c6123d6206017b40798744ed994f00803b97d22ce51bea32e95e1ce7a164",
"title": "1984",
"percentage": 0.42,
"deleted_highlights": [
{ "dedup_key": "abc123" },
{ "dedup_key": "def456" }
],
"deleted_bookmarks": [
{ "dedup_key": "789xyz" }
]
}]
}`
var req KOReaderProgressRequest
err := json.Unmarshal([]byte(payload), &req)
assert.NoError(t, err)
assert.Len(t, req.Books, 1)
book := req.Books[0]
assert.Len(t, book.DeletedHighlights, 2)
assert.Equal(t, "abc123", book.DeletedHighlights[0].DedupKey)
assert.Equal(t, "def456", book.DeletedHighlights[1].DedupKey)
assert.Len(t, book.DeletedBookmarks, 1)
assert.Equal(t, "789xyz", book.DeletedBookmarks[0].DedupKey)
}
// A request without the arrays (older plugins) must bind with them empty —
// deletion propagation is strictly opt-in per push.
func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
payload := `{"books": [{"sha256": "x", "title": "t", "percentage": 0.1}]}`
var req KOReaderProgressRequest
err := json.Unmarshal([]byte(payload), &req)
assert.NoError(t, err)
assert.Empty(t, req.Books[0].DeletedHighlights)
assert.Empty(t, req.Books[0].DeletedBookmarks)
}