MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- 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>
155 lines
6.6 KiB
Go
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())
|
|
}
|
|
}
|