Files
bookhoard/internal/services/collection_service_test.go
T
John O'Keefe 6b2125a45a feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
collections gains filter_tree JSONB (idempotent in-place; same qf DSL
as saved filters — the scope key included). IsCollectionSmart is the
ONE detection (tree OR legacy rules), and EvaluateSmartMembership is
the ONE evaluator: tree-bearing collections run the composed engine
via ComposedLiveMedia (full media rows, so the collection surfaces
keep their episode-card stamping, feed positions for the replay
chain, and the family lens rides the mandatory head), while legacy
rule arrays keep per-item FieldMatches until rewritten — one call
surface, two storage epochs. All smart surfaces (detail JSON, counts,
detail page, reader chain) dispatch through it; the search service is
injected into the collection subsystem (variadic — main.go builds one
shared instance; the test harness mirrors it).

Write path: CreateCollection/UpdateCollection accept the tree,
validated against the engine grammar at the HANDLER (an invalid tree
is a 400, never a stored render-time 500) and exposed in the envelope
(pin widened in TestContractPin_CollectionsJSON + changelog). Update
semantics: an edit form that doesn't send a tree keeps the stored one.

RulesToFilterTree converts legacy rules + match mode at upgrade time:
episode rules map mechanically (feed→in-list, played/downloaded→
booleans, published_within→published_date+within_days), tree root
mode absorbs rule_match_mode, episode rules force the episodes scope,
and legacy starts_with/ends_with fall back to contains (documented).

Read-compat by design: nothing is migrated at boot — legacy arrays
evaluate exactly as before until the collection is rewritten.

Pinned by TestRulesToFilterTree (unit conversion) +
TestEpisodeCollectionsFilterTree (a tree collection evaluates through
the engine, the chain follows the tree's live membership, invalid
trees 400 at write, the tree wins when legacy rules coexist, and an
edit without a tree keeps it).
2026-10-30 19:02:49 -04:00

324 lines
8.4 KiB
Go

package services
import (
"bookhoard/internal/database"
"encoding/json"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEvaluateRules_Equality(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 5,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if !evaluations[0].Matches {
t.Errorf("expected rule to match, but it didn't")
}
if evaluations[0].Confidence <= 0.85 {
t.Errorf("expected confidence > 0.85, got %f", evaluations[0].Confidence)
}
}
func TestEvaluateRules_NoMatch(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Genre: pgtype.Text{String: "Fantasy", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 5,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if evaluations[0].Matches {
t.Errorf("expected rule not to match, but it did")
}
if evaluations[0].Confidence != 0 {
t.Errorf("expected confidence 0, got %f", evaluations[0].Confidence)
}
}
func TestEvaluateRules_Contains(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Series: pgtype.Text{String: "The Expanse", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "series",
Operator: "contains",
Value: "Expanse",
Priority: 7,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if !evaluations[0].Matches {
t.Errorf("expected rule to match, but it didn't")
}
if evaluations[0].Confidence <= 0.9 {
t.Errorf("expected confidence > 0.9 with priority boost, got %f", evaluations[0].Confidence)
}
}
func TestEvaluateRules_PriorityOrder(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
Series: pgtype.Text{String: "Foundation", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 5,
},
{
ID: "rule-2",
Field: "series",
Operator: "equals",
Value: "Foundation",
Priority: 9,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
}
if evaluations[0].RuleID != "rule-2" {
t.Errorf("expected rule-2 (higher priority) to be first, got %s", evaluations[0].RuleID)
}
if evaluations[1].RuleID != "rule-1" {
t.Errorf("expected rule-1 (lower priority) to be second, got %s", evaluations[1].RuleID)
}
if evaluations[0].Confidence <= evaluations[1].Confidence {
t.Errorf("expected higher priority rule to have higher confidence")
}
}
func TestEvaluateRules_CopyrightYear(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
CopyrightYear: pgtype.Int4{Int32: 2020, Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "copyright_year",
Operator: "greater_than",
Value: "2019",
Priority: 5,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if !evaluations[0].Matches {
t.Errorf("expected rule to match (2020 > 2019), but it didn't")
}
}
func TestEvaluateRules_MissingField(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 5,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if evaluations[0].Matches {
t.Errorf("expected rule not to match when field is missing, but it did")
}
}
func TestEvaluateRules_CaseInsensitive(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Genre: pgtype.Text{String: "science fiction", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "SCIENCE FICTION",
Priority: 5,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 1 {
t.Fatalf("expected 1 evaluation, got %d", len(evaluations))
}
if !evaluations[0].Matches {
t.Errorf("expected case-insensitive match, but it didn't match")
}
}
func TestEvaluateRules_PriorityBoost(t *testing.T) {
service := &CollectionService{}
mediaItem := database.MediaItems{
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
}
rules := []Rule{
{
ID: "rule-1",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 1,
},
{
ID: "rule-2",
Field: "genre",
Operator: "equals",
Value: "Science Fiction",
Priority: 10,
},
}
evaluations := service.EvaluateRules(mediaItem, 0, rules)
if len(evaluations) != 2 {
t.Fatalf("expected 2 evaluations, got %d", len(evaluations))
}
// Higher priority (10) should give higher confidence than lower priority (1)
// Genre base confidence is 0.9
// Priority 1: 0.9 + 0.01 = 0.91
// Priority 10: 0.9 + 0.1 = 1.0 (capped)
if evaluations[0].Confidence != 1.0 {
t.Errorf("expected higher priority confidence to be 1.0, got %f", evaluations[0].Confidence)
}
if evaluations[1].Confidence != 0.91 {
t.Errorf("expected lower priority confidence to be 0.91, got %f", evaluations[1].Confidence)
}
// The higher priority rule should come first (sorted by priority descending)
if evaluations[0].RuleID != "rule-2" {
t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID)
}
}
func TestRulesToFilterTree(t *testing.T) {
t.Run("episode_rules_map_mechanically", func(t *testing.T) {
rules := []Rule{
{ID: "1", Field: "feed", Operator: "in", Value: "feed-a, feed-b"},
{ID: "2", Field: "played", Operator: "equals", Value: "false"},
{ID: "3", Field: "downloaded", Operator: "equals", Value: "true"},
{ID: "4", Field: "published_within", Operator: "greater_than", Value: "7"},
}
tree, err := RulesToFilterTree(rules, RuleMatchAll)
require.NoError(t, err)
assert.Equal(t, RuleMatchAll, tree.Mode, "the collection's match mode becomes the tree mode")
assert.Equal(t, ScopeEpisodes, tree.Scope, "episode rules force the episodes scope")
require.Len(t, tree.Children, 4)
assert.Equal(t, FilterChild{Field: "feed", Op: OpIn, Values: []string{"feed-a", "feed-b"}}, tree.Children[0])
assert.Equal(t, FilterChild{Field: "played", Op: OpIsFalse}, tree.Children[1])
assert.Equal(t, FilterChild{Field: "downloaded", Op: OpIsTrue}, tree.Children[2])
assert.Equal(t, FilterChild{Field: "published_date", Op: OpWithinDays, Values: []string{"7"}}, tree.Children[3])
// The converted tree parses under the strict engine.
b, _ := json.Marshal(tree)
_, err = ParseFilterTree(string(b))
require.NoError(t, err, "converted tree must satisfy the engine grammar")
})
t.Run("book_rules_map_and_stay_books_scope", func(t *testing.T) {
rules := []Rule{
{Field: "genre", Operator: "equals", Value: "fantasy"},
{Field: "author", Operator: "contains", Value: "Voss"},
{Field: "copyright_year", Operator: "greater_than", Value: "1990"},
{Field: "tags", Operator: "starts_with", Value: "dyst"},
}
tree, err := RulesToFilterTree(rules, RuleMatchAny)
require.NoError(t, err)
assert.Equal(t, "", tree.Scope, "no episode rules → the books scope (legacy default)")
assert.Equal(t, RuleMatchAny, tree.Mode)
assert.Equal(t, FilterChild{Field: "genre", Op: OpIs, Values: []string{"fantasy"}}, tree.Children[0])
assert.Equal(t, FilterChild{Field: "copyright_year", Op: OpAtLeast, Values: []string{"1990"}}, tree.Children[2])
// Documented fallback: rules ops without an engine equivalent
// (starts_with/ends_with) become contains.
assert.Equal(t, OpContains, tree.Children[3].Op)
})
}