package main import ( "context" "net/http" "net/http/httptest" "testing" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // unlinkedBooksFixture is one user + device + two queue rows. The regular // user's cleanup cascades: users → devices → unlinked_books. type unlinkedBooksFixture struct { setup *TestServerSetup owner UserTestData ownerTok string deviceID pgtype.UUID contentA string contentB string rowA pgtype.UUID rowB pgtype.UUID } func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture { f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"} f.owner = createRegularUserOnce(t, f.setup.DB) f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password) device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{ UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true}, DeviceName: "test-unlinked-reader", DeviceType: "koreader", DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8], AuthToken: "dev_test_" + uuid.New().String(), 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, "device for unlinked-books tests") f.deviceID = device.ID f.rowA = f.seedRow(t, f.contentA, "Test Book A") f.rowB = f.seedRow(t, f.contentB, "Test Book B") return f } // seedRow inserts one queue row for the fixture device. func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID { row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{ DeviceID: f.deviceID, ContentID: contentID, Title: pgtype.Text{String: title, Valid: true}, }) require.NoError(t, err, "seed unlinked_books row") return row.ID } // doAction fires an htmx-style page action as the given user. func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Cookie", "token="+token) req.Header.Set("HX-Request", "true") rec := httptest.NewRecorder() f.setup.Server.Config.Handler.ServeHTTP(rec, req) return rec } func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int { rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(), pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true}) require.NoError(t, err) return len(rows) } func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID { rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(), pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true}) require.NoError(t, err) ids := make([]uuid.UUID, 0, len(rows)) for _, r := range rows { ids = append(ids, uuid.UUID(r.ID.Bytes)) } return ids } // TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden // from the page list, badge count, and per-device API; preserved across // the sync upsert; reversible via unignore. Hard delete is covered too. func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) { f := setupUnlinkedBooksFixture(t) ctx := context.Background() pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String() pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String() t.Run("ignore hides the row everywhere", func(t *testing.T) { rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore") require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) // The htmx swap re-renders the whole section partial. assert.Contains(t, rec.Body.String(), `id="unlinked-section"`) assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued") ignored := f.ignoredIDs(t) require.Len(t, ignored, 1) assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0]) count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true}) require.NoError(t, err) assert.Equal(t, int64(1), count, "dashboard badge must still count row B only") perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID) require.NoError(t, err) assert.Len(t, perDevice, 1, "per-device API must hide the ignored row") deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID) require.NoError(t, err) assert.Equal(t, int64(1), deviceCount) }) t.Run("device sync refresh preserves the ignore", func(t *testing.T) { // The exact query the KOReader sync path runs on every re-sync. _, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{ DeviceID: f.deviceID, ContentID: f.contentA, Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true}, }) require.NoError(t, err) row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA) require.NoError(t, err) assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at") assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata") assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue") }) t.Run("unignore restores the row to the queue", func(t *testing.T) { rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore") require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) assert.Equal(t, 2, f.unresolvedCount(t)) assert.Empty(t, f.ignoredIDs(t)) row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA) require.NoError(t, err) assert.False(t, row.IgnoredAt.Valid) }) t.Run("delete removes the row outright", func(t *testing.T) { rec := f.doAction(t, f.ownerTok, "DELETE", pathB) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) _, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB) assert.Error(t, err, "hard-deleted row must be gone") assert.Equal(t, 1, f.unresolvedCount(t)) }) t.Run("unknown and malformed ids are rejected", func(t *testing.T) { rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String()) assert.Equal(t, http.StatusNotFound, rec.Code) rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore") assert.Equal(t, http.StatusBadRequest, rec.Code) }) } // TestUnlinkedBooksOwnership pins row → device → user authorization: // another user's queue rows are untouchable, ignored or deleted. func TestUnlinkedBooksOwnership(t *testing.T) { f := setupUnlinkedBooksFixture(t) stranger := createRegularUserOnce(t, f.setup.DB) strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password) pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String() rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore") assert.Equal(t, http.StatusForbidden, rec.Code) row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA) require.NoError(t, err) assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land") rec = f.doAction(t, strangerTok, "DELETE", pathA) assert.Equal(t, http.StatusForbidden, rec.Code) _, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA) assert.NoError(t, err, "foreign delete must not land") }