package storage import ( "os" "path/filepath" "testing" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" ) func TestStoreLifecycle(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test_mindsift.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore failed: %v", err) } defer store.Close() user, err := store.EnsureDefaultUser() if err != nil { t.Fatalf("EnsureDefaultUser failed: %v", err) } if user.ID == "" { t.Fatal("expected valid user ID") } // 1. Test Instant Capture thought, err := store.CreateThought(user.ID, "Milch und Brot kaufen #einkauf #dringend", "high", "manual", "") if err != nil { t.Fatalf("CreateThought failed: %v", err) } if thought.Status != domain.StatusInbox { t.Fatalf("expected status inbox, got %s", thought.Status) } if len(thought.Tags) != 2 { t.Fatalf("expected 2 extracted hashtags, got %d", len(thought.Tags)) } // 2. Test Tinder-Style Triage -> Convert to Document triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "", "") if err != nil { t.Fatalf("UpdateThoughtTriage failed: %v", err) } if triaged.Status != domain.StatusConverted || triaged.ConvertedType != "document" || triaged.ConvertedRefID == "" { t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID) } // 3. Test Standalone Shopping List & Sharing lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping") if err != nil { t.Fatalf("CreateList failed: %v", err) } item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0) if err != nil { t.Fatalf("CreateListItem failed: %v", err) } toggled, err := store.ToggleListItem(item.ID, true, user.ID, user.DisplayName) if err != nil || !toggled.IsCompleted { t.Fatalf("ToggleListItem failed: %v", err) } // Test AddListShare & RemoveListShare u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member") if err != nil { t.Fatalf("CreateUser u2 failed: %v", err) } share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor") if err != nil || share.TargetID != u2.ID { t.Fatalf("AddListShare failed: %v", err) } shares, err := store.GetListShares(lst.ID) if err != nil || len(shares) != 1 { t.Fatalf("expected 1 share, got %d", len(shares)) } err = store.RemoveListShare(user.ID, lst.ID, share.ID) if err != nil { t.Fatalf("RemoveListShare failed: %v", err) } sharesAfter, _ := store.GetListShares(lst.ID) if len(sharesAfter) != 0 { t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter)) } // 4. Test Webhook API Key generation & validation apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") if err != nil { t.Fatalf("CreateAPIKey failed: %v", err) } validatedUser, err := store.ValidateAPIKeyAndGetUser(apiKey.RawKey) if err != nil || validatedUser.ID != user.ID { t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err) } // 5. Test TaskLists CRUD taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981") if err != nil || taskList.ID == "" { t.Fatalf("CreateTaskList failed: %v", err) } taskLists, err := store.GetTaskLists(user.ID) if err != nil || len(taskLists) == 0 { t.Fatalf("GetTaskLists failed: %v", err) } // 6. Test Organizations & Teams org, err := store.CreateOrganization(user.ID, "Mindsift Inc") if err != nil || org.ID == "" { t.Fatalf("CreateOrganization failed: %v", err) } team, err := store.CreateTeam(org.ID, "DevOps", "Infrastructure team") if err != nil || team.ID == "" { t.Fatalf("CreateTeam failed: %v", err) } err = store.AddTeamMember(team.ID, user.ID, "owner") if err != nil { t.Fatalf("AddTeamMember failed: %v", err) } // Test TaskList Sharing with Team shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID) if err != nil || shareTeam.ID == "" { t.Fatalf("AddTaskListShare team failed: %v", err) } tlShares, err := store.GetTaskListShares(taskList.ID) if err != nil || len(tlShares) == 0 { t.Fatalf("GetTaskListShares failed: %v", err) } fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID) if err != nil || len(fetchedTL.Shares) == 0 { t.Fatalf("GetTaskListByID with shares failed: %v", err) } // 7. Test System Settings sysSettings, err := store.GetSystemSettings() if err != nil || sysSettings.InstanceName == "" { t.Fatalf("GetSystemSettings failed: %v", err) } sysSettings.SharingMode = "teams_only" err = store.UpdateSystemSettings(sysSettings) if err != nil { t.Fatalf("UpdateSystemSettings failed: %v", err) } // 8. Test Document Sharing & Permissions user2, err := store.CreateUser("collab@example.com", "hash", "Collab User", "member") if err != nil { t.Fatalf("CreateUser collab failed: %v", err) } newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{ {ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0}, }, []string{"test"}, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } // Share with user2 as "read" docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read") if err != nil || docShare.Permission != "read" { t.Fatalf("CreateDocShare failed: %v", err) } // user2 can get document docForUser2, err := store.GetDocumentByID(user2.ID, newDoc.ID) if err != nil || docForUser2.Permission != "read" || docForUser2.IsOwner { t.Fatalf("GetDocumentByID for user2 failed or wrong permission: %v", err) } // user2 tries to edit read-only doc -> must fail updatedTitle := "Hacked Title" _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) if err == nil { t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded") } // Upgrade to write permission _, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write") if err != nil { t.Fatalf("Upgrade CreateDocShare failed: %v", err) } // user2 edits writeable doc -> must succeed _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("UpdateDocument failed for write collaborator: %v", err) } // Remove share err = store.DeleteDocShare(user.ID, newDoc.ID, user2.ID) if err != nil { t.Fatalf("DeleteDocShare failed: %v", err) } _, err = store.GetDocumentByID(user2.ID, newDoc.ID) if err == nil { t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded") } } func TestPerUserDocPinning(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "pin_test.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore failed: %v", err) } defer store.Close() user1, _ := store.CreateUser("user1@example.com", "hash", "User 1", "admin") user2, _ := store.CreateUser("user2@example.com", "hash", "User 2", "member") // user1 creates document and pins it doc, err := store.CreateDocument(user1.ID, "", "Doc 1", "📝", "", []domain.DocumentBlock{ {ID: "b1", Type: "text", Content: "Hello"}, }, []string{"test"}, true) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } if !doc.IsPinned { t.Fatalf("expected doc to be pinned for user1") } // Share with user2 as read-only _, err = store.CreateDocShare(user1.ID, doc.ID, user2.ID, "read") if err != nil { t.Fatalf("CreateDocShare failed: %v", err) } // user2 reads document: should NOT be pinned for user2 docForUser2, err := store.GetDocumentByID(user2.ID, doc.ID) if err != nil { t.Fatalf("GetDocumentByID user2 failed: %v", err) } if docForUser2.IsPinned { t.Fatalf("expected doc to NOT be pinned for user2 initially") } // user2 pins the shared document (even with read-only access) pinTrue := true updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue) if err != nil { t.Fatalf("user2 pinning read-only doc failed: %v", err) } if !updatedDoc2.IsPinned { t.Fatalf("expected doc to be pinned for user2") } // Verify user1's doc is still pinned docForUser1, err := store.GetDocumentByID(user1.ID, doc.ID) if err != nil { t.Fatalf("GetDocumentByID user1 failed: %v", err) } if !docForUser1.IsPinned { t.Fatalf("expected doc to still be pinned for user1") } // user1 unpins the doc pinFalse := false _, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse) if err != nil { t.Fatalf("user1 unpinning doc failed: %v", err) } // user1 doc is now unpinned, but user2 doc must STILL be pinned! docForUser1, _ = store.GetDocumentByID(user1.ID, doc.ID) if docForUser1.IsPinned { t.Fatalf("expected doc to be unpinned for user1") } docForUser2, _ = store.GetDocumentByID(user2.ID, doc.ID) if !docForUser2.IsPinned { t.Fatalf("expected doc to remain pinned for user2") } } func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "demo_test.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore failed: %v", err) } defer store.Close() if err := store.EnsureDemoAccounts(); err != nil { t.Fatalf("EnsureDemoAccounts failed: %v", err) } // 1. Lookup by username "admin" adminUser, err := store.GetUserByLogin("admin") if err != nil || adminUser == nil { t.Fatalf("expected to find admin user by username 'admin', got %v", err) } if adminUser.Role != "admin" { t.Fatalf("expected role 'admin', got %s", adminUser.Role) } if !auth.CheckPassword("admin", adminUser.PasswordHash) { t.Fatalf("admin password check failed") } // 2. Lookup by email "admin@mindsift.local" adminByEmail, err := store.GetUserByLogin("admin@mindsift.local") if err != nil || adminByEmail.ID != adminUser.ID { t.Fatalf("lookup by email failed: got %v", err) } // 3. Lookup with case insensitivity "ADMIN" adminCaps, err := store.GetUserByLogin("ADMIN") if err != nil || adminCaps.ID != adminUser.ID { t.Fatalf("lookup by uppercase failed: got %v", err) } // 4. Lookup by username "user" normalUser, err := store.GetUserByLogin("user") if err != nil || normalUser == nil { t.Fatalf("expected to find user by username 'user', got %v", err) } if normalUser.Role != "member" { t.Fatalf("expected role 'member', got %s", normalUser.Role) } if !auth.CheckPassword("user", normalUser.PasswordHash) { t.Fatalf("user password check failed") } // 5. Ensure re-running is idempotent and preserves/refreshes credentials if err := store.EnsureDemoAccounts(); err != nil { t.Fatalf("second EnsureDemoAccounts call failed: %v", err) } } func TestFolderManagementAndDeletion(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "folder_test.db") docDir := filepath.Join(tmpDir, "workspace") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore failed: %v", err) } defer store.Close() user, err := store.CreateUser("folderuser@example.com", "hash", "Folder User", "member") if err != nil { t.Fatalf("CreateUser failed: %v", err) } // 1. Create folders on disk f1, err := store.CreateFolder(user.ID, "Projekte/MindSift") if err != nil || f1 != "Projekte/MindSift" { t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1) } f2, err := store.CreateFolder(user.ID, "Archiv") if err != nil || f2 != "Archiv" { t.Fatalf("CreateFolder Archiv failed: %v", err) } // 2. Create document in subfolder doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } doc2, err := store.CreateDocument(user.ID, "", "SubDoc", "📝", "Projekte/MindSift/Sub", nil, nil, false) if err != nil { t.Fatalf("CreateDocument 2 failed: %v", err) } // 3. GetFolders folders, err := store.GetFolders(user.ID) if err != nil { t.Fatalf("GetFolders failed: %v", err) } expected := map[string]bool{ "Archiv": true, "Projekte": true, "Projekte/MindSift": true, "Projekte/MindSift/Sub": true, } for _, f := range folders { delete(expected, f) } if len(expected) > 0 { t.Fatalf("GetFolders missing expected folders: %v (got %v)", expected, folders) } // 4. DeleteFolder "Projekte/MindSift" -> should remove doc1 & doc2 deletedDocs, err := store.DeleteFolder(user.ID, "Projekte/MindSift") if err != nil { t.Fatalf("DeleteFolder failed: %v", err) } if len(deletedDocs) != 2 { t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs) } // Verify docs are gone from DB _, err = store.GetDocumentByID(user.ID, doc1.ID) if err == nil { t.Fatalf("expected doc1 to be deleted") } _, err = store.GetDocumentByID(user.ID, doc2.ID) if err == nil { t.Fatalf("expected doc2 to be deleted") } // Verify disk directory was deleted diskFolder := filepath.Join(docDir, user.ID, "Projekte/MindSift") if _, err := os.Stat(diskFolder); !os.IsNotExist(err) { t.Fatalf("expected directory %s to be deleted on disk", diskFolder) } } func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "tags_test.db") store, err := NewStore(dbPath) if err != nil { t.Fatalf("NewStore failed: %v", err) } defer store.Close() user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member") if err != nil { t.Fatalf("CreateUser failed: %v", err) } // Create document with nested bullet points and hashtags blocks := []domain.DocumentBlock{ {ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0}, {ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0}, {ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1}, {ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1}, {ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2}, {ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0}, } doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } // 1. Check GetWorkspaceTags tags, err := store.GetWorkspaceTags(user.ID) if err != nil { t.Fatalf("GetWorkspaceTags failed: %v", err) } if len(tags) != 2 { t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags) } tagMap := make(map[string]int) for _, tg := range tags { tagMap[tg.Tag] = tg.Count } if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 { t.Fatalf("unexpected tag counts: %v", tagMap) } // 2. Check GetTagDetails for #Kühlregal details, err := store.GetTagDetails(user.ID, "#Kühlregal") if err != nil { t.Fatalf("GetTagDetails failed: %v", err) } if details.Tag != "kühlregal" { t.Fatalf("expected tag 'kühlregal', got %s", details.Tag) } if details.TotalMatches != 1 { t.Fatalf("expected 1 match, got %d", details.TotalMatches) } if len(details.Documents) != 1 { t.Fatalf("expected 1 document, got %d", len(details.Documents)) } docGroup := details.Documents[0] if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" { t.Fatalf("unexpected doc group: %+v", docGroup) } if len(docGroup.MatchedBlocks) != 1 { t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks)) } matched := docGroup.MatchedBlocks[0] if matched.RootBlock.ID != "b1" { t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID) } // Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0) if len(matched.Children) != 3 { t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children) } if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" { t.Fatalf("unexpected children IDs: %v", matched.Children) } // 3. Update document: remove #Wocheneinkauf, add #Vorrat updatedBlocks := []domain.DocumentBlock{ {ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0}, } _, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil) if err != nil { t.Fatalf("UpdateDocument failed: %v", err) } tagsAfter, err := store.GetWorkspaceTags(user.ID) if err != nil { t.Fatalf("GetWorkspaceTags after update failed: %v", err) } tagMapAfter := make(map[string]int) for _, tg := range tagsAfter { tagMapAfter[tg.Tag] = tg.Count } if tagMapAfter["wocheneinkauf"] != 0 { t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter) } if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 { t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter) } }