diff --git a/internal/handlers/collections_rules_test.go b/internal/handlers/collections_rules_test.go new file mode 100644 index 0000000..25deab0 --- /dev/null +++ b/internal/handlers/collections_rules_test.go @@ -0,0 +1,415 @@ +package handlers + +import ( + "testing" + + "bookmann/internal/database" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" +) + +// Test collection rule matching with various operators + +func TestEvaluateRule_Equals_StringMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + result := handler.evaluateRule(item, "genre", "equals", "Science Fiction") + assert.True(t, result) +} + +func TestEvaluateRule_Equals_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Fantasy", Valid: true}, + } + + result := handler.evaluateRule(item, "genre", "equals", "Science Fiction") + assert.False(t, result) +} + +func TestEvaluateRule_NotEquals_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Fantasy", Valid: true}, + } + + result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction") + assert.True(t, result) +} + +func TestEvaluateRule_NotEquals_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + result := handler.evaluateRule(item, "genre", "not_equals", "Science Fiction") + assert.False(t, result) +} + +func TestEvaluateRule_Contains_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Author: pgtype.Text{String: "Isaac Asimov", Valid: true}, + } + + result := handler.evaluateRule(item, "author", "contains", "asimov") + assert.True(t, result) +} + +func TestEvaluateRule_Contains_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Author: pgtype.Text{String: "Isaac Asimov", Valid: true}, + } + + result := handler.evaluateRule(item, "author", "contains", "clarke") + assert.False(t, result) +} + +func TestEvaluateRule_NotContains_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Author: pgtype.Text{String: "Isaac Asimov", Valid: true}, + } + + result := handler.evaluateRule(item, "author", "not_contains", "clarke") + assert.True(t, result) +} + +func TestEvaluateRule_StartsWith_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Series: pgtype.Text{String: "Foundation", Valid: true}, + } + + result := handler.evaluateRule(item, "series", "starts_with", "Foun") + assert.True(t, result) +} + +func TestEvaluateRule_StartsWith_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Series: pgtype.Text{String: "Foundation", Valid: true}, + } + + result := handler.evaluateRule(item, "series", "starts_with", "Robots") + assert.False(t, result) +} + +func TestEvaluateRule_EndsWith_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Series: pgtype.Text{String: "Foundation", Valid: true}, + } + + result := handler.evaluateRule(item, "series", "ends_with", "tion") + assert.True(t, result) +} + +func TestEvaluateRule_GreaterThan_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true}, + } + + result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000") + assert.True(t, result) +} + +func TestEvaluateRule_GreaterThan_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true}, + } + + result := handler.evaluateRule(item, "copyright_year", "greater_than", "2000") + assert.False(t, result) +} + +func TestEvaluateRule_LessThan_Matches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + CopyrightYear: pgtype.Int4{Int32: 1999, Valid: true}, + } + + result := handler.evaluateRule(item, "copyright_year", "less_than", "2000") + assert.True(t, result) +} + +func TestEvaluateRule_LessThan_NoMatch(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true}, + } + + result := handler.evaluateRule(item, "copyright_year", "less_than", "2000") + assert.False(t, result) +} + +func TestEvaluateRule_NullField(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{Valid: false}, // NULL + } + + result := handler.evaluateRule(item, "genre", "equals", "Science Fiction") + assert.False(t, result, "Should return false for NULL fields") +} + +func TestCompareValues_CaseInsensitive(t *testing.T) { + handler := &CollectionHandler{} + + result := handler.compareValues("Science Fiction", "contains", "science fiction") + assert.True(t, result, "Contains should be case-insensitive") + + result = handler.compareValues("Science Fiction", "contains", "SCIENCE FICTION") + assert.True(t, result) +} + +func TestCompareValues_EmptyString(t *testing.T) { + handler := &CollectionHandler{} + + result := handler.compareValues("", "contains", "test") + assert.False(t, result, "Empty string should not contain anything") + + result = handler.compareValues("test", "contains", "") + assert.True(t, result, "Non-empty string contains empty string") +} + +func TestCompareValues_NumericEdgeCases(t *testing.T) { + handler := &CollectionHandler{} + + result := handler.compareValues("0", "greater_than", "-1") + assert.True(t, result) + + result = handler.compareValues("9999", "less_than", "10000") + assert.True(t, result) +} + +// Test rule checking with multiple rules + +func TestCheckRulesAgainstBook_MatchesFirstRule(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + rules := []map[string]interface{}{ + { + "field": "genre", + "operator": "equals", + "value": "Science Fiction", + }, + { + "field": "author", + "operator": "contains", + "value": "Asimov", + }, + } + + reason := handler.checkRulesAgainstBook(item, rules) + assert.NotEmpty(t, reason, "Should match at least one rule") + assert.Contains(t, reason, "genre") +} + +func TestCheckRulesAgainstBook_MatchesSecondRule(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Fantasy", Valid: true}, + Author: pgtype.Text{String: "Isaac Asimov", Valid: true}, + CopyrightYear: pgtype.Int4{Int32: 2021, Valid: true}, + } + + rules := []map[string]interface{}{ + { + "field": "genre", + "operator": "equals", + "value": "Science Fiction", + }, + { + "field": "author", + "operator": "contains", + "value": "Asimov", + }, + } + + reason := handler.checkRulesAgainstBook(item, rules) + assert.NotEmpty(t, reason, "Should match second rule") + assert.Contains(t, reason, "author") +} + +func TestCheckRulesAgainstBook_NoMatches(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Mystery", Valid: true}, + Author: pgtype.Text{String: "Agatha Christie", Valid: true}, + CopyrightYear: pgtype.Int4{Int32: 1950, Valid: true}, + } + + rules := []map[string]interface{}{ + { + "field": "genre", + "operator": "equals", + "value": "Science Fiction", + }, + { + "field": "author", + "operator": "contains", + "value": "Asimov", + }, + } + + reason := handler.checkRulesAgainstBook(item, rules) + assert.Empty(t, reason, "Should not match any rules") +} + +func TestCheckRulesAgainstBook_EmptyRules(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + rules := []map[string]interface{}{} + + reason := handler.checkRulesAgainstBook(item, rules) + assert.Empty(t, reason, "Empty rules should return empty reason") +} + +// Test complex rule scenarios + +func TestComplexRule_MultipleConditions(t *testing.T) { + handler := &CollectionHandler{} + + // Book: "Foundation" by Isaac Asimov, Sci-Fi, 1951 + item := database.ListMediaItemsRow{ + Title: "Foundation", + Author: pgtype.Text{String: "Isaac Asimov", Valid: true}, + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + CopyrightYear: pgtype.Int4{Int32: 1951, Valid: true}, + Series: pgtype.Text{String: "Foundation", Valid: true}, + } + + tests := []struct { + name string + field string + operator string + value string + expected bool + }{ + { + name: "Match genre exactly", + field: "genre", + operator: "equals", + value: "Science Fiction", + expected: true, + }, + { + name: "Match author substring", + field: "author", + operator: "contains", + value: "asimov", + expected: true, + }, + { + name: "Match year greater than", + field: "copyright_year", + operator: "greater_than", + value: "1950", + expected: true, + }, + { + name: "Match year less than", + field: "copyright_year", + operator: "less_than", + value: "2000", + expected: true, + }, + { + name: "Match series starts with", + field: "series", + operator: "starts_with", + value: "Found", + expected: true, + }, + { + name: "Match series ends with", + field: "series", + operator: "ends_with", + value: "tion", + expected: true, + }, + { + name: "No match for genre", + field: "genre", + operator: "equals", + value: "Fantasy", + expected: false, + }, + { + name: "No match for author", + field: "author", + operator: "contains", + value: "Tolkien", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := handler.evaluateRule(item, tt.field, tt.operator, tt.value) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test edge cases and error handling + +func TestEvaluateRule_InvalidOperator(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + result := handler.evaluateRule(item, "genre", "invalid_operator", "Science Fiction") + assert.False(t, result, "Invalid operator should return false") +} + +func TestEvaluateRule_NonExistentField(t *testing.T) { + handler := &CollectionHandler{} + item := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + result := handler.evaluateRule(item, "nonexistent_field", "equals", "value") + assert.False(t, result, "Non-existent field should return false") +} + +func TestCompareValues_InvalidOperator(t *testing.T) { + handler := &CollectionHandler{} + + result := handler.compareValues("test", "invalid_op", "value") + assert.False(t, result, "Invalid comparison operator should return false") +} + +func TestCompareValues_NumericConversion(t *testing.T) { + handler := &CollectionHandler{} + + // Valid numeric strings + result := handler.compareValues("100", "greater_than", "99") + assert.True(t, result) + + result = handler.compareValues("99", "less_than", "100") + assert.True(t, result) + + // Invalid numeric strings (should return false) + result = handler.compareValues("abc", "greater_than", "100") + assert.False(t, result, "Non-numeric string should not compare as greater than") + + result = handler.compareValues("100", "greater_than", "xyz") + assert.False(t, result, "Non-numeric value should not allow comparison") +}