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.
190 lines
7.3 KiB
Go
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")
|
|
}
|