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