feat: keep the materials index in sync and add bm:materials-reindex
- the materials admin syncs after create and update and removes after delete, once the save transaction commits (UseMaterialIndex, set at Boot) - POST and PUT /api/v1/bm/materials sync after the transaction, DELETE removes the document (its statement has no primary key, so no callback) - bm:materials-reindex [--fresh] [--if-configured] ensures the index and syncs every material; it refuses without an engine and runs nightly at 04:15 with --if-configured - TestMaterialSearchAgainstDatabase (Postgres) is written for the final verification phase
This commit is contained in:
148
material_search_postgres_test.go
Normal file
148
material_search_postgres_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/internal/searchtest"
|
||||
)
|
||||
|
||||
// TestMaterialSearchAgainstDatabase drives the material search end to end
|
||||
// against a migrated database and a Typesense stand-in: every management
|
||||
// write syncs the index, a delete removes the document, and a search whose
|
||||
// candidates include a draft and another user's private material shows a
|
||||
// student only the public one, counted in SQL, with no snippet of the
|
||||
// hidden rows.
|
||||
func TestMaterialSearchAgainstDatabase(t *testing.T) {
|
||||
stub := &searchtest.Stub{}
|
||||
app, plugins, gdb := migratedWith(t, searchtest.Start(t, stub)...)
|
||||
handler, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
managerID := createUser(t, gdb, "manager@example.test")
|
||||
otherID := createUser(t, gdb, "other@example.test")
|
||||
studentID := createUser(t, gdb, "student@example.test")
|
||||
var groupID uint
|
||||
if err := gdb.Raw(`
|
||||
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
||||
VALUES ('BM materials', 'bm-materials', '{"jz.bm.access_materials":1}', NOW(), NOW())
|
||||
RETURNING id`).Scan(&groupID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, managerID, groupID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, token, err := userclasses.MintAPIToken("usr_", managerID, "BM search", []string{"read", "write"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.Create(token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
send := func(method, path string, payload any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if payload != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
if rec.Body.Len() > 0 {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
create := func(payload map[string]any) string {
|
||||
t.Helper()
|
||||
code, out := send(http.MethodPost, "/api/v1/bm/materials", payload)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("POST %v = %d %v", payload, code, out)
|
||||
}
|
||||
return id(obj(out["data"])["id"])
|
||||
}
|
||||
public := create(map[string]any{"title": "Globalizacja publiczna", "type": "notatka", "body": "Tekst o **globalizacji** dla wszystkich."})
|
||||
draft := create(map[string]any{"title": "Globalizacja szkic", "type": "notatka", "body": "szkic", "published_at": nil})
|
||||
private := create(map[string]any{"title": "Globalizacja prywatna", "type": "notatka", "body": "tajne", "is_private": true, "author_user_id": otherID})
|
||||
|
||||
var upserted []string
|
||||
for _, doc := range stub.Imported() {
|
||||
upserted = append(upserted, doc["id"].(string))
|
||||
}
|
||||
for _, want := range []string{public, draft, private} {
|
||||
if !slices.Contains(upserted, want) {
|
||||
t.Errorf("material %s was not upserted (upserts %v)", want, upserted)
|
||||
}
|
||||
}
|
||||
|
||||
extra := create(map[string]any{"title": "Do usunięcia", "type": "link"})
|
||||
if code, out := send(http.MethodDelete, "/api/v1/bm/materials/"+extra, nil); code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE = %d %v", code, out)
|
||||
}
|
||||
if !slices.Contains(stub.Removed(), extra) {
|
||||
t.Errorf("removed = %v, want %s", stub.Removed(), extra)
|
||||
}
|
||||
|
||||
mark := func(word string) string { return searchtest.HighlightStart + word + searchtest.HighlightEnd }
|
||||
stub.SetHits(
|
||||
searchtest.Hit{ID: private, Field: "body", Snippet: mark("tajne")},
|
||||
searchtest.Hit{ID: draft, Field: "body", Snippet: mark("szkic")},
|
||||
searchtest.Hit{ID: public, Field: "body", Snippet: "Tekst o " + mark("globalizacji") + " dla wszystkich."},
|
||||
)
|
||||
student := apiClient{t: t, h: handler, token: tokenFor(t, studentID)}
|
||||
req := httptest.NewRequest(http.MethodGet, "/_bm/api/v1/materials?q=globalizacja", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+student.token)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("search = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
raw := rec.Body.String()
|
||||
for _, hidden := range []string{"tajne", "szkic", "Globalizacja prywatna", "Globalizacja szkic"} {
|
||||
if strings.Contains(raw, hidden) {
|
||||
t.Errorf("search answer leaks %q: %s", hidden, raw)
|
||||
}
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := list(body)
|
||||
if len(rows) != 1 || id(rows[0]["id"]) != public {
|
||||
t.Fatalf("rows = %v, want only %s", rows, public)
|
||||
}
|
||||
if total := obj(body["meta"])["total"]; total != float64(1) {
|
||||
t.Errorf("meta.total = %v, want 1 (the SQL count, not the engine's found)", total)
|
||||
}
|
||||
snippet := obj(rows[0]["snippet"])
|
||||
if snippet["source"] != "body" {
|
||||
t.Errorf("snippet = %v, want the public body snippet", rows[0]["snippet"])
|
||||
}
|
||||
|
||||
// Without q the list never asks the engine.
|
||||
before := len(stub.Searches())
|
||||
plain := student.ok(http.MethodGet, "/_bm/api/v1/materials")
|
||||
if len(stub.Searches()) != before {
|
||||
t.Error("the list without q called the engine")
|
||||
}
|
||||
for _, row := range list(plain) {
|
||||
if _, ok := row["snippet"]; ok {
|
||||
t.Errorf("row without q carries a snippet: %v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user