test(collections): add comprehensive rule evaluation test suite
Add extensive unit tests for collection rule matching logic: Test Coverage (30+ tests): 1. Rule Evaluation Tests: - Equals operator (match and no match) - Not equals operator (match and no match) - Contains operator (case-insensitive) - Not contains operator - Starts with operator - Ends with operator - Greater than operator (numeric) - Less than operator (numeric) - NULL field handling 2. Comparison Function Tests: - Case-insensitive string matching - Empty string edge cases - Numeric edge cases (0, negative, large numbers) - Type conversion validation 3. Multi-Rule Tests: - Matches first rule - Matches second rule (first fails) - No matches across all rules - Empty rules array 4. Complex Rule Scenarios: - Multiple conditions on same book - Different field types (genre, author, year, series) - Various operators tested - Table-driven test for 8 scenarios 5. Edge Cases and Error Handling: - Invalid operator returns false - Non-existent field returns false - Invalid numeric strings handled - Type conversion failures Test Structure: - Clear test names explaining what's being tested - Assertion messages explain expected vs actual - Uses testify/assert for better error messages - Table-driven tests for multiple scenarios - Comprehensive edge case coverage Code Coverage: - evaluateRule() function - compareValues() function - checkRulesAgainstBook() function - All operators: equals, not_equals, contains, not_contains, starts_with, ends_with, greater_than, less_than - All field types: genre, author, series, copyright_year Test Results: - All 30+ tests passing - Coverage of critical collection rule logic - Prevents regressions in rule matching - Validates edge case handling This test suite ensures the collection auto-assignment feature works correctly for all supported rule types and operators.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user