Files
member-console/internal/integrations/discourse/web/operator_test.go
T
cgalo5758 259c935981 Unify operator integration management
List every provider kind with direct settings and admin links, move
FedWiki
under the integrations route, and add in-shell operator 404s.

Report sync health from Temporal schedule executions and clear one-shot
settings feedback parameters after display.
2026-07-23 00:14:21 -05:00

188 lines
6.6 KiB
Go

package web_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/discoursetest"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/web"
"github.com/google/uuid"
)
// newOperatorFixture builds the operator handler over committed fixtures
// with per-test cleanup (the handler reads through the DB pool).
func newOperatorFixture(t *testing.T) (*http.ServeMux, *discoursetest.Fake, *testing.T) {
t.Helper()
database := testDB(t)
fake := discoursetest.NewFake()
t.Cleanup(fake.Close)
handler, err := web.NewDiscourseOperatorHandler(web.DiscourseOperatorHandlerConfig{
DB: database,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
Client: client.New(client.Config{
BaseURL: fake.URL(), APIKey: "k", APIUsername: "system", RatePerMinute: 60_000,
}),
Configured: true,
TemplatesFS: os.DirFS("../templates"),
SweepScheduleID: "discourse-group-sync",
})
if err != nil {
t.Fatalf("construct operator handler: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /partials/operator/discourse/mappings", handler.CreateMapping)
mux.HandleFunc("DELETE /partials/operator/discourse/mappings/{mappingID}", handler.DeleteMapping)
t.Cleanup(func() {
_, _ = database.ExecContext(context.Background(),
"DELETE FROM discourse.group_mappings")
})
return mux, fake, t
}
func postMapping(t *testing.T, mux *http.ServeMux, resourceKey, groupName string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"resource_key": {resourceKey}, "group_name": {groupName}}
req := httptest.NewRequest(http.MethodPost, "/partials/operator/discourse/mappings",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
// Scenario: mapping a valid non-automatic group succeeds and becomes managed.
func TestOperatorCreateMappingValid(t *testing.T) {
mux, fake, _ := newOperatorFixture(t)
groupName := "valid-" + uuid.New().String()[:8]
gid := fake.AddGroup(groupName, false)
rec := postMapping(t, mux, "discourse_posting", groupName)
if rec.Code != http.StatusOK {
t.Fatalf("create = %d, want 200; body: %s", rec.Code, rec.Body.String()[:min(200, rec.Body.Len())])
}
if !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
t.Error("expected success toast header")
}
database := testDB(t)
mapping, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName)
if err != nil {
t.Fatalf("mapping not stored: %v", err)
}
if mapping.DiscourseGroupID != gid || mapping.ResourceKey != "discourse_posting" {
t.Errorf("stored mapping %+v", mapping)
}
// The re-rendered body lists the mapping.
if !strings.Contains(rec.Body.String(), groupName) {
t.Error("response body does not show the new mapping")
}
}
// Scenario: mapping a nonexistent group is rejected with an error naming the
// problem and stores nothing.
func TestOperatorCreateMappingUnknownGroup(t *testing.T) {
mux, _, _ := newOperatorFixture(t)
groupName := "ghost-" + uuid.New().String()[:8]
rec := postMapping(t, mux, "discourse_posting", groupName)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("create = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No group with that name") {
t.Error("422 body lacks the unknown-group message")
}
database := testDB(t)
if _, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName); err == nil {
t.Error("mapping was stored despite validation failure")
}
}
// Automatic groups are rejected (their membership is Discourse-computed).
func TestOperatorCreateMappingAutomaticGroup(t *testing.T) {
mux, fake, _ := newOperatorFixture(t)
groupName := "auto-" + uuid.New().String()[:8]
fake.AddGroup(groupName, true)
rec := postMapping(t, mux, "discourse_posting", groupName)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("create = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "automatic") {
t.Error("422 body lacks the automatic-group message")
}
}
// Duplicate group mapping translates the unique violation to a field error.
func TestOperatorCreateMappingDuplicate(t *testing.T) {
mux, fake, _ := newOperatorFixture(t)
groupName := "dup-" + uuid.New().String()[:8]
fake.AddGroup(groupName, false)
if rec := postMapping(t, mux, "discourse_posting", groupName); rec.Code != http.StatusOK {
t.Fatalf("first create = %d, want 200", rec.Code)
}
rec := postMapping(t, mux, "discourse_posting", groupName)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("duplicate create = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "already managed") {
t.Error("422 body lacks the duplicate-mapping message")
}
}
// Scenario: unmapping stops management and leaves forum membership as-is.
func TestOperatorDeleteMapping(t *testing.T) {
mux, fake, _ := newOperatorFixture(t)
groupName := "del-" + uuid.New().String()[:8]
gid := fake.AddGroup(groupName, false)
memberUID := fake.AddUser(discoursetest.FakeUser{Username: "stays", Email: "s@example.com", Active: true})
fake.AddMember(gid, memberUID)
if rec := postMapping(t, mux, "discourse_posting", groupName); rec.Code != http.StatusOK {
t.Fatalf("create = %d, want 200", rec.Code)
}
database := testDB(t)
mapping, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodDelete, "/partials/operator/discourse/mappings/"+mapping.MappingID, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delete = %d, want 200", rec.Code)
}
if _, err := dcmod.New(database).GetGroupMappingByGroupName(context.Background(), groupName); err == nil {
t.Error("mapping still present after delete")
}
// Forum-side membership untouched by unmapping.
if len(fake.Members(gid)) != 1 {
t.Error("unmapping modified forum-side membership")
}
// Deleting a missing mapping is a 404.
req = httptest.NewRequest(http.MethodDelete, "/partials/operator/discourse/mappings/"+mapping.MappingID, nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("re-delete = %d, want 404", rec.Code)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}