feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Release / build-and-push (push) Successful in 3m15s
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.
This commit is contained in:
1 parent
afbf1029a9
commit
f6a73ab5aa
10 files changed
+1241
-375
No files matched your search
@@ -0,0 +1,189 @@
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user