fix(filters): blank-typed browse 500 on id-valued fields (library/collection/added_by)

The browse-on-click blank sentinel was trimmed to empty (correct), but
the typed query had already been bound unconditionally — the id-source
suggestion query has no score CASE to reference it, leaving an unused
parameter Postgres rejects (42P18). Every click of an empty library /
collection / added-by box answered 'No matching values' via the error
path.

- ComposedFieldValues binds the typed query ONLY in branches that
  reference it and only when non-blank; the score expression is
  '0 AS score' for browse (no param) and the similarity CASE when
  typing. composedIDSourceValues takes the raw typed string and binds
  it only when the name filter is built. userBind already followed
  this discipline; audited every bind site in the file (fuzzy helpers,
  composed search, count) — all interpolate immediately.
- Tests: library browse under rules (exactly the surviving library,
  count pinned), series browse blank, added_by browse blank (no
  error), collection browse blank (offered with count) and under a
  zero-match rule (never offered). Verified end-to-end against the
  live dev data: author+Sapkowski AND narrator+Kenny → Audiobooks (8).
This commit is contained in:
John O'Keefe committed 2026-10-20 16:44:50 -04:00
1 parent 9c006a9a2f
commit ab713e2feb
2 files changed
+80 -14

No files matched your search

+52
View File
@@ -547,6 +547,58 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
}
}
assert.True(t, found, "the visible library is offered")
// BROWSE the library field UNDER rules (the blank-typed click): the
// id-source query must not choke on the unused typed parameter and
// must list exactly the library holding the surviving books.
status, results = suggest("library", " ", qfAll(qfRule("author", "contains", "Voss")))
require.Equal(t, http.StatusOK, status, "library browse under rules (blank typed)")
require.Len(t, results, 1, "exactly the fixture library survives the rules")
assert.Equal(t, libUUID.String(), results[0]["value"])
assert.InDelta(t, float64(2), results[0]["count"], 0.001, "both Voss books live there")
// Series browse (blank typed, under rules): full-membership source.
status, results = suggest("series", " ", qfAll(qfRule("author", "contains", "Voss")))
require.Equal(t, http.StatusOK, status)
assert.Contains(t, valuesOf(results), "Voss Saga")
// Added-by browse (blank typed): books without added_by → an empty
// menu, but NO error (the parameter-discipline regression net).
status, results = suggest("added_by", " ", "")
require.Equal(t, http.StatusOK, status)
// Collection browse (blank typed): the container sources ride the
// same id-source query — a collection holding a surviving book is
// offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
var tideID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status)
assert.True(t, func() bool {
for _, r := range results {
if r["value"] == collID.String() {
return true
}
}
return false
}(), "the collection holding a book is offered on browse")
status, results = suggest("collection", " ", qfAll(qfRule("genre", "is", "spacewestern")))
require.Equal(t, http.StatusOK, status)
assert.Empty(t, results, "zero-match containers never offered")
}
func TestFilterFamilyLens(t *testing.T) {