Files
MindSift/internal/storage/task_doc_sync_test.go
T
sandrodachandClaude Opus 5.5 5a8922011d
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 2m32s
feat(tasks): linked tasks sync title and subtasks with their note in both directions
Subtasks of a task linked to a note checkbox are tied 1:1 to checkboxes of that note
(subtasks[].blockId), wherever they are moved. ReconcileDocTaskLinks now carries tick, title and
subtasks from the note to the task and adopts new checkboxes directly under the task;
SyncTaskToDoc carries tick, title and subtasks to the note and adds checkboxes for new subtasks.
Deleting is not mirrored. Deepening a task ties its subtasks right away; saves without links keep
the server's links. The sync response reports taskLinks/subtaskLinks; the editor shows an
"Unteraufgabe" badge. v0.36.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:29:10 +01:00

154 lines
6.8 KiB
Go

package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/collab"
"github.com/sandrodach/mindsift/internal/domain"
)
// edit applies operations to a note like a client would.
func edit(t *testing.T, s *Store, userID, docID string, build func(d *collab.Doc) []collab.Op) {
t.Helper()
if _, _, err := s.applyDocChange(docID, collab.Actor{UserID: userID, ClientID: "test"}, nil, build); err != nil {
t.Fatal(err)
}
}
func lastChildKey(d *collab.Doc, parent string) string {
key := ""
for _, c := range d.Children(parent) {
key = c.OrderKey
}
return key
}
// Linked tasks follow their note in both directions: tick, title and subtasks (1:1, wherever the
// checkbox goes); new checkboxes directly under the task become subtasks; deleting is not mirrored.
func TestLinkedTaskAndSubtasksFollowTheNote(t *testing.T) {
s, err := NewStore(filepath.Join(t.TempDir(), "sync.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
anna, _ := s.CreateUser("anna@example.com", "hash", "Anna", "member")
th, _ := s.CreateThoughtWithDetails(anna.ID, "Umzug planen", "normal", "manual", "", "", "todo", "")
_, _ = s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "task", "", "", "")
subs := []domain.SubTask{{ID: "st1", Title: "Kartons besorgen"}, {ID: "st2", Title: "Helfer fragen"}}
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &subs, nil)
task, err := s.UpdateThoughtTriage(anna.ID, th.ID, "keep", "document", "", "", "")
if err != nil {
t.Fatal(err)
}
docID, mainID := task.DocID, task.BlockID
if task.Subtasks[0].BlockID == "" || task.Subtasks[1].BlockID == "" {
t.Fatalf("deepening should tie the subtasks to their checkboxes: %+v", task.Subtasks)
}
kartons := task.Subtasks[0].BlockID
if _, subLinks := s.DocTaskLinks(docID); subLinks[kartons] != th.ID {
t.Fatalf("subtask checkbox should be reported for the badge: %v", subLinks)
}
// Note → task: tick and title of a subtask, title of the main checkbox
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
yes := true
text := "Umzugskartons besorgen"
main := "Umzug nach Leipzig planen"
return []collab.Op{
{OpID: domain.NewID("op"), Kind: collab.OpSetProps, BlockID: kartons, Patch: &collab.PropsPatch{Checked: &yes}},
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: kartons, BaseVersion: d.Blocks[kartons].Version, Content: &text},
{OpID: domain.NewID("op"), Kind: collab.OpSetContent, BlockID: mainID, BaseVersion: d.Blocks[mainID].Version, Content: &main},
}
})
if changed := s.ReconcileDocTaskLinks(docID); len(changed) != 1 {
t.Fatalf("task should change: %v", changed)
}
got, _ := s.GetThoughtByID(anna.ID, th.ID)
if got.Content != "Umzug nach Leipzig planen" || !got.Subtasks[0].IsCompleted || got.Subtasks[0].Title != "Umzugskartons besorgen" {
t.Fatalf("note changes should reach the task: %+v", got)
}
// New checkbox directly under the main one → subtask; one level deeper → stays in the note only
var direct, deeper string
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
k1, _ := collab.KeyBetween(lastChildKey(d, mainID), "")
direct, deeper = domain.NewID("blk"), domain.NewID("blk")
p1, p2 := mainID, kartons
t1, t2 := "Strom ummelden", "Zählerstand fotografieren"
k2, _ := collab.KeyBetween("", "")
return []collab.Op{
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: direct, ParentID: &p1, OrderKey: k1, Type: "todo", Content: &t1, Props: &collab.Props{}},
{OpID: domain.NewID("op"), Kind: collab.OpInsert, BlockID: deeper, ParentID: &p2, OrderKey: k2, Type: "todo", Content: &t2, Props: &collab.Props{}},
}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if len(got.Subtasks) != 3 || got.Subtasks[2].BlockID != direct || got.Subtasks[2].Title != "Strom ummelden" {
t.Fatalf("checkbox right under the task should become a subtask (and only that one): %+v", got.Subtasks)
}
// Moving a subtask checkbox elsewhere in the note keeps the link
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
top := ""
k, _ := collab.KeyBetween(lastChildKey(d, ""), "")
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpMove, BlockID: direct, ParentID: &top, OrderKey: k}}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if got.Subtasks[2].BlockID != direct {
t.Fatalf("a moved checkbox stays tied to its subtask: %+v", got.Subtasks)
}
// Task → note: tick and title of the main task and a subtask, a new subtask gets a checkbox
done := true
title := "Umzug nach Leipzig organisieren"
subs = append(got.Subtasks, domain.SubTask{ID: "st9", Title: "Nachsendeauftrag stellen"})
subs[1].IsCompleted = true
// An app with an older state sends the subtasks without their links: the server keeps them
for i := range subs {
subs[i].BlockID = ""
}
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, &title, &done, nil, nil, nil, nil, nil, &subs, nil)
if s.SyncTaskToDoc(anna.ID, th.ID) != docID {
t.Fatal("the note should change")
}
d, _, _ := loadCollabDoc(s.db, docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if d.Blocks[mainID].Content != title || !d.Blocks[mainID].Props.Checked {
t.Fatalf("main checkbox should follow the task: %+v", d.Blocks[mainID])
}
if !d.Blocks[got.Subtasks[1].BlockID].Props.Checked {
t.Fatal("subtask tick should reach its checkbox")
}
added := got.Subtasks[3].BlockID
if added == "" || d.Blocks[added].ParentID != mainID || d.Blocks[added].Content != "Nachsendeauftrag stellen" {
t.Fatalf("new subtask should get a checkbox under the task: %+v", got.Subtasks)
}
if len(d.Children(mainID)) != 3 { // the two original subtasks and the new one ("Strom ummelden" moved away)
t.Fatalf("no duplicate checkboxes expected: %d children", len(d.Children(mainID)))
}
// Deleting is not mirrored: a deleted checkbox only unlinks, a deleted subtask leaves its checkbox
helfer := got.Subtasks[1].BlockID
edit(t, s, anna.ID, docID, func(d *collab.Doc) []collab.Op {
return []collab.Op{{OpID: domain.NewID("op"), Kind: collab.OpDelete, BlockID: helfer}}
})
s.ReconcileDocTaskLinks(docID)
got, _ = s.GetThoughtByID(anna.ID, th.ID)
if len(got.Subtasks) != 4 || got.Subtasks[1].BlockID != "" {
t.Fatalf("subtask should stay, only unlinked: %+v", got.Subtasks)
}
remaining := append([]domain.SubTask{}, got.Subtasks[0], got.Subtasks[2], got.Subtasks[3])
_, _ = s.UpdateThoughtDetails(anna.ID, th.ID, nil, nil, nil, nil, nil, nil, nil, &remaining, nil)
s.SyncTaskToDoc(anna.ID, th.ID)
d, _, _ = loadCollabDoc(s.db, docID)
if b := d.Blocks[kartons]; b == nil || b.Deleted {
t.Fatal("deleting a subtask must not delete its checkbox")
}
// The unlinked "Helfer fragen" subtask was deleted in the task; nothing came back
if n := len(got.Subtasks); n != 4 {
t.Fatalf("unexpected subtasks: %d", n)
}
}