Files
MindSift/internal/api/server_test.go
T
sandrodach 1310f8102e
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
feat(workspace): add hashtag indexing, tag pages and bullet group view
2026-10-25 12:04:53 +01:00

714 lines
25 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("failed to ensure test user: %v", err)
}
_ = store.SeedStarterContent(user)
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
return srv, store, token
}
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create fresh store: %v", err)
}
defer store.Close()
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Fresh DB must report needsSetup = true
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
}
var statusResp struct {
NeedsSetup bool `json:"needsSetup"`
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=true on fresh database, got false")
}
// 2. Complete First-Boot Setup (without sample data)
setupBody := []byte(`{
"email": "owner@example.com",
"password": "secretpassword",
"displayName": "Server Owner",
"instanceName": "My MindSift",
"allowRegistration": false,
"sharingMode": "teams_only",
"seedSampleData": false
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String())
}
var setupRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
if setupRes.User.Role != "admin" || setupRes.Token == "" {
t.Fatalf("expected admin user and token, got %+v", setupRes)
}
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
arrayEndpoints := []string{
"/api/v1/task-lists",
"/api/v1/tasks",
"/api/v1/organizations",
"/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists",
}
for _, ep := range arrayEndpoints {
req = httptest.NewRequest(http.MethodGet, ep, nil)
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", ep, rec.Code)
}
trimmed := strings.TrimSpace(rec.Body.String())
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
}
}
// 4. Public registration without invite must be rejected (403)
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
}
// 5. Create an Invite as Admin and register with that Invite Code
invBody := []byte(`{"role":"team_admin","validDays":7}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
}
var createdInvite domain.UserInvite
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
if createdInvite.Code == "" {
t.Fatalf("expected invite code, got empty")
}
regWithInviteBody, _ := json.Marshal(map[string]string{
"email": "invited@example.com",
"password": "password123",
"displayName": "Invited Team Admin",
"inviteCode": createdInvite.Code,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
}
var invitedRes struct {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
if invitedRes.User.Role != "team_admin" {
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
}
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
endpoints := []string{
"/api/v1/auth/me",
"/api/v1/triage/deck",
"/api/v1/lists",
"/api/v1/spaces",
"/api/v1/tasks",
"/api/v1/documents",
}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
for _, ep := range endpoints {
wg.Add(1)
go func(path string) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String())
}
}(ep)
}
}
wg.Wait()
}
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
}
// 1. Capture Quick Dump (default target: inbox)
dumpPayload := map[string]string{
"content": "Schneller Quick Dump Testgedanke #test",
"priority": "high",
"target": "inbox",
}
body, _ := json.Marshal(dumpPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String())
}
var createdDump domain.Thought
if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil {
t.Fatalf("failed to decode created dump: %v", err)
}
if createdDump.Status != domain.StatusInbox {
t.Fatalf("expected status 'inbox', got %q", createdDump.Status)
}
// 2. Capture Direct Task (target: task)
taskPayload := map[string]string{
"content": "Direkte Aufgabe ohne Triage",
"priority": "urgent",
"target": "task",
"dueDate": "2026-11-01",
}
body, _ = json.Marshal(taskPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String())
}
var createdTask domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdTask)
if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" {
t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType)
}
// 3. Capture Direct List Item (target: list_item)
defUser, _ := store.EnsureDefaultUser()
lists, err := store.GetAllListsForUser(defUser.ID)
if err != nil || len(lists) == 0 {
t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err)
}
targetListID := lists[0].ID
listPayload := map[string]string{
"content": "Frische Haferflocken",
"target": "list_item",
"targetListId": targetListID,
}
body, _ = json.Marshal(listPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String())
}
var createdListItemThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought)
if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" {
t.Fatalf("expected converted list_item, got %+v", createdListItemThought)
}
// 4. Swipe Triage card to Task
triagePayload := map[string]string{
"action": "keep",
"convertTo": "task",
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNewFullFeatures(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
// 1. Test TaskLists: Create & Get
taskListPayload := map[string]string{
"title": "Homelab & Docker",
"icon": "🐳",
"color": "#3b82f6",
}
body, _ := json.Marshal(taskListPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-lists", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for TaskList, got %d: %s", rec.Code, rec.Body.String())
}
var createdTaskList domain.TaskList
_ = json.Unmarshal(rec.Body.Bytes(), &createdTaskList)
if createdTaskList.Title != "Homelab & Docker" {
t.Fatalf("expected task list title 'Homelab & Docker', got %q", createdTaskList.Title)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/task-lists", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
}
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
settingsPayload := map[string]interface{}{
"displayName": "Admin User",
"alarmChannels": []string{"email", "discord", "webhook"},
"alarmEmail": "alarms@example.com",
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
"customWebhookUrl": "https://ntfy.sh/my-topic",
"notifyBeforeMinutes": 15,
}
body, _ = json.Marshal(settingsPayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/settings", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
}
var updatedUser domain.User
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
}
// 3. Test Admin routes (default user is admin)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/users, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/settings, got %d: %s", rec.Code, rec.Body.String())
}
// 4. Test Kanban & Task details update
taskUpdatePayload := map[string]interface{}{
"kanbanStatus": "in_progress",
"notes": "Detailnotizen für die Aufgabe",
"taskListId": createdTaskList.ID,
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var taskThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &taskThought)
body, _ = json.Marshal(taskUpdatePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/thoughts/"+taskThought.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/thoughts/{id}, got %d: %s", rec.Code, rec.Body.String())
}
var updatedThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &updatedThought)
if updatedThought.KanbanStatus != "in_progress" || updatedThought.Notes != "Detailnotizen für die Aufgabe" {
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
}
}
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("failed to seed demo accounts: %v", err)
}
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "demo-jwt-secret",
DemoMode: true,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Check auth status reports demoMode: true
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
}
var statusResp struct {
NeedsSetup bool `json:"needsSetup"`
DemoMode bool `json:"demoMode"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.DemoMode {
t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode)
}
if statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup)
}
// 2. Login with username "admin" / "admin"
loginBody := []byte(`{"email":"admin","password":"admin"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String())
}
var loginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginRes)
if loginRes.Token == "" || loginRes.User.Role != "admin" {
t.Fatalf("invalid admin login response: %+v", loginRes)
}
// 3. Login with username "user" / "user"
userLoginBody := []byte(`{"username":"user","password":"user"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String())
}
var userLoginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes)
if userLoginRes.Token == "" || userLoginRes.User.Role != "member" {
t.Fatalf("invalid user login response: %+v", userLoginRes)
}
// 4. Invalid credentials must return 401
wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
}
}
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("failed to seed demo accounts: %v", err)
}
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-secret",
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
adminUser, err := store.GetUserByLogin("admin")
if err != nil {
t.Fatalf("failed to get admin user: %v", err)
}
normalUser, err := store.GetUserByLogin("user")
if err != nil {
t.Fatalf("failed to get normal user: %v", err)
}
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// 1. Admin creates a space
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
}
var createdSpace domain.Space
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
if createdSpace.ID == "" {
t.Fatalf("expected created space with ID, got %+v", createdSpace)
}
// 2. Admin invites normalUser by username "user"
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
}
var newMember domain.SpaceMember
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
if newMember.UserID != normalUser.ID {
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
}
// 3. User checks their spaces; the shared space must be listed
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code)
}
var userSpaces []domain.Space
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
found := false
for _, s := range userSpaces {
if s.ID == createdSpace.ID {
found = true
break
}
}
if !found {
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
}
// 4. Admin removes user from space
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestWorkspaceTagsAPI(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
// Create document with hashtags
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0},
{ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1},
}
_, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. GET /api/v1/workspace/tags
req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var tagList []domain.TagSummary
_ = json.Unmarshal(rec.Body.Bytes(), &tagList)
if len(tagList) != 1 || tagList[0].Tag != "sprint12" {
t.Fatalf("expected tag 'sprint12', got %+v", tagList)
}
// 2. GET /api/v1/workspace/tags/Sprint12
req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var details domain.TagDetails
_ = json.Unmarshal(rec.Body.Bytes(), &details)
if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 {
t.Fatalf("unexpected tag details: %+v", details)
}
matched := details.Documents[0].MatchedBlocks[0]
if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" {
t.Fatalf("unexpected matched blocks or children: %+v", matched)
}
// 3. Folder endpoints test
// Create folder
createFolderBody := []byte(`{"path":"TestOrdner"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/folders, got %d: %s", rec.Code, rec.Body.String())
}
// Get folders
req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code)
}
var folders []string
_ = json.Unmarshal(rec.Body.Bytes(), &folders)
foundFolder := false
for _, f := range folders {
if f == "TestOrdner" {
foundFolder = true
break
}
}
if !foundFolder {
t.Fatalf("expected TestOrdner in folders list, got %v", folders)
}
// Delete folder
req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
}
}