Files
bookhoard/cmd/server/tests/contract_pins_test.go
T
John O'Keefe 7d698ea0d8 fix(annotations): bookmark and note PUTs land through the sync service (LWW by id)
H3 census bugs #2 and #3: both web PUTs used plain queries that
bypassed the sync-aware layer entirely — no LWW, no source bookkeeping,
and locator fields the client couldn't even send. The highlight PUT was
the only one already routed (via HighlightID); the other two now mirror
it:

- SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID:
  id-targeted resolution reads the exact row, verifies ownership,
  LWWs under its stored dedup key, and resurrects tombstones when the
  edit is newer (same doctrine and comment structure as the highlight
  path).
- Fields the PUT omits are coalesced from the stored row before the
  full-column sync update — the legacy web PUT sends only
  title/notes/position, and zeroing cfi/percentage/page on every edit
  would have been data loss. Verified both directions by
  TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit
  lands).
- The bookmark PUT's wire DTO gains the create endpoint's locator keys
  as optional fields (documented additive request keys in
  contract_pins.md); the note PUT keeps its content+position wire.

UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled
(the generated queries stay until the H4 query sweep).
2026-10-17 15:53:12 -04:00

1309 lines
55 KiB
Go

package main
// Contract pins (hardening batch H0) — these tests freeze the WIRE SHAPES
// of the load-bearing surfaces: the JSON key SETS, not the values. The
// canonical-types plan (hardening-canonical-types.md) must keep them green
// through H1-H7, modulo explicitly whitelisted ADDITIVE keys.
//
// Rules for evolving this file:
// - a batch may only WIDEN an `allowed` list (additive keys), never
// shrink it or drop a `required` key;
// - every widening must be recorded in docs/contract_pins.md in the
// same commit;
// - KOReader/Kobo/progress pins are the hard freeze (ground rule 1).
//
// The pinned-shape inventory lives in docs/contract_pins.md.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// shape assertion helpers
// ---------------------------------------------------------------------------
// assertResponseShape fails when the body carries a key outside `allowed`
// (an unapproved addition) or misses any `required` key (a removal).
func assertResponseShape(t *testing.T, what string, body map[string]interface{}, allowed, required []string) {
t.Helper()
allowedSet := make(map[string]bool, len(allowed))
for _, k := range allowed {
allowedSet[k] = true
}
var extra []string
for k := range body {
if !allowedSet[k] {
extra = append(extra, k)
}
}
require.Empty(t, extra,
"%s: response gained keys outside the frozen contract — additive keys must be whitelisted here AND in docs/contract_pins.md first. extra=%v", what, extra)
for _, k := range required {
require.Contains(t, body, k, "%s: contract key %q went missing", what, k)
}
}
// assertArrayItemShape pins the key set of every element of a JSON array.
func assertArrayItemShape(t *testing.T, what string, items []interface{}, allowed, required []string) {
t.Helper()
for i, raw := range items {
item, ok := raw.(map[string]interface{})
require.True(t, ok, "%s[%d]: expected object, got %T", what, i, raw)
assertResponseShape(t, fmt.Sprintf("%s[%d]", what, i), item, allowed, required)
}
}
// getArray extracts an array field, failing the test when absent.
func getArray(t *testing.T, body map[string]interface{}, key string) []interface{} {
t.Helper()
raw, ok := body[key]
require.True(t, ok, "expected array field %q", key)
arr, ok := raw.([]interface{})
require.True(t, ok, "%q should be an array, got %T", key, raw)
return arr
}
// ---------------------------------------------------------------------------
// shared fixture
// ---------------------------------------------------------------------------
type contractFixture struct {
libraryID string
itemID string
seriesName string
itemTitle string
}
// createContractFixture inserts a test library + one media item (directly
// through the DB, the createPreviewTestMediaItems pattern) and returns its
// ids. The library name carries "test" so the harness cleanup removes it.
func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture {
t.Helper()
ctx := context.Background()
libID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB),
fmt.Sprintf("Test Contract Library %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() {
libUUID, perr := uuid.Parse(libID)
if perr == nil {
_ = setup.DB.DeleteLibrary(ctx, pgtype.UUID{Bytes: libUUID, Valid: true})
}
})
libUUID, err := uuid.Parse(libID)
require.NoError(t, err)
// Device-sync queries (KOReader/Kobo library pulls) INNER JOIN
// library_visibility, so the fixture library needs an explicit row for
// the admin user or the sync surfaces see nothing.
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: getTestUserID(t, setup.DB), Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
IsVisible: true,
})
require.NoError(t, err, "Should mark fixture library visible for the sync queries")
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle,
Author: pgtype.Text{String: "Test Contract Author", Valid: true},
Series: pgtype.Text{String: seriesName, Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
FilePath: "/tmp/contract-test-item.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err, "Should create media item")
itemUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
require.NoError(t, err)
return contractFixture{
libraryID: libID,
itemID: itemUUID.String(),
seriesName: seriesName,
itemTitle: itemTitle,
}
}
// createContractDevice registers a sync device for the SHARED ADMIN user
// (testuser) and returns its auth token. Bearer-token auth for the
// KOReader endpoints, URL-token auth for the Kobo endpoints.
func createContractDevice(t *testing.T, setup *TestServerSetup, deviceType string) string {
return createContractDeviceNamed(t, setup, "Test Contract Device", deviceType)
}
// createContractDeviceNamed registers a device with an explicit name —
// the device NAME is what the web progress push adopts as its sync
// source (X-Device-Token attribution), so conflict tests can drive a
// second named source without a separate account.
func createContractDeviceNamed(t *testing.T, setup *TestServerSetup, name, deviceType string) string {
t.Helper()
token := fmt.Sprintf("dev_contract_%s", uuid.New().String())
device, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: getTestUserID(t, setup.DB), Valid: true},
DeviceName: name,
DeviceType: deviceType,
DeviceIdentifier: fmt.Sprintf("contract-%s-%d", deviceType, time.Now().UnixNano()),
AuthToken: token,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "Should create sync device")
_ = device
return token
}
// deviceGet / devicePost run requests authenticated by a device token.
func deviceRequest(t *testing.T, method, url, token string, body io.Reader) (*http.Response, map[string]interface{}) {
t.Helper()
req, err := http.NewRequest(method, url, body)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var parsed map[string]interface{}
if len(raw) > 0 {
require.NoError(t, json.Unmarshal(raw, &parsed), "response should be a JSON object: %s", string(raw))
}
return resp, parsed
}
// ---------------------------------------------------------------------------
// 1. Progress surfaces (media.go) — the hard freeze
// ---------------------------------------------------------------------------
// progressGETBaseKeys is the flat-map key set of GET
// /api/media-items/:id/progress after a percentage-only PUT (no CFI anchor
// and no context_text stored → the conditional bolt-ons never fire).
var progressGETBaseKeys = []string{
"id", "media_item_id", "user_id",
"current_page", "total_pages", "last_read_at", "percentage",
"character_offset", "epubcfi", "chapter", "chapter_progress",
"format_group", "total_characters", "chapter_count",
"last_sync_device", "last_sync_source", "last_sync_timestamp",
}
// progressGETAdditiveKeys are the keys the row carries that H1 adds to the
// GET (plus the already-existing conditional bolt-ons). Whitelisted in the
// H1 commit — see docs/contract_pins.md.
var progressGETAdditiveKeys = []string{
"audio_offset_seconds", "reading_mode",
"viewport_x", "viewport_y", "zoom_level",
"scroll_position_x", "scroll_position_y", "panel_number",
// conditional bolt-ons (present in some responses today):
"context_text", "css_selector", "anchor_href", "char_offset",
}
// progressPUTResponseKeys is the database.ReadingProgress JSON the web PUT
// returns (the SaveProgress funnel result).
var progressPUTResponseKeys = []string{
"id", "media_item_id", "user_id",
"current_page", "total_pages", "last_read_at", "percentage",
"character_offset", "epubcfi", "context_text",
"chapter", "chapter_progress", "audio_offset_seconds",
"viewport_x", "viewport_y", "zoom_level",
"scroll_position_x", "scroll_position_y", "panel_number",
"reading_mode", "last_sync_device", "last_sync_source",
"last_sync_timestamp", "conflict_detected", "conflict_resolved",
}
func TestContractPin_ProgressWeb(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
url := progressURL(setup.Server.URL, fx.itemID)
t.Run("GET after percentage-only PUT has the frozen base key set", func(t *testing.T) {
resp := doReq(t, "PUT", url, map[string]interface{}{"percentage": 0.42}, setup.RegularToken)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "progress PUT response", body, progressPUTResponseKeys, progressPUTResponseKeys)
resp2 := doReq(t, "GET", url, nil, setup.RegularToken)
defer resp2.Body.Close()
require.Equal(t, http.StatusOK, resp2.StatusCode)
getBody := decodeJSON(t, resp2)
assertResponseShape(t, "progress GET", getBody,
append(append([]string{}, progressGETBaseKeys...), progressGETAdditiveKeys...), progressGETBaseKeys)
})
t.Run("context_text bolt-on stays conditional", func(t *testing.T) {
other := createContractFixture(t, setup)
otherURL := progressURL(setup.Server.URL, other.itemID)
resp := doReq(t, "PUT", otherURL, map[string]interface{}{
"percentage": 0.1,
"context_text": "contract pin context",
}, setup.RegularToken)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
resp2 := doReq(t, "GET", otherURL, nil, setup.RegularToken)
defer resp2.Body.Close()
require.Equal(t, http.StatusOK, resp2.StatusCode)
body := decodeJSON(t, resp2)
assertResponseShape(t, "progress GET (with context)",
body, append(append([]string{}, progressGETBaseKeys...), progressGETAdditiveKeys...),
append(append([]string{}, progressGETBaseKeys...), "context_text"))
})
t.Run("GET with no rows returns the two-key empty shape", func(t *testing.T) {
resp := doReq(t, "GET", progressURL(setup.Server.URL, uuid.New().String()), nil, setup.RegularToken)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "progress GET (no rows)", body,
[]string{"current_page", "total_pages"}, []string{"current_page", "total_pages"})
})
}
// ---------------------------------------------------------------------------
// 2. KOReader plugin surfaces — byte-level freeze (ground rule 1)
// ---------------------------------------------------------------------------
func TestContractPin_KOReaderProgressPush(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
token := createContractDevice(t, setup, "koreader")
payload := fmt.Sprintf(`{"books": [{"uuid": %q, "percentage": 0.33, "progress": 0.33}], "sync_mode": "immediate"}`, fx.itemID)
resp, body := deviceRequest(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", token, strings.NewReader(payload))
require.Equal(t, http.StatusAccepted, resp.StatusCode)
assertResponseShape(t, "koreader sync progress", body,
[]string{"sync_status", "books_synced", "book_results", "conflicts", "timestamp", "device_updated"},
[]string{"sync_status", "books_synced", "timestamp", "device_updated"})
if raw, ok := body["book_results"]; ok && raw != nil {
assertArrayItemShape(t, "koreader book_results", getArray(t, body, "book_results"),
[]string{"sha256", "book_uuid", "synced"}, []string{"sha256", "synced"})
}
if raw, ok := body["conflicts"]; ok && raw != nil {
assertArrayItemShape(t, "koreader conflicts", getArray(t, body, "conflicts"),
[]string{"book_uuid", "conflict_type", "device_progress", "server_progress", "resolution"}, nil)
}
}
func TestContractPin_KOReaderProgressPull(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
token := createContractDevice(t, setup, "koreader")
// Seed progress so the pull has something to serve back.
pushResp, _ := deviceRequest(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", token,
strings.NewReader(fmt.Sprintf(`{"books": [{"uuid": %q, "percentage": 0.5}], "sync_mode": "immediate"}`, fx.itemID)))
require.Equal(t, http.StatusAccepted, pushResp.StatusCode)
resp, body := deviceRequest(t, "GET", setup.Server.URL+"/api/sync/koreader/metadata/"+fx.itemID, token, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
assertResponseShape(t, "koreader metadata", body,
[]string{"uuid", "sha256", "title", "authors", "progress", "annotations", "last_sync"},
[]string{"uuid", "title", "authors", "progress", "annotations", "last_sync"})
progress, ok := body["progress"].(map[string]interface{})
require.True(t, ok, "progress should be an object")
assertResponseShape(t, "koreader metadata.progress", progress,
[]string{"percentage", "character", "epubcfi", "koreader_xpointer", "chapter", "chapter_progress", "page", "total_pages"},
[]string{"percentage"})
annotations, ok := body["annotations"].(map[string]interface{})
require.True(t, ok, "annotations should be an object")
assertResponseShape(t, "koreader metadata.annotations", annotations,
[]string{"highlights", "notes", "bookmarks", "deleted_highlights", "deleted_bookmarks"}, nil)
}
func TestContractPin_KOReaderLibrary(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
_ = createContractFixture(t, setup)
token := createContractDevice(t, setup, "koreader")
resp, body := deviceRequest(t, "GET", setup.Server.URL+"/api/sync/koreader/library", token, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
assertResponseShape(t, "koreader library", body,
[]string{"library_sync", "total_books", "last_sync"},
[]string{"library_sync", "total_books", "last_sync"})
books := getArray(t, body, "library_sync")
require.NotEmpty(t, books, "admin user should see at least the fixture item")
assertArrayItemShape(t, "koreader library_sync[]", books,
[]string{"uuid", "sha256", "title", "author", "content_type", "percent_read", "pages_remaining", "bookmark_count", "last_modified"},
[]string{"uuid", "title", "author", "content_type", "percent_read", "bookmark_count", "last_modified"})
}
func TestContractPin_KOReaderBookmarksPush(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
token := createContractDevice(t, setup, "koreader")
payload := fmt.Sprintf(`{"book_uuid": %q, "bookmarks": [{"text": "contract pin bookmark", "pos0": "p1", "page": 1, "chapter": 1}]}`, fx.itemID)
resp, body := deviceRequest(t, "POST", setup.Server.URL+"/api/sync/koreader/bookmarks", token, strings.NewReader(payload))
require.Equal(t, http.StatusOK, resp.StatusCode)
assertResponseShape(t, "koreader bookmarks push", body,
[]string{"sync_status", "bookmarks_synced", "notes_synced", "highlights_synced", "total_synced", "timestamp"},
[]string{"sync_status", "bookmarks_synced", "notes_synced", "highlights_synced", "total_synced", "timestamp"})
}
// ---------------------------------------------------------------------------
// 3. Kobo adapter surfaces — the hard freeze (ground rule 1)
// ---------------------------------------------------------------------------
func TestContractPin_KoboInitialization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
token := createContractDevice(t, setup, "kobo")
req, err := http.NewRequest("GET", setup.Server.URL+"/api/sync/kobo/"+token+"/v1/initialization", nil)
require.NoError(t, err)
req.Header.Set("x-kobo-device", fmt.Sprintf(`{"DeviceId":"%s","Model":"Kobo Clara","SerialNumber":"contract-serial"}`, uuid.New().String()))
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var body map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assertResponseShape(t, "kobo initialization", body,
[]string{"library_sync", "total_books", "last_sync"},
[]string{"library_sync", "total_books", "last_sync"})
books := getArray(t, body, "library_sync")
require.NotEmpty(t, books, "kobo library should include the fixture item")
assertArrayItemShape(t, "kobo library_sync[]", books,
[]string{"ContentId", "ContentType", "Title", "Author", "PercentRead", "PagesRemaining", "BookmarkCount", "LastModified", "EntitlementId", "Revision", "MimeType", "FileSize", "Categories", "BookhoardUUID"},
[]string{"ContentId", "ContentType", "Title", "Author", "PercentRead", "BookmarkCount", "LastModified", "Revision", "MimeType", "FileSize"})
_ = fx
}
func TestContractPin_KoboMarkupSync(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
token := createContractDevice(t, setup, "kobo")
payload := fmt.Sprintf(`{"ReadingSync": [{"ContentId": %q, "PercentRead": 12.5}]}`, fx.itemID)
resp, body := deviceRequest(t, "POST", setup.Server.URL+"/api/sync/kobo/"+token+"/markup", token, strings.NewReader(payload))
require.Equal(t, http.StatusOK, resp.StatusCode)
assertResponseShape(t, "kobo markup sync", body,
[]string{"Status", "MarkupsSynced", "BookmarksSynced", "DeletedAnnotations"},
[]string{"Status", "MarkupsSynced", "BookmarksSynced"})
}
func TestContractPin_KoboSyncFromServer(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
_ = createContractFixture(t, setup)
token := createContractDevice(t, setup, "kobo")
resp, body := deviceRequest(t, "POST", setup.Server.URL+"/api/sync/kobo/"+token+"/sync-from-server", token, strings.NewReader(`[]`))
require.Equal(t, http.StatusOK, resp.StatusCode)
assertResponseShape(t, "kobo sync-from-server", body,
[]string{"BooksSynced", "BookmarksSent", "HighlightsSent"},
[]string{"BooksSynced", "BookmarksSent", "HighlightsSent"})
}
// ---------------------------------------------------------------------------
// 4. Collections / series / search / dashboard / media list JSON
// ---------------------------------------------------------------------------
// bookInfoKeys is the BookInfo JSON every card surface emits.
var bookInfoKeys = []string{
"media_item_id", "title", "author", "cover_image_path",
"has_conflict", "format_group", "badge",
}
func collectionURL(setup *TestServerSetup, id string) string {
return setup.Server.URL + "/api/collections/" + id
}
// findSystemCollection returns the admin user's system collection with the
// given query type (e.g. "continue-reading"), from the collections list.
func findSystemCollection(t *testing.T, setup *TestServerSetup, queryType string) map[string]interface{} {
t.Helper()
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
list, ok := body["collections"].([]interface{})
require.True(t, ok, "collections list should be an array, got: %T", body["collections"])
for _, raw := range list {
col, ok := raw.(map[string]interface{})
require.True(t, ok)
if name, _ := col["name"].(string); strings.EqualFold(name, "Continue Reading") {
return col
}
}
t.Fatalf("system collection %q not found in list", queryType)
return nil
}
func TestContractPin_CollectionsJSON(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
// The envelope every detail branch shares.
envelopeKeys := []string{
"id", "user_id", "name", "description", "color", "icon",
"auto_assign_rules", "view_settings", "books", "book_count",
"is_smart", "created_at",
}
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract Coll %d", time.Now().UnixNano()),
"description": "contract pin",
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
created := decodeJSON(t, resp)
colID, _ := created["id"].(string)
require.NotEmpty(t, colID)
t.Cleanup(func() {
del := doReq(t, "DELETE", collectionURL(setup, colID), nil, setup.Token)
defer del.Body.Close()
})
// Add the fixture book so the branch has a row to shape (204).
add := doReq(t, "POST", collectionURL(setup, colID)+"/books", map[string]interface{}{
"book_ids": []string{fx.itemID},
}, setup.Token)
defer add.Body.Close()
require.Equal(t, http.StatusNoContent, add.StatusCode)
detail := doReq(t, "GET", collectionURL(setup, colID)+"?library_id="+fx.libraryID, nil, setup.Token)
defer detail.Body.Close()
require.Equal(t, http.StatusOK, detail.StatusCode)
body := decodeJSON(t, detail)
assertResponseShape(t, "collection detail (user+library)", body, envelopeKeys, envelopeKeys)
books := getArray(t, body, "books")
require.Len(t, books, 1)
assertArrayItemShape(t, "collection books[]", books, bookInfoKeys, bookInfoKeys)
})
t.Run("user collection without library_id (GetCollectionBooksData branch)", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract Nolib %d", time.Now().UnixNano()),
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
colID, _ := decodeJSON(t, resp)["id"].(string)
require.NotEmpty(t, colID)
t.Cleanup(func() {
del := doReq(t, "DELETE", collectionURL(setup, colID), nil, setup.Token)
defer del.Body.Close()
})
add := doReq(t, "POST", collectionURL(setup, colID)+"/books", map[string]interface{}{
"book_ids": []string{fx.itemID},
}, setup.Token)
defer add.Body.Close()
require.Equal(t, http.StatusNoContent, add.StatusCode)
detail := doReq(t, "GET", collectionURL(setup, colID), nil, setup.Token)
defer detail.Body.Close()
require.Equal(t, http.StatusOK, detail.StatusCode)
body := decodeJSON(t, detail)
assertResponseShape(t, "collection detail (no library)", body, envelopeKeys, envelopeKeys)
books := getArray(t, body, "books")
require.Len(t, books, 1)
assertArrayItemShape(t, "collection books[] (no library)", books, bookInfoKeys, bookInfoKeys)
})
t.Run("smart collection (rules branch)", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract Smart %d", time.Now().UnixNano()),
"auto_assign_rules": []map[string]interface{}{
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
},
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
created := decodeJSON(t, resp)
colID, _ := created["id"].(string)
require.NotEmpty(t, colID)
t.Cleanup(func() {
del := doReq(t, "DELETE", collectionURL(setup, colID), nil, setup.Token)
defer del.Body.Close()
})
detail := doReq(t, "GET", collectionURL(setup, colID)+"?library_id="+fx.libraryID, nil, setup.Token)
defer detail.Body.Close()
require.Equal(t, http.StatusOK, detail.StatusCode)
body := decodeJSON(t, detail)
assertResponseShape(t, "collection detail (smart)", body, envelopeKeys, envelopeKeys)
assertArrayItemShape(t, "collection books[] (smart)", getArray(t, body, "books"), bookInfoKeys, bookInfoKeys)
})
t.Run("system collection with query_type (dashboard-sections branch)", func(t *testing.T) {
col := findSystemCollection(t, setup, "continue-reading")
colID, _ := col["id"].(string)
require.NotEmpty(t, colID)
// Give the admin progress on the fixture so the section has a row.
put := doReq(t, "PUT", progressURL(setup.Server.URL, fx.itemID), map[string]interface{}{"percentage": 0.55}, setup.Token)
defer put.Body.Close()
require.Equal(t, http.StatusOK, put.StatusCode)
detail := doReq(t, "GET", collectionURL(setup, colID)+"?library_id="+fx.libraryID, nil, setup.Token)
defer detail.Body.Close()
require.Equal(t, http.StatusOK, detail.StatusCode)
body := decodeJSON(t, detail)
assertResponseShape(t, "collection detail (system)", body, envelopeKeys, envelopeKeys)
assertArrayItemShape(t, "collection books[] (system)", getArray(t, body, "books"), bookInfoKeys, bookInfoKeys)
})
t.Run("preview returns the bare items envelope", func(t *testing.T) {
resp := doReq(t, "POST", setup.Server.URL+"/api/collections/preview", map[string]interface{}{
"library_id": fx.libraryID,
"rules": []map[string]interface{}{
{"id": "rule-1", "field": "genre", "operator": "equals", "value": "Science Fiction", "priority": 5},
},
"manual_book_ids": []string{},
"limit": 20,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "collection preview", body, []string{"items"}, []string{"items"})
assertArrayItemShape(t, "preview items[]", getArray(t, body, "items"), bookInfoKeys, bookInfoKeys)
})
}
func TestContractPin_SeriesJSON(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
url := fmt.Sprintf("%s/api/series/books?name=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.seriesName), fx.libraryID)
resp := doReq(t, "GET", url, nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "series books", body,
[]string{"name", "library_id", "books", "total"},
[]string{"name", "library_id", "books", "total"})
books := getArray(t, body, "books")
require.NotEmpty(t, books)
assertArrayItemShape(t, "series books[]", books, bookInfoKeys, bookInfoKeys)
}
func TestContractPin_SearchJSON(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
// SearchMediaItemsUnifiedRow json keys (47 fields, sqlc-generated).
rowKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "asin", "date_published",
"publisher", "contributors", "language", "edition", "page_count",
"genre", "copyright_year", "goodreads_id", "openlibrary_id",
"google_books_id", "added_by_admin_id", "created_at", "updated_at",
"format_group", "format_mimetype", "is_reflowable", "has_fixed_layout",
"total_characters", "chapter_count", "entitlement_id", "revision_number",
"kobo_content_id", "kobo_metadata", "tags_search", "contributors_search",
"narrators", "file_sha256", "opf_identifier", "opf_uuid",
"hash_confidence", "library_name", "library_type_name",
}
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
resp := doReq(t, "GET", url, nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var rows []interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
require.NotEmpty(t, rows)
assertArrayItemShape(t, "search rows[]", rows, rowKeys, rowKeys)
}
func TestContractPin_DashboardSections(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
put := doReq(t, "PUT", progressURL(setup.Server.URL, fx.itemID), map[string]interface{}{"percentage": 0.6}, setup.Token)
defer put.Body.Close()
require.Equal(t, http.StatusOK, put.StatusCode)
resp := doReq(t, "GET", setup.Server.URL+"/api/dashboard/sections", nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "dashboard sections", body, []string{"sections"}, []string{"sections"})
sectionKeys := []string{
"id", "collection_id", "is_system", "title", "description",
"icon", "items", "view_all_url", "priority",
}
sections := getArray(t, body, "sections")
require.NotEmpty(t, sections)
assertArrayItemShape(t, "sections[]", sections, sectionKeys, sectionKeys)
for _, raw := range sections {
section := raw.(map[string]interface{})
assertArrayItemShape(t, "sections[].items", getArray(t, section, "items"), bookInfoKeys, bookInfoKeys)
}
}
func TestContractPin_MediaListBothBranches(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
// The sqlc row key sets behind BOTH branches.
rawRowKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "narrators", "abridged",
"asin", "date_published", "publisher", "contributors", "language",
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
"imported_at", "updated_at", "format_group", "format_mimetype",
"is_reflowable", "has_fixed_layout", "total_characters", "chapter_count",
"entitlement_id", "revision_number", "kobo_content_id", "kobo_metadata",
"manga_type", "reading_direction", "series_count", "volume", "imprint",
"age_rating", "web_url", "story_arc", "is_black_and_white",
"metadata_notes", "community_rating", "alternate_info", "scan_information",
"summary", "metadata_overrides", "missing_scan_count", "archived_at",
"chapter_metadata", "library_type_name", "audio_metadata",
"tags_search", "contributors_search", "narrators_search", "file_sha256",
"opf_identifier", "opf_uuid", "hash_confidence", "library_name",
"library_type_name_2",
}
resolvedKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "asin", "date_published",
"publisher", "contributors", "language", "edition", "page_count",
"genre", "created_at", "updated_at", "format_group",
"manga_type", "reading_direction",
"series_count", "volume", "imprint", "age_rating", "web_url",
"metadata_notes", "community_rating",
"story_arc", "is_black_and_white", "alternate_info", "scan_information",
"summary",
}
t.Run("unscoped list emits the resolved 38-key map", func(t *testing.T) {
resp := doReq(t, "GET", setup.Server.URL+"/api/media-items?limit=100", nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
assertResponseShape(t, "media list envelope", body, []string{"data", "total"}, []string{"data"})
items := getArray(t, body, "data")
require.NotEmpty(t, items)
assertArrayItemShape(t, "media list rows[]", items, resolvedKeys, resolvedKeys)
})
t.Run("library-scoped list emits raw sqlc rows", func(t *testing.T) {
url := fmt.Sprintf("%s/api/media-items?library_id=%s&sort=%s&limit=100", setup.Server.URL, fx.libraryID, url.QueryEscape("title ASC"))
resp := doReq(t, "GET", url, nil, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body := decodeJSON(t, resp)
items := getArray(t, body, "data")
require.NotEmpty(t, items)
assertArrayItemShape(t, "media list (scoped) rows[]", items, rawRowKeys, rawRowKeys)
})
}
// ---------------------------------------------------------------------------
// 5. SSR smoke — the templ pages render 200 with their stable markers
// ---------------------------------------------------------------------------
func ssrGet(t *testing.T, url, token string) (int, string) {
t.Helper()
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
req.Header.Set("Cookie", "token="+token)
resp, err := (&http.Client{}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return resp.StatusCode, string(raw)
}
func TestContractPin_SSRPages(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
t.Run("/bookshelf", func(t *testing.T) {
code, html := ssrGet(t, setup.Server.URL+"/bookshelf", setup.Token)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, html, "<title>Library - Bookhoard</title>")
})
t.Run("/collections list + detail", func(t *testing.T) {
code, html := ssrGet(t, setup.Server.URL+"/collections", setup.Token)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, html, "<title>Collections - Bookhoard</title>")
// Detail for the user collection created via the API (no library).
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
"name": fmt.Sprintf("Test Contract SSR %d", time.Now().UnixNano()),
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
colID, _ := decodeJSON(t, resp)["id"].(string)
t.Cleanup(func() {
del := doReq(t, "DELETE", collectionURL(setup, colID), nil, setup.Token)
defer del.Body.Close()
})
code, html = ssrGet(t, setup.Server.URL+"/collections/"+colID, setup.Token)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, html, "- Bookhoard</title>")
})
t.Run("/series and /series/detail", func(t *testing.T) {
code, _ := ssrGet(t, setup.Server.URL+"/series", setup.Token)
require.Equal(t, http.StatusOK, code)
url := fmt.Sprintf("%s/series/detail?name=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.seriesName), fx.libraryID)
code, html := ssrGet(t, url, setup.Token)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, html, fx.seriesName)
})
t.Run("/tags/detail", func(t *testing.T) {
url := setup.Server.URL + "/tags/detail?name=contract-pin-tag"
code, html := ssrGet(t, url, setup.Token)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, html, "- Bookhoard</title>")
})
}
// TestH1_SyncQueueFlush_LandsAndPreservesAudio proves the offline flush
// end-to-end: a pending sync_queue row (what the app's checkpoint sync
// enqueues) must be picked up by the running processor, funnel through
// SaveProgress (winner percentage lands), and NOT erase row fields the
// queue snapshot doesn't carry (audio_offset_seconds survives).
// NOTE: before 2026-10-17 this test could not pass — the processor's
// enumerator passed a NULL device_id into `device_id = $1`, which matches
// no rows, so the flush loop never ran.
func TestH1_SyncQueueFlush_LandsAndPreservesAudio(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
// Seed the stored position with audio via the web path.
put := doReq(t, "PUT", progressURL(setup.Server.URL, fx.itemID), map[string]interface{}{
"percentage": 0.5,
"audio_offset_seconds": 900,
}, setup.Token)
defer put.Body.Close()
require.Equal(t, http.StatusOK, put.StatusCode)
// Enqueue a checkpoint-style progress entry (percentage only, no
// audio — exactly what buildProgressSnapshot captures).
itemUUID, perr := uuid.Parse(fx.itemID)
require.NoError(t, perr)
deviceRow, derr := setup.DB.GetDeviceByAuthToken(context.Background(), deviceToken)
require.NoError(t, derr)
syncData, _ := json.Marshal(map[string]interface{}{"percentage": 0.8})
_, qerr := setup.DB.CreateSyncQueueItem(context.Background(), database.CreateSyncQueueItemParams{
DeviceID: deviceRow.ID,
MediaItemID: pgtype.UUID{Bytes: itemUUID, Valid: true},
SyncType: "progress",
SyncData: syncData,
Priority: pgtype.Int4{Int32: 5, Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: "pending", Valid: true},
})
require.NoError(t, qerr)
// The processor ticks every 5s; poll up to 15s for the flush.
var row map[string]interface{}
landed := false
for i := 0; i < 30 && !landed; i++ {
time.Sleep(500 * time.Millisecond)
get := doReq(t, "GET", progressURL(setup.Server.URL, fx.itemID), nil, setup.Token)
if get.StatusCode != http.StatusOK {
get.Body.Close()
continue
}
row = decodeJSON(t, get)
get.Body.Close()
if pct, ok := row["percentage"].(float64); ok && pct > 0.79 {
landed = true
}
}
require.True(t, landed, "the queued progress (0.8) should flush onto the stored row")
require.Equal(t, float64(900), getFloatField(t, row, "audio_offset_seconds"),
"the flush must preserve audio_offset_seconds (LWW seed), not NULL it")
}
// TestH1_ConflictResolutionPreservesCFIPair is the BOOK-side twin of the
// audio test: a winner carrying epubcfi + context_text must land with its
// locator pair intact — the far-anchor doctrine's coherent pair — instead
// of the old direct-write behavior that NULLed context_text and
// character_offset (because they weren't in its param list), leaving a
// winner CFI standing next to nothing.
func TestH1_ConflictResolutionPreservesCFIPair(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
progress := progressURL(setup.Server.URL, fx.itemID)
// Push 1 (web): percentage + the full CFI/context anchor pair.
cfi := "epubcfi(/6/4/2:15)"
context := "the contract pin context sentence"
resp := doReq(t, "PUT", progress, map[string]interface{}{
"percentage": 0.5,
"epubcfi": cfi,
"context_text": context,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Push 2 within the window from a second source: percentage-only (the
// koreader-style push) -> conflict detected against push 1.
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
push2Req, err := http.NewRequest("PUT", progress,
strings.NewReader(`{"percentage": 0.3}`))
require.NoError(t, err)
push2Req.Header.Set("Content-Type", "application/json")
push2Req.Header.Set("Authorization", "Bearer "+setup.Token)
push2Req.Header.Set("X-Device-Token", deviceToken)
resp2, err := (&http.Client{}).Do(push2Req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp2.StatusCode)
resp2.Body.Close()
// Find the detected conflict.
listResp := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
defer listResp.Body.Close()
require.Equal(t, http.StatusOK, listResp.StatusCode)
list := decodeJSON(t, listResp)
var conflictID string
for _, raw := range list["conflicts"].([]interface{}) {
col := raw.(map[string]interface{})
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" && col["resolution_status"] == "unresolved" {
conflictID, _ = col["id"].(string)
}
}
require.NotEmpty(t, conflictID, "two-source push should detect a progress conflict")
// Resolve with the CFI-carrying side as winner.
resolve := doReq(t, "POST", setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", map[string]interface{}{
"winner": "web",
}, setup.Token)
defer resolve.Body.Close()
require.Equal(t, http.StatusOK, resolve.StatusCode)
// The winner's ENTIRE locator pair lands: percentage, epubcfi AND
// context_text together (the old write NULLed the context), and the
// loser's percentage does not.
get := doReq(t, "GET", progress, nil, setup.Token)
defer get.Body.Close()
require.Equal(t, http.StatusOK, get.StatusCode)
row := decodeJSON(t, get)
require.Equal(t, 0.5, getFloatField(t, row, "percentage"))
require.Equal(t, cfi, getStringField(t, row, "epubcfi"))
require.Equal(t, context, getStringField(t, row, "context_text"))
// No duplicate pending conflict from the funneled resolution write.
get2 := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
defer get2.Body.Close()
require.Equal(t, http.StatusOK, get2.StatusCode)
for _, raw := range decodeJSON(t, get2)["conflicts"].([]interface{}) {
col := raw.(map[string]interface{})
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" {
require.Equal(t, "user_resolved", col["resolution_status"],
"resolution must not spawn a duplicate pending conflict")
}
}
}
// ---------------------------------------------------------------------------
// 7. H1 verification — resolving a conflict must preserve unknown fields
// ---------------------------------------------------------------------------
// TestH1_ConflictResolutionPreservesAudio is the executable check for the
// hardening plan's H1 done-when: a resolution applied via
// POST /api/conflicts/:id/resolve (the endpoint the web UI and the
// reader's mark-as-read both use) must NOT erase fields the winner's
// snapshot doesn't carry (before the funnel, the resolution write NULLed
// audio_offset_seconds), and the funneled resolution write must not file
// a duplicate pending conflict on the item it just resolved.
func TestH1_ConflictResolutionPreservesAudio(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
progress := progressURL(setup.Server.URL, fx.itemID)
// Push 1: web push carrying an audiobook-style position (source "web")
// — audio_offset_seconds is stored but NOT part of conflict snapshots.
resp := doReq(t, "PUT", progress, map[string]interface{}{
"percentage": 0.5,
"audio_offset_seconds": 300,
}, setup.Token)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Push 2 within the 5-minute window from a second source (a
// device-token push attributes the write to the device's name) with a
// diverging percentage and NO audio field: the funnel detects the
// source mismatch + pct diff and records a conflict.
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
push2Req, err := http.NewRequest("PUT", progress,
strings.NewReader(`{"percentage": 0.3}`))
require.NoError(t, err)
push2Req.Header.Set("Content-Type", "application/json")
push2Req.Header.Set("Authorization", "Bearer "+setup.Token)
push2Req.Header.Set("X-Device-Token", deviceToken)
resp2, err := (&http.Client{}).Do(push2Req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp2.StatusCode)
resp2.Body.Close()
// Find the freshly detected progress conflict.
listResp := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
defer listResp.Body.Close()
require.Equal(t, http.StatusOK, listResp.StatusCode)
list := decodeJSON(t, listResp)
conflicts, ok := list["conflicts"].([]interface{})
require.True(t, ok, "conflicts should be an array")
var conflictID string
for _, raw := range conflicts {
col := raw.(map[string]interface{})
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" && col["resolution_status"] == "unresolved" {
conflictID, _ = col["id"].(string)
}
}
require.NotEmpty(t, conflictID, "the two-source push should have detected a progress conflict")
// Resolve with the second source as the winner — the endpoint and
// payload shape the web UI posts (web/static/main.js resolveConflict).
resolve := doReq(t, "POST", setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", map[string]interface{}{
"winner": "koreader",
}, setup.Token)
defer resolve.Body.Close()
require.Equal(t, http.StatusOK, resolve.StatusCode)
resolved := decodeJSON(t, resolve)
require.Equal(t, true, resolved["conflict_resolved"])
// The saved row carries the winner's percentage AND the audio field
// the winner never knew about.
get := doReq(t, "GET", progress, nil, setup.Token)
defer get.Body.Close()
require.Equal(t, http.StatusOK, get.StatusCode)
row := decodeJSON(t, get)
require.Equal(t, 0.3, getFloatField(t, row, "percentage"))
require.Equal(t, float64(300), getFloatField(t, row, "audio_offset_seconds"),
"resolution must preserve audio_offset_seconds (LWW seed), not NULL it")
// No duplicate pending conflict may remain for this item (the funnel's
// own LWW detector must skip the resolution write).
get2 := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
defer get2.Body.Close()
require.Equal(t, http.StatusOK, get2.StatusCode)
list2 := decodeJSON(t, get2)
for _, raw := range list2["conflicts"].([]interface{}) {
col := raw.(map[string]interface{})
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" {
require.Equal(t, "user_resolved", col["resolution_status"],
"the resolved conflict must not have spawned a duplicate pending conflict")
}
}
}
// ---------------------------------------------------------------------------
// 3b. web annotation CRUD — the shape freeze H3 needs before it consolidates
// ---------------------------------------------------------------------------
// The three response shapes are the full sqlc row models (annotations are
// richer than their create/PUT request DTOs — the sync columns ride along).
// H3's consolidation + PUT fixes must keep these key sets; response keys
// gained by routing the PUTs through the sync service (e.g. outcome
// markers) are additive and go through the whitelist.
var (
_WEBBookmarkRowKeys = []string{
"id", "media_item_id", "user_id", "page_number", "chapter_number",
"cfi_position", "title", "position", "notes", "created_at",
"dedup_key", "last_modified_at", "last_modified_source",
"device_sync_data", "percentage_location", "epubcfi_location",
"chapter_reference", "deleted", "deleted_at", "origin_source",
}
_WebNoteRowKeys = []string{
"id", "media_item_id", "user_id", "content", "position", "created_at",
"updated_at", "percentage_location", "character_start",
"character_end", "epubcfi_location", "chapter_reference",
"paragraph_reference", "device_sync_data", "dedup_key",
"last_modified_at", "last_modified_source", "deleted", "deleted_at",
}
_WebHighlightRowKeys = []string{
"id", "media_item_id", "user_id", "selection_text",
"start_position", "end_position", "color", "note_id", "created_at",
"updated_at", "percentage_start", "percentage_end",
"character_start", "character_end", "epubcfi_start", "epubcfi_end",
"chapter_reference", "paragraph_start", "paragraph_end",
"panel_number", "device_sync_data", "dedup_key",
"last_modified_at", "last_modified_source", "note_text",
"deleted", "deleted_at",
}
)
func annotationURL(serverURL, mediaItemID, kind, id string) string {
if id == "" {
return fmt.Sprintf("%s/api/media-items/%s/%s", serverURL, mediaItemID, kind)
}
return fmt.Sprintf("%s/api/media-items/%s/%s/%s", serverURL, mediaItemID, kind, id)
}
// pinAnnotationLifecycle walks create -> update -> delete on ONE annotation
// type and freezes every response shape (that's the whole contract the web
// reader and the app rely on for annotations). Every failed status includes
// the response body — pins that fail on status alone are otherwise
// undiagnosable.
func pinAnnotationLifecycle(t *testing.T, setup *TestServerSetup, fx contractFixture, kind string, createBody, updateBody interface{}, rowKeys []string) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
t.Run(kind+" POST response", func(t *testing.T) {
resp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, kind, ""), createBody, setup.RegularToken)
defer resp.Body.Close()
raw, readErr := io.ReadAll(resp.Body)
require.NoError(t, readErr)
if !assert.Equal(t, http.StatusCreated, resp.StatusCode, "create must stay 201; body: %s", raw) {
t.FailNow()
}
var body map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &body))
assertResponseShape(t, "annotation "+kind+" create", body, rowKeys, rowKeys)
})
t.Run(kind+" PUT response", func(t *testing.T) {
resp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, kind, ""), createBody, setup.RegularToken)
defer resp.Body.Close()
raw0, readErr := io.ReadAll(resp.Body)
require.NoError(t, readErr)
if !assert.Equal(t, http.StatusCreated, resp.StatusCode, "inner create must stay 201; body: %s", raw0) {
t.FailNow()
}
var created map[string]interface{}
require.NoError(t, json.Unmarshal(raw0, &created))
id, _ := created["id"].(string)
require.NotEmpty(t, id)
resp2 := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, kind, id), updateBody, setup.RegularToken)
defer resp2.Body.Close()
raw, readErr := io.ReadAll(resp2.Body)
require.NoError(t, readErr)
if !assert.Equal(t, http.StatusOK, resp2.StatusCode, "update must stay 200; body: %s", raw) {
t.FailNow()
}
var body2 map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &body2))
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
t.Run(kind+" DELETE response", func(t *testing.T) {
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
defer resp3.Body.Close()
require.Equal(t, http.StatusNoContent, resp3.StatusCode, "delete must stay 204")
})
})
}
func TestContractPin_AnnotationCRUDNote(t *testing.T) {
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
pinAnnotationLifecycle(t, setup, fx, "notes",
map[string]interface{}{"content": "pin note", "position": "p1"},
map[string]interface{}{"content": "pin note 2", "position": "p1b"},
_WebNoteRowKeys)
}
func TestContractPin_AnnotationCRUDHighlight(t *testing.T) {
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
pinAnnotationLifecycle(t, setup, fx, "highlights",
map[string]interface{}{
"selection_text": "pin highlight text",
"epubcfi_start": "epubcfi(/6/4/2:10)",
"epubcfi_end": "epubcfi(/6/4/2:20)",
"color": "#ffff00",
},
map[string]interface{}{
"selection_text": "pin highlight text",
"epubcfi_start": "epubcfi(/6/4/2:10)",
"epubcfi_end": "epubcfi(/6/4/2:20)",
"color": "#ff0000",
},
_WebHighlightRowKeys)
}
func TestContractPin_AnnotationCRUDBookmark(t *testing.T) {
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
pinAnnotationLifecycle(t, setup, fx, "bookmarks",
map[string]interface{}{
"title": "pin bookmark",
"position": "p1",
"cfi_position": "epubcfi(/6/4/2:30)",
"page_number": 12,
"chapter_number": 3,
"percentage": 0.25,
"chapter_reference": 4,
},
map[string]interface{}{
"title": "pin bookmark 2",
"position": "p1b",
"notes": "updated by pin",
},
_WEBBookmarkRowKeys)
}
// TestH3_BookmarkPUTPreservesLocators locks the H3 PUT routing: the web
// bookmark PUT lands through the sync service as an id-targeted LWW edit.
// Fields the PUT omits must be COALESCED with the stored row (the old
// plain query never touched them; a naive full-column sync write would
// have zeroed them), fields it sends must land, and the row's
// source/modified_at bookkeeping must update — the plain query bypassed
// it entirely.
func TestH3_BookmarkPUTPreservesLocators(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
setup := setupTestServer(t)
fx := createContractFixture(t, setup)
url := annotationURL(setup.Server.URL, fx.itemID, "bookmarks", "")
create := doReq(t, "POST", url, map[string]interface{}{
"title": "H3 bookmark",
"position": "p1",
"cfi_position": "epubcfi(/6/4/2:30)",
"page_number": 12,
"chapter_number": 3,
"percentage": 0.25,
"chapter_reference": 4,
}, setup.RegularToken)
defer create.Body.Close()
require.Equal(t, http.StatusCreated, create.StatusCode)
created := decodeJSON(t, create)
id, _ := created["id"].(string)
require.NotEmpty(t, id)
// PUT carrying ONLY the legacy fields: locators must survive.
put := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
"title": "H3 bookmark renamed",
"notes": "edited",
}, setup.RegularToken)
defer put.Body.Close()
raw, _ := io.ReadAll(put.Body)
require.Equal(t, http.StatusOK, put.StatusCode, "body: %s", raw)
var row map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &row))
require.Equal(t, "H3 bookmark renamed", getStringField(t, row, "title"))
require.Equal(t, "epubcfi(/6/4/2:30)", getStringField(t, row, "cfi_position"),
"an omitted locator must be coalesced, not zeroed")
require.Equal(t, 0.25, getFloatField(t, row, "percentage_location"),
"an omitted locator must be coalesced, not zeroed")
require.Equal(t, float64(12), getFloatField(t, row, "page_number"))
require.Equal(t, "web", getStringField(t, row, "last_modified_source"),
"the sync-routed PUT must maintain source bookkeeping")
// PUT carrying a locator explicitly: it lands.
put2 := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
"title": "H3 bookmark renamed",
"notes": "edited",
"percentage": 0.5,
"cfi_position": "epubcfi(/6/4/2:44)",
}, setup.RegularToken)
defer put2.Body.Close()
raw2, _ := io.ReadAll(put2.Body)
require.Equal(t, http.StatusOK, put2.StatusCode, "body: %s", raw2)
var row2 map[string]interface{}
require.NoError(t, json.Unmarshal(raw2, &row2))
require.Equal(t, 0.5, getFloatField(t, row2, "percentage_location"))
require.Equal(t, "epubcfi(/6/4/2:44)", getStringField(t, row2, "cfi_position"))
// ...and the fields omitted THIS time coalesce again (page survived).
require.Equal(t, float64(12), getFloatField(t, row2, "page_number"))
// The note PUT carries its content edit through the service too.
noteURL := annotationURL(setup.Server.URL, fx.itemID, "notes", "")
noteCreate := doReq(t, "POST", noteURL, map[string]interface{}{
"content": "H3 note", "position": "p9",
}, setup.RegularToken)
defer noteCreate.Body.Close()
require.Equal(t, http.StatusCreated, noteCreate.StatusCode)
note := decodeJSON(t, noteCreate)
noteID, _ := note["id"].(string)
notePut := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "notes", noteID), map[string]interface{}{
"content": "H3 note edited", "position": "p9b",
}, setup.RegularToken)
defer notePut.Body.Close()
raw3, _ := io.ReadAll(notePut.Body)
require.Equal(t, http.StatusOK, notePut.StatusCode, "body: %s", raw3)
var noteRow map[string]interface{}
require.NoError(t, json.Unmarshal(raw3, &noteRow))
require.Equal(t, "H3 note edited", getStringField(t, noteRow, "content"))
require.Equal(t, "web", getStringField(t, noteRow, "last_modified_source"),
"the note PUT must maintain source bookkeeping via the sync service")
}