diff --git a/cmd/server/tests/contract_pins_test.go b/cmd/server/tests/contract_pins_test.go new file mode 100644 index 00000000..43ec9e2d --- /dev/null +++ b/cmd/server/tests/contract_pins_test.go @@ -0,0 +1,824 @@ +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 { + 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: "Test Contract Device", + 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, "