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.
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"bookhoard/internal/database"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestPreviewCollection(t *testing.T) {
|
|
setup := setupDeviceTest(t)
|
|
defer setup.Server.Close()
|
|
|
|
libraryID := setup.CreateLibrary(t, "Preview Test Library", "ebooks")
|
|
bookIDs := createPreviewTestMediaItems(t, setup.DB, libraryID, 5)
|
|
|
|
t.Run("NoAuth_ReturnsUnauthorized", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
|
})
|
|
|
|
t.Run("MissingLibraryID_ReturnsBadRequest", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": "",
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("InvalidLibraryID_ReturnsBadRequest", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": "not-a-valid-uuid",
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("NonExistentLibrary_ReturnsBadRequest", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": uuid.New().String(),
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
|
|
t.Run("EmptyRules_ReturnsEmptyItems", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
|
})
|
|
|
|
t.Run("ManualBookSelection_ReturnsSelectedBooks", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
bookIDs[0],
|
|
bookIDs[2],
|
|
},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
|
})
|
|
|
|
t.Run("RuleFiltering_ReturnsMatchingBooks", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{
|
|
{
|
|
"id": "rule-1",
|
|
"field": "genre",
|
|
"operator": "equals",
|
|
"value": "Science Fiction",
|
|
"priority": 5,
|
|
},
|
|
},
|
|
"manual_book_ids": []string{},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
|
})
|
|
|
|
t.Run("CombinedRulesAndManualSelection_ReturnsAllMatches", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{
|
|
{
|
|
"id": "rule-1",
|
|
"field": "genre",
|
|
"operator": "equals",
|
|
"value": "Science Fiction",
|
|
"priority": 5,
|
|
},
|
|
},
|
|
"manual_book_ids": []string{bookIDs[4]},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
|
})
|
|
|
|
t.Run("LimitRespected_ReturnsLimitedItems", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
bookIDs[0],
|
|
bookIDs[1],
|
|
bookIDs[2],
|
|
bookIDs[3],
|
|
bookIDs[4],
|
|
},
|
|
"limit": 2,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
|
})
|
|
|
|
t.Run("LimitExceedsMax_DefaultsToTwenty", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
bookIDs[0],
|
|
},
|
|
"limit": 150,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
|
})
|
|
|
|
t.Run("LimitZero_DefaultsToTwenty", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
bookIDs[0],
|
|
},
|
|
"limit": 0,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
|
})
|
|
|
|
t.Run("InvalidManualBookID_SkippedGracefully", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
"not-a-valid-uuid",
|
|
bookIDs[0],
|
|
},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
|
})
|
|
|
|
t.Run("DuplicateManualBookIDs_ReturnsUniqueItems", func(t *testing.T) {
|
|
reqBody := map[string]interface{}{
|
|
"library_id": libraryID,
|
|
"rules": []map[string]interface{}{},
|
|
"manual_book_ids": []string{
|
|
bookIDs[0],
|
|
bookIDs[0],
|
|
bookIDs[0],
|
|
},
|
|
"limit": 20,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(rec.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
|
|
items := result["items"].([]interface{})
|
|
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
|
})
|
|
|
|
t.Run("InvalidJSON_ReturnsBadRequest", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewBuffer([]byte("invalid json")))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
|
rec := httptest.NewRecorder()
|
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
|
})
|
|
}
|
|
|
|
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
|
|
ctx := context.Background()
|
|
|
|
libUUID, err := uuid.Parse(libraryID)
|
|
require.NoError(t, err, "Should parse library ID")
|
|
|
|
var bookIDs []string
|
|
|
|
genres := []string{"Science Fiction", "Fantasy", "Mystery", "Science Fiction", "Romance"}
|
|
|
|
for i := 0; i < count; i++ {
|
|
genre := genres[i%len(genres)]
|
|
|
|
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
Title: "Preview Test Book " + string(rune('A'+i)),
|
|
Authors: []string{"Test Author " + string(rune('A'+i))},
|
|
Genre: pgtype.Text{String: genre, Valid: true},
|
|
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
|
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
|
})
|
|
require.NoError(t, err, "Should create media item")
|
|
|
|
mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16])
|
|
require.NoError(t, err, "Should parse media UUID")
|
|
|
|
bookIDs = append(bookIDs, mediaUUID.String())
|
|
}
|
|
|
|
return bookIDs
|
|
}
|