Design correction discovered by the auth chicken-and-egg: the decorator-level refusal was re-created a convention gate (plan B thinking). ScopedDB now NEVER refuses — it declares the GUC when the context carries an identity and declares nothing otherwise; the ENGINE is the fail-loud layer (policies raise on governed tables, ungoverned tables like auth lookups simply work). rls.WithInternal removed as a dead concept. - search service's raw DBTX path now receives the ScopedDB itself (main.go + harness) — a bare pool bypassed the GUC. - database/schema.go: migration tx declares a synthetic identity (schema.sql's dedup DO block SELECTs media_items; with policies live, boot 2 raised 'unrecognized configuration parameter' — A11). - harness: TestServerSetup.DB is a raw-pool SEED handle (write policies permissive, other seeded tables ungoverned); the server under test keeps the scoped wiring. 33 CreateMediaItem sites use seedCtx() — a seed identity with no visibility rows — because RETURNING requires SELECT visibility of the new row. - invariant test: regex UUID-scraping replaced with a structured JSON decode of the sections payload. INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied, confirmed by the tool, and subsequently vanished from disk while an 'opencode serve' process (PID 84551) is alive on the machine. This commit pins the tree against further loss; implementation is PAUSED pending the user killing that process.
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(seedCtx(), database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Title: title,
|
|
Authors: []string{"Smart Rule Author"},
|
|
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")
|
|
})
|
|
}
|