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:
John O'Keefe committed 2026-10-20 14:49:50 -04:00
1 parent ebb7f4a5b0
commit 4ee24e7152
18 files changed
+3788 -376

No files matched your search

+1 -1
View File
@@ -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)
+718
View File
@@ -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))
}
+1 -1
View File
@@ -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