Files
bookhoard/cmd/server/tests/unlinked_books_test.go
T
John O'Keefe f6a73ab5aa
Release / build-and-push (push) Successful in 3m15s
feat(unlinked-books): dismiss (ignore) or delete queue entries
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

Ignore is the durable dismiss: a new ignored_at column hides the row
from the page list, dashboard badge, and per-device API while the sync
keeps refreshing its metadata (the upsert never clears the column).
A collapsible "Ignored" section lists dismissed rows with Restore and
Delete so a misclick is recoverable. Delete is the hard exit — the row
is gone, and honestly re-queued if the device syncs that file again
(the confirm dialog says so).

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
2026-10-26 14:58:50 -04:00

190 lines
7.3 KiB
Go

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")
}