Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
1414 lines
60 KiB
Go
1414 lines
60 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(seedCtx(), database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Title: itemTitle,
|
|
Authors: []string{"Test Contract Author"},
|
|
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.
|
|
// 2026-10-27 (search-progress batch): + viewer_percentage (nil = no
|
|
// viewer data; only the search-unified adapter populates it today)
|
|
// and + library_name (same deal). docs/contract_pins.md changelog.
|
|
var bookInfoKeys = []string{
|
|
"media_item_id", "title", "author", "cover_image_path",
|
|
"has_conflict", "format_group", "badge",
|
|
"viewer_percentage", "library_name",
|
|
}
|
|
|
|
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.
|
|
// A2c (2026-10-19): + up_next (the collection's reading-queue strip
|
|
// payload; docs/contract_pins.md changelog).
|
|
envelopeKeys := []string{
|
|
"id", "user_id", "name", "description", "color", "icon",
|
|
"auto_assign_rules", "view_settings", "books", "book_count",
|
|
"is_smart", "created_at", "up_next",
|
|
}
|
|
|
|
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",
|
|
// H4: the unified row gained the two lagging columns (additive,
|
|
// EXPLAIN plan-identical) and the search endpoint serializes the
|
|
// row directly, so they emit here.
|
|
"abridged", "audio_metadata",
|
|
// A2b/A2c (2026-10-19): + subtitle, explicit, authors,
|
|
// authors_search, publishers, publishers_search, narrators_search
|
|
// (docs/contract_pins.md changelog).
|
|
"subtitle", "explicit", "authors", "authors_search",
|
|
"publishers", "publishers_search", "narrators_search",
|
|
// 2026-10-27 (search-progress batch): + viewer_percentage —
|
|
// the requester's reading_progress.percentage (LEFT JOIN on
|
|
// the same user_id param the visibility join binds), null
|
|
// when they never opened the book (docs/contract_pins.md
|
|
// changelog).
|
|
"viewer_percentage",
|
|
}
|
|
|
|
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",
|
|
// A2c (2026-10-19): + is_playlist, complete (docs/contract_pins.md).
|
|
"is_playlist", "complete",
|
|
}
|
|
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.
|
|
// A2b (2026-10-19): + subtitle, explicit, authors, authors_search,
|
|
// publishers, publishers_search (docs/contract_pins.md changelog).
|
|
rawRowKeys := []string{
|
|
"id", "library_id", "title", "subtitle", "author", "isbn", "description",
|
|
"file_path", "file_size", "mime_type", "cover_image_path",
|
|
"series", "series_number", "tags", "narrators", "abridged",
|
|
"explicit", "authors", "authors_search", "publishers",
|
|
"publishers_search",
|
|
"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", "subtitle", "author", "authors", "isbn", "description",
|
|
"file_path", "file_size", "mime_type", "cover_image_path",
|
|
"series", "series_number", "tags", "asin", "date_published",
|
|
"publisher", "publishers", "explicit", "contributors", "language", "edition", "page_count",
|
|
"genre", "created_at", "updated_at", "format_group",
|
|
"manga_type", "reading_direction",
|
|
"series_count", "volume", "imprint", "age_rating", "web_url",
|
|
"metadata_notes", "community_rating",
|
|
"story_arc", "is_black_and_white", "alternate_info", "scan_information",
|
|
"summary",
|
|
}
|
|
|
|
t.Run("unscoped list emits the resolved 38-key map", func(t *testing.T) {
|
|
resp := doReq(t, "GET", setup.Server.URL+"/api/media-items?limit=100", nil, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
body := decodeJSON(t, resp)
|
|
assertResponseShape(t, "media list envelope", body, []string{"data", "total"}, []string{"data"})
|
|
items := getArray(t, body, "data")
|
|
require.NotEmpty(t, items)
|
|
assertArrayItemShape(t, "media list rows[]", items, resolvedKeys, resolvedKeys)
|
|
})
|
|
|
|
t.Run("library-scoped list emits raw sqlc rows", func(t *testing.T) {
|
|
url := fmt.Sprintf("%s/api/media-items?library_id=%s&sort=%s&limit=100", setup.Server.URL, fx.libraryID, url.QueryEscape("title ASC"))
|
|
resp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
body := decodeJSON(t, resp)
|
|
items := getArray(t, body, "data")
|
|
require.NotEmpty(t, items)
|
|
assertArrayItemShape(t, "media list (scoped) rows[]", items, rawRowKeys, rawRowKeys)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. SSR smoke — the templ pages render 200 with their stable markers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func ssrGet(t *testing.T, url, token string) (int, string) {
|
|
t.Helper()
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
require.NoError(t, err)
|
|
req.Header.Set("Cookie", "token="+token)
|
|
resp, err := (&http.Client{}).Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
return resp.StatusCode, string(raw)
|
|
}
|
|
|
|
func TestContractPin_SSRPages(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
|
|
t.Run("/bookshelf", func(t *testing.T) {
|
|
code, html := ssrGet(t, setup.Server.URL+"/bookshelf", setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, html, "<title>Library - Bookhoard</title>")
|
|
})
|
|
|
|
t.Run("/collections list + detail", func(t *testing.T) {
|
|
code, html := ssrGet(t, setup.Server.URL+"/collections", setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, html, "<title>Collections - Bookhoard</title>")
|
|
|
|
// Detail for the user collection created via the API (no library).
|
|
resp := doReq(t, "POST", setup.Server.URL+"/api/collections", map[string]interface{}{
|
|
"name": fmt.Sprintf("Test Contract SSR %d", time.Now().UnixNano()),
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
colID, _ := decodeJSON(t, resp)["id"].(string)
|
|
t.Cleanup(func() {
|
|
del := doReq(t, "DELETE", collectionURL(setup, colID), nil, setup.Token)
|
|
defer del.Body.Close()
|
|
})
|
|
code, html = ssrGet(t, setup.Server.URL+"/collections/"+colID, setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, html, "- Bookhoard</title>")
|
|
})
|
|
|
|
t.Run("/series and /series/detail", func(t *testing.T) {
|
|
code, _ := ssrGet(t, setup.Server.URL+"/series", setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
|
|
url := fmt.Sprintf("%s/series/detail?name=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.seriesName), fx.libraryID)
|
|
code, html := ssrGet(t, url, setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, html, fx.seriesName)
|
|
})
|
|
|
|
t.Run("/tags/detail", func(t *testing.T) {
|
|
url := setup.Server.URL + "/tags/detail?name=contract-pin-tag"
|
|
code, html := ssrGet(t, url, setup.Token)
|
|
require.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, html, "- Bookhoard</title>")
|
|
})
|
|
}
|
|
|
|
// TestH1_SyncQueueFlush_LandsAndPreservesAudio proves the offline flush
|
|
// end-to-end: a pending sync_queue row (what the app's checkpoint sync
|
|
// enqueues) must be picked up by the running processor, funnel through
|
|
// SaveProgress (winner percentage lands), and NOT erase row fields the
|
|
// queue snapshot doesn't carry (audio_offset_seconds survives).
|
|
// NOTE: before 2026-10-17 this test could not pass — the processor's
|
|
// enumerator passed a NULL device_id into `device_id = $1`, which matches
|
|
// no rows, so the flush loop never ran.
|
|
func TestH1_SyncQueueFlush_LandsAndPreservesAudio(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
|
|
|
|
// Seed the stored position with audio via the web path.
|
|
put := doReq(t, "PUT", progressURL(setup.Server.URL, fx.itemID), map[string]interface{}{
|
|
"percentage": 0.5,
|
|
"audio_offset_seconds": 900,
|
|
}, setup.Token)
|
|
defer put.Body.Close()
|
|
require.Equal(t, http.StatusOK, put.StatusCode)
|
|
|
|
// Enqueue a checkpoint-style progress entry (percentage only, no
|
|
// audio — exactly what buildProgressSnapshot captures).
|
|
itemUUID, perr := uuid.Parse(fx.itemID)
|
|
require.NoError(t, perr)
|
|
deviceRow, derr := setup.DB.GetDeviceByAuthToken(context.Background(), deviceToken)
|
|
require.NoError(t, derr)
|
|
syncData, _ := json.Marshal(map[string]interface{}{"percentage": 0.8})
|
|
_, qerr := setup.DB.CreateSyncQueueItem(context.Background(), database.CreateSyncQueueItemParams{
|
|
DeviceID: deviceRow.ID,
|
|
MediaItemID: pgtype.UUID{Bytes: itemUUID, Valid: true},
|
|
SyncType: "progress",
|
|
SyncData: syncData,
|
|
Priority: pgtype.Int4{Int32: 5, Valid: true},
|
|
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
|
|
Status: pgtype.Text{String: "pending", Valid: true},
|
|
})
|
|
require.NoError(t, qerr)
|
|
|
|
// The processor ticks every 5s; poll up to 15s for the flush.
|
|
var row map[string]interface{}
|
|
landed := false
|
|
for i := 0; i < 30 && !landed; i++ {
|
|
time.Sleep(500 * time.Millisecond)
|
|
get := doReq(t, "GET", progressURL(setup.Server.URL, fx.itemID), nil, setup.Token)
|
|
if get.StatusCode != http.StatusOK {
|
|
get.Body.Close()
|
|
continue
|
|
}
|
|
row = decodeJSON(t, get)
|
|
get.Body.Close()
|
|
if pct, ok := row["percentage"].(float64); ok && pct > 0.79 {
|
|
landed = true
|
|
}
|
|
}
|
|
require.True(t, landed, "the queued progress (0.8) should flush onto the stored row")
|
|
require.Equal(t, float64(900), getFloatField(t, row, "audio_offset_seconds"),
|
|
"the flush must preserve audio_offset_seconds (LWW seed), not NULL it")
|
|
}
|
|
|
|
// TestH1_ConflictResolutionPreservesCFIPair is the BOOK-side twin of the
|
|
// audio test: a winner carrying epubcfi + context_text must land with its
|
|
// locator pair intact — the far-anchor doctrine's coherent pair — instead
|
|
// of the old direct-write behavior that NULLed context_text and
|
|
// character_offset (because they weren't in its param list), leaving a
|
|
// winner CFI standing next to nothing.
|
|
func TestH1_ConflictResolutionPreservesCFIPair(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
progress := progressURL(setup.Server.URL, fx.itemID)
|
|
|
|
// Push 1 (web): percentage + the full CFI/context anchor pair.
|
|
cfi := "epubcfi(/6/4/2:15)"
|
|
context := "the contract pin context sentence"
|
|
resp := doReq(t, "PUT", progress, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
"epubcfi": cfi,
|
|
"context_text": context,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Push 2 within the window from a second source: percentage-only (the
|
|
// koreader-style push) -> conflict detected against push 1.
|
|
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
|
|
push2Req, err := http.NewRequest("PUT", progress,
|
|
strings.NewReader(`{"percentage": 0.3}`))
|
|
require.NoError(t, err)
|
|
push2Req.Header.Set("Content-Type", "application/json")
|
|
push2Req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
push2Req.Header.Set("X-Device-Token", deviceToken)
|
|
resp2, err := (&http.Client{}).Do(push2Req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
resp2.Body.Close()
|
|
|
|
// Find the detected conflict.
|
|
listResp := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
|
|
defer listResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, listResp.StatusCode)
|
|
list := decodeJSON(t, listResp)
|
|
var conflictID string
|
|
for _, raw := range list["conflicts"].([]interface{}) {
|
|
col := raw.(map[string]interface{})
|
|
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" && col["resolution_status"] == "unresolved" {
|
|
conflictID, _ = col["id"].(string)
|
|
}
|
|
}
|
|
require.NotEmpty(t, conflictID, "two-source push should detect a progress conflict")
|
|
|
|
// Resolve with the CFI-carrying side as winner.
|
|
resolve := doReq(t, "POST", setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", map[string]interface{}{
|
|
"winner": "web",
|
|
}, setup.Token)
|
|
defer resolve.Body.Close()
|
|
require.Equal(t, http.StatusOK, resolve.StatusCode)
|
|
|
|
// The winner's ENTIRE locator pair lands: percentage, epubcfi AND
|
|
// context_text together (the old write NULLed the context), and the
|
|
// loser's percentage does not.
|
|
get := doReq(t, "GET", progress, nil, setup.Token)
|
|
defer get.Body.Close()
|
|
require.Equal(t, http.StatusOK, get.StatusCode)
|
|
row := decodeJSON(t, get)
|
|
require.Equal(t, 0.5, getFloatField(t, row, "percentage"))
|
|
require.Equal(t, cfi, getStringField(t, row, "epubcfi"))
|
|
require.Equal(t, context, getStringField(t, row, "context_text"))
|
|
|
|
// No duplicate pending conflict from the funneled resolution write.
|
|
get2 := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
|
|
defer get2.Body.Close()
|
|
require.Equal(t, http.StatusOK, get2.StatusCode)
|
|
for _, raw := range decodeJSON(t, get2)["conflicts"].([]interface{}) {
|
|
col := raw.(map[string]interface{})
|
|
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" {
|
|
require.Equal(t, "user_resolved", col["resolution_status"],
|
|
"resolution must not spawn a duplicate pending conflict")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. H1 verification — resolving a conflict must preserve unknown fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestH1_ConflictResolutionPreservesAudio is the executable check for the
|
|
// hardening plan's H1 done-when: a resolution applied via
|
|
// POST /api/conflicts/:id/resolve (the endpoint the web UI and the
|
|
// reader's mark-as-read both use) must NOT erase fields the winner's
|
|
// snapshot doesn't carry (before the funnel, the resolution write NULLed
|
|
// audio_offset_seconds), and the funneled resolution write must not file
|
|
// a duplicate pending conflict on the item it just resolved.
|
|
func TestH1_ConflictResolutionPreservesAudio(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
progress := progressURL(setup.Server.URL, fx.itemID)
|
|
|
|
// Push 1: web push carrying an audiobook-style position (source "web")
|
|
// — audio_offset_seconds is stored but NOT part of conflict snapshots.
|
|
resp := doReq(t, "PUT", progress, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
"audio_offset_seconds": 300,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
// Push 2 within the 5-minute window from a second source (a
|
|
// device-token push attributes the write to the device's name) with a
|
|
// diverging percentage and NO audio field: the funnel detects the
|
|
// source mismatch + pct diff and records a conflict.
|
|
deviceToken := createContractDeviceNamed(t, setup, "koreader", "koreader")
|
|
push2Req, err := http.NewRequest("PUT", progress,
|
|
strings.NewReader(`{"percentage": 0.3}`))
|
|
require.NoError(t, err)
|
|
push2Req.Header.Set("Content-Type", "application/json")
|
|
push2Req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
push2Req.Header.Set("X-Device-Token", deviceToken)
|
|
resp2, err := (&http.Client{}).Do(push2Req)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
resp2.Body.Close()
|
|
|
|
// Find the freshly detected progress conflict.
|
|
listResp := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
|
|
defer listResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, listResp.StatusCode)
|
|
list := decodeJSON(t, listResp)
|
|
conflicts, ok := list["conflicts"].([]interface{})
|
|
require.True(t, ok, "conflicts should be an array")
|
|
var conflictID string
|
|
for _, raw := range conflicts {
|
|
col := raw.(map[string]interface{})
|
|
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" && col["resolution_status"] == "unresolved" {
|
|
conflictID, _ = col["id"].(string)
|
|
}
|
|
}
|
|
require.NotEmpty(t, conflictID, "the two-source push should have detected a progress conflict")
|
|
|
|
// Resolve with the second source as the winner — the endpoint and
|
|
// payload shape the web UI posts (web/static/main.js resolveConflict).
|
|
resolve := doReq(t, "POST", setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", map[string]interface{}{
|
|
"winner": "koreader",
|
|
}, setup.Token)
|
|
defer resolve.Body.Close()
|
|
require.Equal(t, http.StatusOK, resolve.StatusCode)
|
|
resolved := decodeJSON(t, resolve)
|
|
require.Equal(t, true, resolved["conflict_resolved"])
|
|
|
|
// The saved row carries the winner's percentage AND the audio field
|
|
// the winner never knew about.
|
|
get := doReq(t, "GET", progress, nil, setup.Token)
|
|
defer get.Body.Close()
|
|
require.Equal(t, http.StatusOK, get.StatusCode)
|
|
row := decodeJSON(t, get)
|
|
require.Equal(t, 0.3, getFloatField(t, row, "percentage"))
|
|
require.Equal(t, float64(300), getFloatField(t, row, "audio_offset_seconds"),
|
|
"resolution must preserve audio_offset_seconds (LWW seed), not NULL it")
|
|
|
|
// No duplicate pending conflict may remain for this item (the funnel's
|
|
// own LWW detector must skip the resolution write).
|
|
get2 := doReq(t, "GET", setup.Server.URL+"/api/conflicts", nil, setup.Token)
|
|
defer get2.Body.Close()
|
|
require.Equal(t, http.StatusOK, get2.StatusCode)
|
|
list2 := decodeJSON(t, get2)
|
|
for _, raw := range list2["conflicts"].([]interface{}) {
|
|
col := raw.(map[string]interface{})
|
|
if col["media_item_id"] == fx.itemID && col["conflict_type"] == "progress" {
|
|
require.Equal(t, "user_resolved", col["resolution_status"],
|
|
"the resolved conflict must not have spawned a duplicate pending conflict")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3b. web annotation CRUD — the shape freeze H3 needs before it consolidates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The three response shapes are the full sqlc row models (annotations are
|
|
// richer than their create/PUT request DTOs — the sync columns ride along).
|
|
// H3's consolidation + PUT fixes must keep these key sets; response keys
|
|
// gained by routing the PUTs through the sync service (e.g. outcome
|
|
// markers) are additive and go through the whitelist.
|
|
var (
|
|
_WEBBookmarkRowKeys = []string{
|
|
"id", "media_item_id", "user_id", "page_number", "chapter_number",
|
|
"cfi_position", "title", "position", "notes", "created_at",
|
|
"dedup_key", "last_modified_at", "last_modified_source",
|
|
"device_sync_data", "percentage_location", "epubcfi_location",
|
|
"chapter_reference", "deleted", "deleted_at", "origin_source",
|
|
}
|
|
_WebNoteRowKeys = []string{
|
|
"id", "media_item_id", "user_id", "content", "position", "created_at",
|
|
"updated_at", "percentage_location", "character_start",
|
|
"character_end", "epubcfi_location", "chapter_reference",
|
|
"paragraph_reference", "device_sync_data", "dedup_key",
|
|
"last_modified_at", "last_modified_source", "deleted", "deleted_at",
|
|
}
|
|
_WebHighlightRowKeys = []string{
|
|
"id", "media_item_id", "user_id", "selection_text",
|
|
"start_position", "end_position", "color", "note_id", "created_at",
|
|
"updated_at", "percentage_start", "percentage_end",
|
|
"character_start", "character_end", "epubcfi_start", "epubcfi_end",
|
|
"chapter_reference", "paragraph_start", "paragraph_end",
|
|
"panel_number", "device_sync_data", "dedup_key",
|
|
"last_modified_at", "last_modified_source", "note_text",
|
|
"deleted", "deleted_at",
|
|
}
|
|
)
|
|
|
|
func annotationURL(serverURL, mediaItemID, kind, id string) string {
|
|
if id == "" {
|
|
return fmt.Sprintf("%s/api/media-items/%s/%s", serverURL, mediaItemID, kind)
|
|
}
|
|
return fmt.Sprintf("%s/api/media-items/%s/%s/%s", serverURL, mediaItemID, kind, id)
|
|
}
|
|
|
|
// pinAnnotationLifecycle walks create -> update -> delete on ONE annotation
|
|
// type and freezes every response shape (that's the whole contract the web
|
|
// reader and the app rely on for annotations). Every failed status includes
|
|
// the response body — pins that fail on status alone are otherwise
|
|
// undiagnosable.
|
|
func pinAnnotationLifecycle(t *testing.T, setup *TestServerSetup, fx contractFixture, kind string, createBody, updateBody interface{}, rowKeys []string) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
t.Run(kind+" POST response", func(t *testing.T) {
|
|
resp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, kind, ""), createBody, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
raw, readErr := io.ReadAll(resp.Body)
|
|
require.NoError(t, readErr)
|
|
if !assert.Equal(t, http.StatusCreated, resp.StatusCode, "create must stay 201; body: %s", raw) {
|
|
t.FailNow()
|
|
}
|
|
var body map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw, &body))
|
|
assertResponseShape(t, "annotation "+kind+" create", body, rowKeys, rowKeys)
|
|
})
|
|
t.Run(kind+" PUT response", func(t *testing.T) {
|
|
resp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, kind, ""), createBody, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
raw0, readErr := io.ReadAll(resp.Body)
|
|
require.NoError(t, readErr)
|
|
if !assert.Equal(t, http.StatusCreated, resp.StatusCode, "inner create must stay 201; body: %s", raw0) {
|
|
t.FailNow()
|
|
}
|
|
var created map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw0, &created))
|
|
id, _ := created["id"].(string)
|
|
require.NotEmpty(t, id)
|
|
|
|
resp2 := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, kind, id), updateBody, setup.RegularToken)
|
|
defer resp2.Body.Close()
|
|
raw, readErr := io.ReadAll(resp2.Body)
|
|
require.NoError(t, readErr)
|
|
if !assert.Equal(t, http.StatusOK, resp2.StatusCode, "update must stay 200; body: %s", raw) {
|
|
t.FailNow()
|
|
}
|
|
var body2 map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw, &body2))
|
|
assertResponseShape(t, "annotation "+kind+" update", body2, rowKeys, rowKeys)
|
|
|
|
t.Run(kind+" DELETE response", func(t *testing.T) {
|
|
resp3 := doReq(t, "DELETE", annotationURL(setup.Server.URL, fx.itemID, kind, id), nil, setup.RegularToken)
|
|
defer resp3.Body.Close()
|
|
require.Equal(t, http.StatusNoContent, resp3.StatusCode, "delete must stay 204")
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestContractPin_AnnotationCRUDNote(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
pinAnnotationLifecycle(t, setup, fx, "notes",
|
|
map[string]interface{}{"content": "pin note", "position": "p1"},
|
|
map[string]interface{}{"content": "pin note 2", "position": "p1b"},
|
|
_WebNoteRowKeys)
|
|
}
|
|
|
|
func TestContractPin_AnnotationCRUDHighlight(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
pinAnnotationLifecycle(t, setup, fx, "highlights",
|
|
map[string]interface{}{
|
|
"selection_text": "pin highlight text",
|
|
"epubcfi_start": "epubcfi(/6/4/2:10)",
|
|
"epubcfi_end": "epubcfi(/6/4/2:20)",
|
|
"color": "#ffff00",
|
|
},
|
|
map[string]interface{}{
|
|
"selection_text": "pin highlight text",
|
|
"epubcfi_start": "epubcfi(/6/4/2:10)",
|
|
"epubcfi_end": "epubcfi(/6/4/2:20)",
|
|
"color": "#ff0000",
|
|
},
|
|
_WebHighlightRowKeys)
|
|
}
|
|
|
|
func TestContractPin_AnnotationCRUDBookmark(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
pinAnnotationLifecycle(t, setup, fx, "bookmarks",
|
|
map[string]interface{}{
|
|
"title": "pin bookmark",
|
|
"position": "p1",
|
|
"cfi_position": "epubcfi(/6/4/2:30)",
|
|
"page_number": 12,
|
|
"chapter_number": 3,
|
|
"percentage": 0.25,
|
|
"chapter_reference": 4,
|
|
},
|
|
map[string]interface{}{
|
|
"title": "pin bookmark 2",
|
|
"position": "p1b",
|
|
"notes": "updated by pin",
|
|
},
|
|
_WEBBookmarkRowKeys)
|
|
}
|
|
|
|
// TestH3_BookmarkPUTPreservesLocators locks the H3 PUT routing: the web
|
|
// bookmark PUT lands through the sync service as an id-targeted LWW edit.
|
|
// Fields the PUT omits must be COALESCED with the stored row (the old
|
|
// plain query never touched them; a naive full-column sync write would
|
|
// have zeroed them), fields it sends must land, and the row's
|
|
// source/modified_at bookkeeping must update — the plain query bypassed
|
|
// it entirely.
|
|
func TestH3_BookmarkPUTPreservesLocators(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
url := annotationURL(setup.Server.URL, fx.itemID, "bookmarks", "")
|
|
|
|
create := doReq(t, "POST", url, map[string]interface{}{
|
|
"title": "H3 bookmark",
|
|
"position": "p1",
|
|
"cfi_position": "epubcfi(/6/4/2:30)",
|
|
"page_number": 12,
|
|
"chapter_number": 3,
|
|
"percentage": 0.25,
|
|
"chapter_reference": 4,
|
|
}, setup.RegularToken)
|
|
defer create.Body.Close()
|
|
require.Equal(t, http.StatusCreated, create.StatusCode)
|
|
created := decodeJSON(t, create)
|
|
id, _ := created["id"].(string)
|
|
require.NotEmpty(t, id)
|
|
|
|
// PUT carrying ONLY the legacy fields: locators must survive.
|
|
put := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
|
|
"title": "H3 bookmark renamed",
|
|
"notes": "edited",
|
|
}, setup.RegularToken)
|
|
defer put.Body.Close()
|
|
raw, _ := io.ReadAll(put.Body)
|
|
require.Equal(t, http.StatusOK, put.StatusCode, "body: %s", raw)
|
|
var row map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw, &row))
|
|
require.Equal(t, "H3 bookmark renamed", getStringField(t, row, "title"))
|
|
require.Equal(t, "epubcfi(/6/4/2:30)", getStringField(t, row, "cfi_position"),
|
|
"an omitted locator must be coalesced, not zeroed")
|
|
require.Equal(t, 0.25, getFloatField(t, row, "percentage_location"),
|
|
"an omitted locator must be coalesced, not zeroed")
|
|
require.Equal(t, float64(12), getFloatField(t, row, "page_number"))
|
|
require.Equal(t, "web", getStringField(t, row, "last_modified_source"),
|
|
"the sync-routed PUT must maintain source bookkeeping")
|
|
|
|
// PUT carrying a locator explicitly: it lands.
|
|
put2 := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
|
|
"title": "H3 bookmark renamed",
|
|
"notes": "edited",
|
|
"percentage": 0.5,
|
|
"cfi_position": "epubcfi(/6/4/2:44)",
|
|
}, setup.RegularToken)
|
|
defer put2.Body.Close()
|
|
raw2, _ := io.ReadAll(put2.Body)
|
|
require.Equal(t, http.StatusOK, put2.StatusCode, "body: %s", raw2)
|
|
var row2 map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw2, &row2))
|
|
require.Equal(t, 0.5, getFloatField(t, row2, "percentage_location"))
|
|
require.Equal(t, "epubcfi(/6/4/2:44)", getStringField(t, row2, "cfi_position"))
|
|
// ...and the fields omitted THIS time coalesce again (page survived).
|
|
require.Equal(t, float64(12), getFloatField(t, row2, "page_number"))
|
|
|
|
// The note PUT carries its content edit through the service too.
|
|
noteURL := annotationURL(setup.Server.URL, fx.itemID, "notes", "")
|
|
noteCreate := doReq(t, "POST", noteURL, map[string]interface{}{
|
|
"content": "H3 note", "position": "p9",
|
|
}, setup.RegularToken)
|
|
defer noteCreate.Body.Close()
|
|
require.Equal(t, http.StatusCreated, noteCreate.StatusCode)
|
|
note := decodeJSON(t, noteCreate)
|
|
noteID, _ := note["id"].(string)
|
|
notePut := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "notes", noteID), map[string]interface{}{
|
|
"content": "H3 note edited", "position": "p9b",
|
|
}, setup.RegularToken)
|
|
defer notePut.Body.Close()
|
|
raw3, _ := io.ReadAll(notePut.Body)
|
|
require.Equal(t, http.StatusOK, notePut.StatusCode, "body: %s", raw3)
|
|
var noteRow map[string]interface{}
|
|
require.NoError(t, json.Unmarshal(raw3, ¬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")
|
|
}
|
|
|
|
// TestOriginWriterAttribution locks the client-origin labeling: every
|
|
// web-API write endpoint accepts an optional `origin` key and records it
|
|
// as the writer (last_modified_source / last_sync_source) when it names a
|
|
// known first-party client ("android", "ios"). Absent or unknown values
|
|
// stay "web", and a paired X-Device-Token still outranks origin on the
|
|
// progress push. The web SPA sends no origin — its rows are unchanged.
|
|
func TestOriginWriterAttribution(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
setup := setupTestServer(t)
|
|
fx := createContractFixture(t, setup)
|
|
|
|
t.Run("progress PUT honors origin, defaults to web", func(t *testing.T) {
|
|
url := progressURL(setup.Server.URL, fx.itemID)
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.3, "origin": "android",
|
|
}, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
putRow := decodeJSON(t, resp)
|
|
require.Equal(t, "android", getStringField(t, putRow, "last_sync_source"))
|
|
|
|
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.31,
|
|
}, setup.RegularToken)
|
|
defer resp2.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
require.Equal(t, "web", getStringField(t, decodeJSON(t, resp2), "last_sync_source"))
|
|
|
|
// Unknown labels normalize to web (the label keys conflict claims
|
|
// and device_sync_data — free-form client strings would pollute).
|
|
resp3 := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.32, "origin": "fridge",
|
|
}, setup.RegularToken)
|
|
defer resp3.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
|
require.Equal(t, "web", getStringField(t, decodeJSON(t, resp3), "last_sync_source"))
|
|
})
|
|
|
|
t.Run("bookmark create and PUT honor origin", func(t *testing.T) {
|
|
url := annotationURL(setup.Server.URL, fx.itemID, "bookmarks", "")
|
|
resp := doReq(t, "POST", url, map[string]interface{}{
|
|
"title": "origin bookmark", "position": "p1", "origin": "android",
|
|
}, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
row := decodeJSON(t, resp)
|
|
id, _ := row["id"].(string)
|
|
require.NotEmpty(t, id)
|
|
require.Equal(t, "android", getStringField(t, row, "last_modified_source"))
|
|
require.Equal(t, "android", getStringField(t, row, "origin_source"))
|
|
|
|
put := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
|
|
"title": "origin bookmark renamed", "origin": "android",
|
|
}, setup.RegularToken)
|
|
defer put.Body.Close()
|
|
require.Equal(t, http.StatusOK, put.StatusCode)
|
|
require.Equal(t, "android", getStringField(t, decodeJSON(t, put), "last_modified_source"))
|
|
})
|
|
|
|
t.Run("note and highlight creates honor origin", func(t *testing.T) {
|
|
noteResp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, "notes", ""), map[string]interface{}{
|
|
"content": "origin note", "position": "p2", "origin": "android",
|
|
}, setup.RegularToken)
|
|
defer noteResp.Body.Close()
|
|
require.Equal(t, http.StatusCreated, noteResp.StatusCode)
|
|
require.Equal(t, "android", getStringField(t, decodeJSON(t, noteResp), "last_modified_source"))
|
|
|
|
hlResp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, "highlights", ""), map[string]interface{}{
|
|
"selection_text": "origin highlight", "origin": "ios",
|
|
}, setup.RegularToken)
|
|
defer hlResp.Body.Close()
|
|
require.Equal(t, http.StatusCreated, hlResp.StatusCode)
|
|
require.Equal(t, "ios", getStringField(t, decodeJSON(t, hlResp), "last_modified_source"))
|
|
})
|
|
}
|