MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m20s
- 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>
189 lines
8.6 KiB
Go
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())
|
|
}
|
|
}
|