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, "Library - Bookhoard") }) 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, "Collections - Bookhoard") // 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") }) 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") }) } // 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, ¬eRow)) 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") }