Files
MindSift/internal/api/restructure_test.go
T
sandrodachandClaude Opus 5.5 68bc4d279c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
fix(workspace): hidden bullet actions on phones, no second extract, notice layout
- The floating bullet actions only react to hover on devices with a real
  pointer; a tap on a phone made the invisible buttons clickable.
- A bullet that is only a note link (plus hashtags) can no longer be
  extracted: button and /extract are hidden, the server answers 400.
- Extract notice on phones sits above the bottom navigation, text on
  top (two lines max) with the actions below.
- Release v0.26.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:56:32 +01:00

189 lines
8.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 out struct {
Document domain.Document
Link string
}
_ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
if created.Title != "Konzept: Garten" || created.FolderPath != "Arbeit" || out.Link != "[[Konzept: Garten]] #draußen" {
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)
}
// The server leaves the source alone; the editor replaces the branch itself
if got := strings.Join(flatOf(t, store, uid, src.ID), "|"); got != "- Konzept: Garten #draußen|>Beete|>>Erde kaufen|>Zaun|Danach" {
t.Fatalf("source must stay unchanged on the server: %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 struct{ Document domain.Document }
_ = json.Unmarshal(sessionCall(router, "POST", "/api/v1/documents/"+p2+"/extract", token, `{"blockId":"blk_x"}`, "").Body.Bytes(), &second)
if second.Document.Title != "Konzept: Garten (2)" {
t.Fatalf("duplicate title: %q", second.Document.Title)
}
// Undo puts the new note into the trash; only its creator may
_, strangerToken := newTestUser(t, store, "fremd2@example.com")
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", strangerToken, "", ""); rec.Code == http.StatusOK {
t.Fatal("stranger must not undo")
}
if rec := sessionCall(router, "POST", "/api/v1/documents/"+created.ID+"/extract/undo", token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("undo: %d %s", rec.Code, rec.Body.String())
}
if d, _ := store.GetDocumentByID(uid, created.ID); d == nil || !d.IsArchived {
t.Fatal("extracted note must be in the trash after undo")
}
}
func TestExtractRejectsBulletThatIsOnlyANoteLink(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
claims, _ := auth.ValidateToken(testJWTSecret, token)
src, _ := store.CreateDocument(claims.UserID, "", "Projekt", "", "", []domain.DocumentBlock{
{ID: "blk_l", Type: "bullet", Content: "[[Konzept: Garten]] #draußen", Level: 0, Position: 1},
{ID: "blk_m", Type: "bullet", Content: "Siehe [[Konzept: Garten]] für Details", Level: 0, Position: 2},
}, nil, false)
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_l"}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("link-only bullet: want 400, got %d %s", rec.Code, rec.Body.String())
}
// A link inside other text is ordinary content
if rec := sessionCall(router, "POST", "/api/v1/documents/"+src.ID+"/extract", token, `{"blockId":"blk_m"}`, ""); rec.Code != http.StatusCreated {
t.Fatalf("bullet with text and link: want 201, got %d %s", rec.Code, rec.Body.String())
}
}
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 out struct{ Document domain.Document }
_ = json.Unmarshal(rec.Body.Bytes(), &out)
created := out.Document
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())
}
}