Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).
- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
survivor dead effort of the rules-builder UI finally has real
endpoints (rules were never editable after creation; nothing ever
re-evaluated them). First rule converts a user collection to smart;
removing the last reverts it to static. UpdateCollectionRulesJSON
edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
visible-library set (family lens enforced at source) + archived IS
NULL AND missing_scan_count = 0 — any enabled rule matches, title
order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
templ whole-attribute interpolation ("{ expr }" inside a quoted attr
value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
rules builder), ⚡ badge + real per-card counts on the grid (smart =
live matches, static = stored-link count), smart detail renders
matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
app's default exclusion, include_auto=true surfaces smart rows with
live counts; TestRules previews against the SAME candidates the live
render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
with NO library_id returned zero books — QueryType != '' caught user
rows ('filter' default) and routed them to dashboard sections that
never contain them; now only real system query types go there
(handlers + SSR). App-visible symptom: /api/collections/:id without
library_id came back books:[] for every static collection.
Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
354 lines
14 KiB
Go
354 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"bookhoard/internal/database"
|
|
)
|
|
|
|
// HTTP helpers -----------------------------------------------------------
|
|
|
|
func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string {
|
|
t.Helper()
|
|
payload := map[string]interface{}{
|
|
"name": name,
|
|
"description": "smart collections test",
|
|
"color": "blue",
|
|
"icon": "📚",
|
|
"collection_type": collectionType,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed")
|
|
|
|
var result map[string]interface{}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
|
return result["id"].(string)
|
|
}
|
|
|
|
func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
var result map[string]interface{}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
|
return result
|
|
}
|
|
|
|
func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response {
|
|
t.Helper()
|
|
payload := map[string]interface{}{
|
|
"field": field,
|
|
"operator": operator,
|
|
"value": value,
|
|
}
|
|
if enabled != nil {
|
|
payload["enabled"] = *enabled
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
func smartDecodeRuleID(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
|
var result map[string]interface{}
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
|
|
rule := result["rule"].(map[string]interface{})
|
|
return rule["id"].(string)
|
|
}
|
|
|
|
// Data helpers -----------------------------------------------------------
|
|
|
|
func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
|
require.NoError(t, err)
|
|
uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano())
|
|
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
|
Name: uniqueName,
|
|
Description: pgtype.Text{String: "smart collections test library", Valid: true},
|
|
LibraryTypeID: libType.ID,
|
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
|
})
|
|
require.NoError(t, err, "should create test library")
|
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
_ = setup.DB.DeleteLibrary(ctx, library.ID)
|
|
})
|
|
|
|
for title, genre := range titlesByGenre {
|
|
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Title: title,
|
|
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
|
Genre: pgtype.Text{String: genre, Valid: true},
|
|
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
|
})
|
|
require.NoError(t, err, "should create test media item")
|
|
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
|
|
require.NoError(t, err)
|
|
bookIDs = append(bookIDs, mediaUUID.String())
|
|
}
|
|
return libUUID.String(), bookIDs
|
|
}
|
|
|
|
func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool {
|
|
t.Helper()
|
|
books, _ := coll["books"].([]interface{})
|
|
set := make(map[string]bool, len(books))
|
|
for _, b := range books {
|
|
if m, ok := b.(map[string]interface{}); ok {
|
|
if title, ok := m["title"].(string); ok {
|
|
set[title] = true
|
|
}
|
|
}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// Tests ------------------------------------------------------------------
|
|
|
|
func TestSmartCollections(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
adminID := getTestUserID(t, setup.DB)
|
|
|
|
t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) {
|
|
_, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
"Smart SF Book One": "Science Fiction",
|
|
"Smart SF Book Two": "Science Fiction",
|
|
"Smart FANT Book": "Fantasy",
|
|
})
|
|
|
|
// Rule evaluation ignores stored links: a static collection's own
|
|
// added item must NOT show up once the collection is smart.
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static")
|
|
|
|
// Add one hand-picked book (a Fantasy one that the smart rule
|
|
// will not match — so it must vanish from the render).
|
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}})
|
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
|
addReq.Header.Set("Content-Type", "application/json")
|
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
addResp, err := http.DefaultClient.Do(addReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
_ = addResp.Body.Close()
|
|
|
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
assert.Equal(t, false, staticDetail["is_smart"])
|
|
assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type")
|
|
assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only")
|
|
|
|
memberTitles := smartLibraryTitleSet(t, smartDetail)
|
|
assert.True(t, memberTitles["Smart SF Book One"])
|
|
assert.True(t, memberTitles["Smart SF Book Two"])
|
|
assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)")
|
|
})
|
|
|
|
t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) {
|
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
"Smart Diss Book": "Science Fiction",
|
|
})
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
ruleID := smartDecodeRuleID(t, resp)
|
|
|
|
assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"])
|
|
|
|
// Disable: membership drops to zero at once.
|
|
falseFlag := false
|
|
dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag})
|
|
dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody))
|
|
dReq.Header.Set("Content-Type", "application/json")
|
|
dReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
dResp, err := http.DefaultClient.Do(dReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, dResp.StatusCode)
|
|
_ = dResp.Body.Close()
|
|
|
|
assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match")
|
|
})
|
|
|
|
t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) {
|
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
"Smart Guard Book": "Science Fiction",
|
|
})
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
|
addReq.Header.Set("Content-Type", "application/json")
|
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
addResp, err := http.DefaultClient.Do(addReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, addResp.StatusCode)
|
|
_ = addResp.Body.Close()
|
|
|
|
removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil)
|
|
removeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
removeResp, err := http.DefaultClient.Do(removeReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusBadRequest, removeResp.StatusCode)
|
|
_ = removeResp.Body.Close()
|
|
})
|
|
|
|
t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) {
|
|
smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
"Smart Back Book": "Science Fiction",
|
|
})
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
ruleID := smartDecodeRuleID(t, resp)
|
|
|
|
smartDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
assert.Equal(t, true, smartDetail["is_smart"])
|
|
|
|
delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil)
|
|
delReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
delResp, err := http.DefaultClient.Do(delReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, http.StatusOK, delResp.StatusCode)
|
|
_ = delResp.Body.Close()
|
|
|
|
staticDetail := smartGetCollection(t, setup, setup.Token, collID)
|
|
assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static")
|
|
|
|
// Membership mutations work again on the reverted static.
|
|
addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}})
|
|
addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody))
|
|
addReq.Header.Set("Content-Type", "application/json")
|
|
addReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
addResp, err := http.DefaultClient.Do(addReq)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
|
_ = addResp.Body.Close()
|
|
})
|
|
|
|
t.Run("RuleValidationAndSystemCollections", func(t *testing.T) {
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil)
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
// System collections may never carry rules.
|
|
colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true})
|
|
require.NoError(t, err)
|
|
var systemID string
|
|
for _, c := range colls {
|
|
if c.IsSystemCollection.Bool {
|
|
systemID = uuid.UUID(c.ID.Bytes).String()
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, systemID, "admin user should have system collections")
|
|
|
|
sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil)
|
|
require.Equal(t, http.StatusBadRequest, sysResp.StatusCode)
|
|
_ = sysResp.Body.Close()
|
|
})
|
|
|
|
t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) {
|
|
_, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{
|
|
"Smart List Book": "Science Fiction",
|
|
})
|
|
collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart")
|
|
|
|
resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil)
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
|
_ = resp.Body.Close()
|
|
|
|
// Default list (the Android app's read): smart collections hidden.
|
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil)
|
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
defaultResp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body)
|
|
var defaultData struct {
|
|
Collections []struct {
|
|
ID string `json:"id"`
|
|
} `json:"collections"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData))
|
|
for _, c := range defaultData.Collections {
|
|
assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)")
|
|
}
|
|
|
|
// Opt-in listing includes them with LIVE counts.
|
|
scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil)
|
|
scopeReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
|
scopeResp, err := http.DefaultClient.Do(scopeReq)
|
|
require.NoError(t, err)
|
|
defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body)
|
|
var scopeData struct {
|
|
Collections []struct {
|
|
ID string `json:"id"`
|
|
BookCount int `json:"book_count"`
|
|
IsSmart bool `json:"is_smart"`
|
|
} `json:"collections"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData))
|
|
found := false
|
|
for _, c := range scopeData.Collections {
|
|
if c.ID == collID {
|
|
found = true
|
|
assert.True(t, c.IsSmart)
|
|
assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live")
|
|
}
|
|
}
|
|
assert.True(t, found, "include_auto=true must surface smart collections")
|
|
})
|
|
}
|