feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
+1
-1
@@ -165,7 +165,7 @@ func main() {
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
package main
|
||||
|
||||
// Filter rule builder integration tests (faceted plan Phase 9): the
|
||||
// composed engines behind the qf wire param — tree semantics (AND/OR/
|
||||
// negation/nesting), every user-relative field class (the "my data"
|
||||
// weld), the note-text substring pin, family lens, suggestion-under-
|
||||
// rules exactness, qf validation, and qf-over-flat precedence.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// qfSearch runs the search endpoint with arbitrary params (qf trees go
|
||||
// in the qf param as JSON) and returns (status, decoded rows).
|
||||
func qfSearch(t *testing.T, setup *TestServerSetup, token string, params url.Values) (int, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||
return resp.StatusCode, rows
|
||||
}
|
||||
|
||||
// qfRule is a compact rule builder for tests.
|
||||
func qfRule(field, op string, values ...string) map[string]interface{} {
|
||||
return map[string]interface{}{"field": field, "op": op, "values": values}
|
||||
}
|
||||
|
||||
func qfAll(children ...map[string]interface{}) string {
|
||||
return qfGroup("all", children...)
|
||||
}
|
||||
|
||||
func qfAny(children ...map[string]interface{}) string {
|
||||
return qfGroup("any", children...)
|
||||
}
|
||||
|
||||
func qfGroup(mode string, children ...map[string]interface{}) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{"mode": mode, "children": children})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func qfTitles(t *testing.T, rows []map[string]interface{}) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
title, _ := r["title"].(string)
|
||||
out = append(out, title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// builderFixtureLib builds one test library + four books with
|
||||
// trigram-disjoint values. Returns the library UUID and the book titles.
|
||||
func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, name string) (uuid.UUID, pgtype.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Builder %s %d", name, time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "filter builder test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
year := pgtype.Int4{Int32: 1999, Valid: true}
|
||||
datePublished := pgtype.Date{Time: time.Date(1999, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||
books := []database.CreateMediaItemParams{
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Tide",
|
||||
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||
Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true},
|
||||
Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true},
|
||||
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
DatePublished: datePublished, CopyrightYear: year,
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Stone",
|
||||
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||
Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true},
|
||||
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 2, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Fen",
|
||||
Authors: []string{"Benjamin Quill"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||
Tags: []string{"epic"}, PageCount: pgtype.Int4{Int32: 200, Valid: true},
|
||||
Abridged: true,
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-fen-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 512, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Wick",
|
||||
Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||
PageCount: pgtype.Int4{Int32: 700, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 4096, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
}
|
||||
for i := range books {
|
||||
if books[i].Title == "" {
|
||||
books[i].Title = fmt.Sprintf("%s Book %d", name, i)
|
||||
}
|
||||
// created_at binds explicitly — a zero pgtype would insert NULL
|
||||
// and the date fields (added_at within N days) would never match.
|
||||
books[i].CreatedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||
books[i].ImportedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||
// Ingest populates the *_search columns (normalized lowercase);
|
||||
// direct-create fixtures must mirror that or the *_search-side
|
||||
// match clauses (tags via tags_search) see nothing.
|
||||
if len(books[i].Authors) > 0 && books[i].AuthorsSearch == nil {
|
||||
lowered := make([]string, len(books[i].Authors))
|
||||
for j, a := range books[i].Authors {
|
||||
lowered[j] = strings.ToLower(a)
|
||||
}
|
||||
books[i].AuthorsSearch = lowered
|
||||
}
|
||||
if len(books[i].Tags) > 0 && books[i].TagsSearch == nil {
|
||||
lowered := make([]string, len(books[i].Tags))
|
||||
for j, tag := range books[i].Tags {
|
||||
lowered[j] = strings.ToLower(tag)
|
||||
}
|
||||
books[i].TagsSearch = lowered
|
||||
}
|
||||
_, err := setup.DB.CreateMediaItem(ctx, books[i])
|
||||
require.NoError(t, err, "create %s", books[i].Title)
|
||||
}
|
||||
return libUUID, library.ID
|
||||
}
|
||||
|
||||
// secondBuilderUser creates a second test user (the "other reader" for
|
||||
// the my-data weld tests) and returns (userID bytes, JWT).
|
||||
func secondBuilderUser(t *testing.T, setup *TestServerSetup) (uuid.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
second, err := setup.DB.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: fmt.Sprintf("testbuilder%d@tests.bookhoard.internal", suffix),
|
||||
Username: fmt.Sprintf("testbuilder%d", suffix),
|
||||
PasswordHash: "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq",
|
||||
FirstName: pgtype.Text{String: "Second", Valid: true},
|
||||
LastName: pgtype.Text{String: "Reader", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteUser(ctx, second.ID) })
|
||||
token := loginWithCredentials(t, setup.Server, second.Email, "Test@Pass123!")
|
||||
secondUUID, err := uuid.FromBytes(second.ID.Bytes[:])
|
||||
require.NoError(t, err)
|
||||
return secondUUID, token
|
||||
}
|
||||
|
||||
func TestFilterComposedSemantics(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sem")
|
||||
|
||||
titles := func(qf string) []string {
|
||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||
status, rows := qfSearch(t, setup, setup.RegularToken, p)
|
||||
// 404 is the endpoint's pinned no-results contract — an empty set.
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
return qfTitles(t, rows)
|
||||
}
|
||||
|
||||
// Plain rule: author contains (fuzzy, per-element).
|
||||
got := titles(qfAll(qfRule("author", "contains", "Voss")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "author contains")
|
||||
|
||||
// Negation as operator: doesn't contain.
|
||||
got = titles(qfAll(qfRule("author", "not_contains", "Voss")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "author not_contains")
|
||||
|
||||
// ALL group = AND.
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
qfRule("genre", "contains", "fantasy"),
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "ALL group")
|
||||
|
||||
// ANY group = OR.
|
||||
got = titles(qfAny(
|
||||
qfRule("genre", "is", "history"),
|
||||
qfRule("abridged", "is_true"),
|
||||
))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "ANY group")
|
||||
|
||||
// Nesting: group ANDed into its parent.
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||
qfRule("genre", "is", "history"),
|
||||
qfRule("page_count", "at_least", "400"),
|
||||
}},
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Stone"}, got, "nested ANY inside ALL")
|
||||
|
||||
// Negated subgroup inside ALL: author Voss AND NOT(genre fantasy).
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||
qfRule("genre", "not_contains", "fantasy"),
|
||||
qfRule("title", "contains", "zzz-no-match"),
|
||||
}},
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Stone"}, got, "negation inside nested group")
|
||||
|
||||
// Array exact + negation.
|
||||
got = titles(qfAll(qfRule("tags", "is", "epic")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Fen"}, got, "tags is")
|
||||
got = titles(qfAll(qfRule("tags", "is_not", "epic")))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Wick"}, got, "tags isn't")
|
||||
|
||||
// Numbers: between / at_most.
|
||||
got = titles(qfAll(qfRule("page_count", "between", "250", "550")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "page_count between")
|
||||
got = titles(qfAll(qfRule("page_count", "at_most", "250")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen"}, got, "page_count at_most")
|
||||
|
||||
// Empty operators.
|
||||
got = titles(qfAll(qfRule("series", "empty")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "series is empty")
|
||||
got = titles(qfAll(qfRule("isbn", "missing")))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "isbn missing")
|
||||
|
||||
// Enums exact + isn't.
|
||||
got = titles(qfAll(qfRule("age_rating", "is", "Teen")))
|
||||
assert.Empty(t, got, "age_rating is (none set)")
|
||||
got = titles(qfAll(qfRule("mime_type", "is", "application/epub+zip")))
|
||||
assert.Len(t, got, 4, "mime_type is")
|
||||
|
||||
// Series full membership: the alternate_info series matches too.
|
||||
ctx := context.Background()
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||
WHERE title = $1`, "Sem Fen")
|
||||
require.NoError(t, err)
|
||||
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
||||
assert.Equal(t, []string{"Sem Fen"}, got, "series exact matches alternate_info membership")
|
||||
|
||||
// Dates: within the last N days (all fixtures created now).
|
||||
got = titles(qfAll(qfRule("added_at", "within_days", "7")))
|
||||
assert.Len(t, got, 4, "added_at within the last 7 days")
|
||||
|
||||
// Identifiers: is / isn't (isn't includes books without one).
|
||||
got = titles(qfAll(qfRule("isbn", "is", "1111111111111")))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "isbn is")
|
||||
got = titles(qfAll(qfRule("isbn", "is_not", "1111111111111")))
|
||||
assert.Len(t, got, 3, "isbn isn't includes books without an ISBN")
|
||||
|
||||
// Presence.
|
||||
got = titles(qfAll(qfRule("cover", "missing")))
|
||||
assert.Len(t, got, 4, "cover missing")
|
||||
|
||||
// Published year (flat year-range semantics: date_published falls
|
||||
// back to copyright_year).
|
||||
got = titles(qfAll(qfRule("published_year", "at_least", "1990")))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
||||
}
|
||||
|
||||
func TestFilterUserRelativeFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userA := getRegularUserID(t, setup.DB)
|
||||
userBUUID, tokenB := secondBuilderUser(t, setup)
|
||||
ctx := context.Background()
|
||||
|
||||
libUUID, _ := builderFixtureLib(t, setup, userA, "Mine")
|
||||
|
||||
// Resolve the fixture media item ids by title.
|
||||
itemID := func(title string) pgtype.UUID {
|
||||
var id pgtype.UUID
|
||||
err := setup.DBPool.QueryRow(ctx,
|
||||
`SELECT id FROM media_items WHERE title = $1`, title).Scan(&id)
|
||||
require.NoError(t, err, "fixture item %s", title)
|
||||
return id
|
||||
}
|
||||
tideID := itemID("Mine Tide")
|
||||
stoneID := itemID("Mine Stone")
|
||||
fenID := itemID("Mine Fen")
|
||||
wickID := itemID("Mine Wick")
|
||||
|
||||
toPg := func(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: id, Valid: true} }
|
||||
aID := toPg(userA)
|
||||
bID := toPg(userBUUID)
|
||||
|
||||
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||
// Stone, time on Wick (2h), collection holding Tide.
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
||||
tideID, aID, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||
tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
||||
aID, wickID, aID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var collID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx,
|
||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
titlesFor := func(token string, qf string) []string {
|
||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||
status, rows := qfSearch(t, setup, token, p)
|
||||
// 404 = the pinned no-results contract (an empty set).
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
return qfTitles(t, rows)
|
||||
}
|
||||
|
||||
// Status: the "my data" weld — same tree, different readers.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "reading"))), "A: Tide is in progress")
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("status", "is", "reading"))), "B: nothing in progress")
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(tokenB, qfAll(qfRule("status", "is", "finished"))), "B finished Tide; A did not")
|
||||
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "unread"))), "A unread")
|
||||
|
||||
// Progress percent.
|
||||
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("progress_percent", "at_least", "60"))))
|
||||
|
||||
// Last read.
|
||||
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("last_read", "within_days", "7"))), 2)
|
||||
|
||||
// My rating: two worlds.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("my_rating", "at_least", "5"))))
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("my_rating", "at_least", "5"))), "B rated 3")
|
||||
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Stone", "Mine Wick"},
|
||||
titlesFor(tokenB, qfAll(qfRule("my_rating", "unrated"))), "B rated only Tide")
|
||||
|
||||
// Review presence.
|
||||
assert.ElementsMatch(t, []string{"Mine Fen"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("review", "has"))))
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("review", "has"))))
|
||||
|
||||
// Notes presence + recency.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("notes", "has"))))
|
||||
|
||||
// Note text: substring pin + the weld. A's note says "catharsis":
|
||||
// searching "catharsis" matches, "Carthage" does NOT (substring,
|
||||
// not fuzzy), and B — no notes — sees nothing either way.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "catharsis"))))
|
||||
assert.Empty(t, titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "Carthage"))),
|
||||
"substring semantics: catharsis ≠ Carthage")
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("note_text", "contains", "catharsis"))),
|
||||
"my data: B's notes are the only notes in B's world")
|
||||
|
||||
// Highlight text.
|
||||
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("highlight_text", "contains", "dark forest"))))
|
||||
|
||||
// Time spent (minutes) + pages read.
|
||||
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("time_spent", "at_least", "60"))))
|
||||
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("pages_read", "at_least", "100"))))
|
||||
|
||||
// Collections: in / in none.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("collection", "in_any"))))
|
||||
assert.Len(t, titlesFor(tokenB, qfAll(qfRule("collection", "in_none"))), 4,
|
||||
"B is in no collections")
|
||||
|
||||
// Library field (holding structure): the fixture library holds all
|
||||
// four books.
|
||||
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("library", "is", libUUID.String()))), 4)
|
||||
}
|
||||
|
||||
func TestFilterSuggestionsUnderRules(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
||||
|
||||
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||
ctx := context.Background()
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Sug Dune",
|
||||
Authors: []string{"Zephyrine Hale"},
|
||||
Genre: pgtype.Text{String: "spacewestern", Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/sug-dune-%d.epub", time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 128, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
suggest := func(field, typed, qf string) (int, []map[string]interface{}) {
|
||||
p := url.Values{"library_id": {libUUID.String()}, field: {typed}, "limit": {"20"}}
|
||||
if qf != "" {
|
||||
p.Set("qf", qf)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var body struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return resp.StatusCode, body.Results
|
||||
}
|
||||
valuesOf := func(results []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
v, _ := r["value"].(string)
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Typed genre menu: both genres surface for their prefixes.
|
||||
status, results := suggest("genre", "fan", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy")
|
||||
|
||||
// The two-worlds example: author contains Voss → the genre menu is
|
||||
// HER genres only; author DOESN'T contain → her-only genres absent.
|
||||
status, results = suggest("genre", "fan", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy")
|
||||
|
||||
// NOTE: "fan" + not_contains STILL lists fantasy — Quill's Fen is
|
||||
// fantasy too; the genre is not author-exclusive. The clean
|
||||
// two-worlds demo is spacewestern (only Hale's book), below.
|
||||
|
||||
// ...and the OTHER world's genre appears only when Voss is excluded.
|
||||
status, results = suggest("genre", "space", qfAll(qfRule("author", "not_contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "spacewestern", "the other world's genre surfaces under negation")
|
||||
status, results = suggest("genre", "space", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
|
||||
|
||||
// Suggestions are symmetric: the genre rule constrains the AUTHOR
|
||||
// menu just the same.
|
||||
status, results = suggest("author", "zeph", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "Zephyrine Hale")
|
||||
status, results = suggest("author", "amara", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered")
|
||||
|
||||
// Library field menu: visibility-scoped, id values + name display
|
||||
// (this test's fixture libraries are named "Test Builder Sug …").
|
||||
status, results = suggest("library", "Test Builder", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if r["value"] == libUUID.String() {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "the visible library is offered")
|
||||
}
|
||||
|
||||
func TestFilterFamilyLens(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
|
||||
makeLib := func(name string) (uuid.UUID, pgtype.UUID) {
|
||||
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Lens %s %d", name, time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "family lens test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
id, err := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||
return id, lib.ID
|
||||
}
|
||||
_, visibleID := makeLib("Visible")
|
||||
hiddenUUID, hiddenID := makeLib("Hidden")
|
||||
|
||||
// One book in each, same author so the All-Books menu would
|
||||
// aggregate both if the lens failed.
|
||||
for _, lib := range []struct {
|
||||
id pgtype.UUID
|
||||
title string
|
||||
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: lib.id, Title: lib.title,
|
||||
Authors: []string{"Lens Secret Author"},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 64, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||
return qfSearch(t, setup, setup.RegularToken, params)
|
||||
}
|
||||
|
||||
// The composed search never sees the hidden library's books —
|
||||
// even when a rule names it explicitly.
|
||||
qfHide := qfAll(qfRule("library", "is", hiddenUUID.String()))
|
||||
status, rows := search(url.Values{"qf": {qfHide}})
|
||||
// 200-with-empty or the 404 no-results contract — either way: nothing.
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
assert.Empty(t, rows, "a rule naming a hidden library matches nothing")
|
||||
|
||||
// Explicit out-of-set scope + a tree: 403 (never silent).
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "contains", "Lens"))}, "library_id": {hiddenUUID.String()}})
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
|
||||
// The library suggestion menu never offers hidden libraries (a
|
||||
// real typed prefix — empty typed values skip the autocomplete
|
||||
// branch entirely).
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+url.Values{
|
||||
"library": {"Test Lens"}, "limit": {"50"},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var body struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
for _, r := range body.Results {
|
||||
assert.NotEqual(t, hiddenUUID.String(), r["value"], "hidden library never offered")
|
||||
}
|
||||
|
||||
// filter-count: out-of-set scope is a 403 too.
|
||||
req, _ = http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?qf="+url.QueryEscape(qfAll(qfRule("author", "contains", "Lens")))+"&library_id="+hiddenUUID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestFilterQFValidationAndPrecedence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Val")
|
||||
|
||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||
return qfSearch(t, setup, setup.RegularToken, params)
|
||||
}
|
||||
|
||||
// Unknown field → 400.
|
||||
status, _ := search(url.Values{"qf": {qfAll(qfRule("not_a_field", "is", "x"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "unknown field is a 400")
|
||||
|
||||
// Unknown operator → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "explodes", "x"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "unknown operator is a 400")
|
||||
|
||||
// Operator not allowed on the field → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "contains", "3"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "operator not allowed on field")
|
||||
|
||||
// between with one value → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "between", "3"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "between needs two values")
|
||||
|
||||
// Malformed JSON → 400.
|
||||
status, _ = search(url.Values{"qf": {"{not json"}})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// SQL injection in a VALUE is just a value (bound parameter): no
|
||||
// error, no match.
|
||||
inj := qfAll(qfRule("author", "contains", "x' OR 1=1 --"))
|
||||
status, rows := search(url.Values{"qf": {inj}})
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status,
|
||||
"injection attempt is a bound value, not SQL")
|
||||
assert.Empty(t, rows)
|
||||
|
||||
// Precedence: a URL carrying BOTH qf and flat params — qf wins.
|
||||
p := url.Values{
|
||||
"library_id": {libUUID.String()},
|
||||
"qf": {qfAll(qfRule("author", "contains", "Voss"))},
|
||||
"author_filter": {"Coraline Dar"},
|
||||
}
|
||||
status, rows = search(p)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.ElementsMatch(t, []string{"Val Tide", "Val Stone"}, qfTitles(t, rows),
|
||||
"qf wins over flat on a hand-crafted URL")
|
||||
|
||||
// Live count endpoint: rules + scope, a true COUNT.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?"+url.Values{
|
||||
"qf": {qfAll(qfRule("author", "contains", "Voss"))}, "library_id": {libUUID.String()},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, 2, body.Count, "live count is the true match count")
|
||||
|
||||
// Empty group → 400 (parse validation).
|
||||
status, _ = search(url.Values{"qf": {`{"mode":"all","children":[]}`}})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
}
|
||||
|
||||
func TestFilterFlatEquivalence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Eqv")
|
||||
scope := url.Values{"library_id": {libUUID.String()}}
|
||||
|
||||
// The tree and its flat deep link return the SAME rows (the smart
|
||||
// serialization promise: flat links keep their semantics).
|
||||
statusFlat, flatRows := qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"author_filter": {"Voss"}}))
|
||||
require.Equal(t, http.StatusOK, statusFlat)
|
||||
statusTree, treeRows := qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("author", "contains", "Voss"))}}))
|
||||
require.Equal(t, http.StatusOK, statusTree)
|
||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows),
|
||||
"tree and flat link return identical rows")
|
||||
|
||||
// Enum: exact flat vs tree is (both may 404-no-results — the empty
|
||||
// set on both sides is still equivalence).
|
||||
statusFlat, flatRows = qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"age_rating_filter": {"Teen"}}))
|
||||
statusTree, treeRows = qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("age_rating", "is", "Teen"))}}))
|
||||
require.Equal(t, statusFlat, statusTree, "same result status")
|
||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
||||
}
|
||||
@@ -507,7 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
statisticsService := services.NewStatisticsService(queries)
|
||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
|
||||
Reference in new issue
Block a user