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) } } }