Files
MindSift/internal/api/restructure_test.go
T
sandrodachandClaude Opus 5.5 4d1b37c956
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
feat(workspace): turn a branch into its own note, merge notes
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet
  becomes the title (hashtags stay at the link), its children the content
  of a new note in the same folder with the source's shares (a foreign
  source owner gets write access); the branch becomes [[title]] through
  the operation log, so open editors follow.
- Merge: drop a note onto another in the sidebar or use "Mit anderer
  Notiz zusammenführen…" in the note header. The own source is appended
  as an h2 section with its bullets below and goes to the trash; the toast
  offers a complete undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:25:46 +01:00

155 lines
6.6 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
func flatOf(t *testing.T, store *storage.Store, userID, docID string) []string {
t.Helper()
d, err := store.GetDocumentByID(userID, docID)
if err != nil {
t.Fatal(err)
}
out := make([]string, 0, len(d.Blocks))
for _, b := range d.Blocks {
out = append(out, strings.Repeat(">", b.Level)+b.Content)
}
return out
}
func TestExtractBranchIntoOwnNote(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
src, _ := store.CreateDocument(uid, "", "Projekt", "", "Arbeit", []domain.DocumentBlock{
{ID: "blk_a", Type: "bullet", Content: "- Konzept: Garten #draußen", Level: 0, Position: 1},
{ID: "blk_b", Type: "bullet", Content: "Beete", Level: 1, Position: 2},
{ID: "blk_c", Type: "todo", Content: "Erde kaufen", Level: 2, Position: 3, Checked: true},
{ID: "blk_d", Type: "bullet", Content: "Zaun", Level: 1, Position: 4},
{ID: "blk_e", Type: "bullet", Content: "Danach", Level: 0, Position: 5},
}, nil, false)
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_a"}`, "")
if rec.Code != http.StatusCreated {
t.Fatalf("extract: %d %s", rec.Code, rec.Body.String())
}
var created domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" {
t.Fatalf("new note: %q in %q", created.Title, created.FolderPath)
}
if got := strings.Join(flatOf(t, store, uid, created.ID), "|"); got != "Beete|>Erde kaufen|Zaun" {
t.Fatalf("new note blocks: %s", got)
}
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "[[Konzept: Garten]] #draußen|Danach" {
t.Fatalf("source after extract: %s", got)
}
// Same title again: the link must stay unique
_, _ = store.CreateDocument(uid, "", "Projekt 2", "", "", []domain.DocumentBlock{{ID: "blk_x", Type: "bullet", Content: "Konzept: Garten", Level: 0}}, nil, false)
docs, _ := store.GetDocuments(uid)
var p2 string
for _, d := range docs {
if d.Title == "Projekt 2" {
p2 = d.ID
}
}
var second domain.Document
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
if second.Title != "Konzept: Garten (2)" {
t.Fatalf("duplicate title: %q", second.Title)
}
}
func TestExtractInSharedNoteKeepsAccess(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
owner, _ := newTestUser(t, store, "owner@example.com")
editor, editorToken := newTestUser(t, store, "editor@example.com")
reader, readerToken := newTestUser(t, store, "reader@example.com")
src, _ := store.CreateDocument(owner.ID, "", "Gemeinsam", "", "", []domain.DocumentBlock{
{ID: "blk_s1", Type: "bullet", Content: "Idee", Level: 0, Position: 1},
{ID: "blk_s2", Type: "bullet", Content: "Detail", Level: 1, Position: 2},
}, nil, false)
if _, err := store.CreateDocShare(owner.ID, src.ID, editor.ID, "write"); err != nil {
t.Fatal(err)
}
_, _ = store.CreateDocShare(owner.ID, src.ID, reader.ID, "read")
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", readerToken, `{"blockId":"blk_s1"}`, ""); rec.Code != http.StatusForbidden {
t.Fatalf("reader must not extract: %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", editorToken, `{"blockId":"blk_s1"}`, "")
var created domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if rec.Code != http.StatusCreated {
t.Fatalf("editor extract: %d %s", rec.Code, rec.Body.String())
}
if r := store.DocRole(editor.ID, created.ID); r != storage.DocRoleOwner {
t.Fatalf("editor should own the new note, got %q", r)
}
if r := store.DocRole(owner.ID, created.ID); !storage.CanEditDoc(r) {
t.Fatalf("source owner must be able to edit the new note, got %q", r)
}
if r := store.DocRole(reader.ID, created.ID); r == "" || storage.CanEditDoc(r) {
t.Fatalf("reader must read (not edit) the new note, got %q", r)
}
}
func TestMergeDocumentsAndUndo(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
uid := claims.UserID
source, _ := store.CreateDocument(uid, "", "Einkauf", "🛒", "", []domain.DocumentBlock{
{ID: "blk_m1", Type: "bullet", Content: "Milch", Level: 0, Position: 1},
{ID: "blk_m2", Type: "bullet", Content: "laktosefrei", Level: 1, Position: 2},
}, nil, false)
target, _ := store.CreateDocument(uid, "", "Haushalt", "", "", []domain.DocumentBlock{{ID: "blk_t1", Type: "bullet", Content: "Putzen", Level: 0}}, nil, false)
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("merge into itself: want 400, got %d", rec.Code)
}
rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge", token, `{"sourceId":"`+source.ID+`"}`, "")
if rec.Code != http.StatusOK {
t.Fatalf("merge: %d %s", rec.Code, rec.Body.String())
}
var res storage.MergeResult
_ = json.Unmarshal(rec.Body.Bytes(), &res)
got := flatOf(t, store, uid, target.ID)
if len(got) != 4 || got[0] != "Putzen" || !strings.HasPrefix(got[1], "🛒 Einkauf (") || got[2] != ">Milch" || got[3] != ">>laktosefrei" {
t.Fatalf("target after merge: %v", got)
}
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || !d.IsArchived {
t.Fatal("source must be in the trash")
}
undo := `{"sourceId":"` + source.ID + `","headingId":"` + res.HeadingID + `"}`
if rec := sessionCall(router, "POST", "/api/v1/documents/"+target.ID+"/merge/undo", token, undo, ""); rec.Code != http.StatusOK {
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
}
if got := strings.Join(flatOf(t, store, uid, target.ID), "|"); got != "Putzen" {
t.Fatalf("target after undo: %s", got)
}
if d, _ := store.GetDocumentByID(uid, source.ID); d == nil || d.IsArchived {
t.Fatal("source must be back")
}
// Someone else's note cannot be the source
other, otherToken := newTestUser(t, store, "andere@example.com")
own, _ := store.CreateDocument(other.ID, "", "Eigen", "", "", nil, nil, false)
if _, err := store.CreateDocShare(uid, target.ID, other.ID, "write"); err != nil {
t.Fatal(err)
}
if rec := sessionCall(router, "POST", "/api/v1/documents/"+own.ID+"/merge", otherToken, `{"sourceId":"`+target.ID+`"}`, ""); rec.Code != http.StatusForbidden {
t.Fatalf("foreign source: want 403, got %d %s", rec.Code, rec.Body.String())
}
}