Pin-first batch for H3: freeze web annotation CRUD response shapes (create 201 / update 200 / delete 204; full sqlc row models) before the stack consolidation touches anything. The new pins immediately caught a live server bug the 28 unit tests could never see (annotations_test.go is pure-unit: keys and compare logic, no DB): applyNoteLWW built UpdateMediaNoteForSyncParams without UserID, so the query's WHERE id=$1 AND user_id=$13 compared against zero — every dedup-hit note re-save (same content+position) returned 500 "update note: no rows in result set" and the update never landed. The bookmark twin passes UserID and works; the note stack is the clone that dropped the field. Fix: pass UserID in the params (one line). Pins go green and become the regression net; changelog records the new surfaces.
1221 lines
51 KiB
Go
1221 lines
51 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)
|
|
}
|