package main import ( "encoding/json" "path/filepath" "strconv" "strings" "testing" ) func newSyncTestApp(t *testing.T) *App { t.Helper() s, e := OpenStore(filepath.Join(t.TempDir(), "sync.db")) if e != nil { t.Fatal(e) } t.Cleanup(func() { s.db.Close() }) // 显式指向本机未监听端口,让远端访问立即失败(离线语义): // 空配置会回落到内置默认服务器,单测可能误连开发机上的真实 MySQL。 _ = s.SetMeta("sync_host", "127.0.0.1") _ = s.SetMeta("sync_port", "1") _ = s.SetMeta("sync_user", "test") _ = s.SetMeta("sync_database", "test") return &App{store: s} } func TestApplyRemoteRowInsertAndProjectMapping(t *testing.T) { a := newSyncTestApp(t) p, e := a.store.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir()}) if e != nil { t.Fatal(e) } applied, e := a.applyRemoteRow(todoSync, map[string]any{ "uuid": "u-1", "title": "远端待办", "content": "", "project_name": "demo", "due_at": "2026-08-20", "priority": "high", "status": "open", "created_at": "2026-08-10T00:00:00Z", "updated_at": "2026-08-11T00:00:00Z", "deleted": int64(0), }) if e != nil || !applied { t.Fatalf("apply failed: %v %v", applied, e) } todos, _ := a.store.ListTodos("all", 0) if len(todos) != 1 || todos[0].ProjectID != p.ID || todos[0].Title != "远端待办" { t.Fatalf("unexpected todos: %#v", todos) } var dirty int _ = a.store.db.QueryRow(`SELECT dirty FROM todos WHERE uuid='u-1'`).Scan(&dirty) if dirty != 0 { t.Fatal("pulled row must not be dirty") } } func TestApplyRemoteRowLWW(t *testing.T) { a := newSyncTestApp(t) saved, e := a.store.SaveTodo(Todo{Title: "本地标题"}) if e != nil { t.Fatal(e) } // 远端 updated_at 更旧 → 保留本地 applied, e := a.applyRemoteRow(todoSync, map[string]any{ "uuid": saved.UUID, "title": "旧远端", "content": "", "project_name": "", "due_at": "", "priority": "low", "status": "open", "created_at": "2020-01-01T00:00:00Z", "updated_at": "2020-01-01T00:00:00Z", "deleted": int64(0), }) if e != nil || applied { t.Fatalf("stale remote should be skipped: %v %v", applied, e) } // 远端 updated_at 更新 → 覆盖本地,且 dirty 清零 applied, e = a.applyRemoteRow(todoSync, map[string]any{ "uuid": saved.UUID, "title": "新远端", "content": "x", "project_name": "", "due_at": "", "priority": "high", "status": "done", "created_at": "2020-01-01T00:00:00Z", "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(0), }) if e != nil || !applied { t.Fatalf("newer remote should apply: %v %v", applied, e) } got, _ := a.store.GetTodo(saved.ID) if got.Title != "新远端" || got.Status != "done" { t.Fatalf("unexpected merge result: %#v", got) } } func TestApplyRemoteRowDelete(t *testing.T) { a := newSyncTestApp(t) saved, _ := a.store.SaveTodo(Todo{Title: "将被远端删除"}) applied, e := a.applyRemoteRow(todoSync, map[string]any{ "uuid": saved.UUID, "title": saved.Title, "content": "", "project_name": "", "due_at": "", "priority": "medium", "status": "open", "created_at": saved.CreatedAt, "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(1), }) if e != nil || !applied { t.Fatalf("remote delete should apply: %v %v", applied, e) } todos, _ := a.store.ListTodos("all", 0) if len(todos) != 0 { t.Fatalf("todo should be soft-deleted, got %#v", todos) } } func TestApplyRemoteNoteMerge(t *testing.T) { a := newSyncTestApp(t) if _, e := a.store.SaveNote("本地内容"); e != nil { t.Fatal(e) } // 其它设备的 note(uuid 不同)按多条模型 upsert 为一条新笔记 applied, e := a.applyRemoteRow(noteSync, map[string]any{ "uuid": "other-device-uuid", "content": "远端更新内容", "updated_at": "2999-01-01T00:00:00Z", "deleted": int64(0), }) if e != nil || !applied { t.Fatalf("newer remote note should apply: %v %v", applied, e) } notes, _ := a.store.ListNotes(0) if len(notes) != 2 { t.Fatalf("expect 2 notes after pull, got %d", len(notes)) } // 最近更新的一条应是远端行;本地行内容不被覆盖 n, _ := a.store.GetNote() if n.Content != "远端更新内容" { t.Fatalf("unexpected latest note content: %q", n.Content) } // 更旧的远端更新不应覆盖同 uuid 行 applied, _ = a.applyRemoteRow(noteSync, map[string]any{ "uuid": "other-device-uuid", "content": "旧内容", "updated_at": "2020-01-01T00:00:00Z", "deleted": int64(0), }) if applied { t.Fatal("stale remote note should be skipped") } // 远端软删同 uuid 行应生效 applied, e = a.applyRemoteRow(noteSync, map[string]any{ "uuid": "other-device-uuid", "content": "远端更新内容", "updated_at": "2999-01-02T00:00:00Z", "deleted": int64(1), }) if e != nil || !applied { t.Fatalf("remote note delete should apply: %v %v", applied, e) } if notes, _ = a.store.ListNotes(0); len(notes) != 1 { t.Fatalf("expect 1 note after remote delete, got %d", len(notes)) } } func TestSyncConfigHelpers(t *testing.T) { if syncConfigComplete(SyncConfig{Host: "h", User: "u"}) { t.Fatal("incomplete config accepted") } c := SyncConfig{Host: "db.local", Port: 3307, User: "root", Password: "pw", Database: "cc"} if !syncConfigComplete(c) { t.Fatal("complete config rejected") } dsn := syncDSN(c) for _, want := range []string{"db.local:3307", "root:pw@", "/cc", "charset=utf8mb4"} { if !strings.Contains(dsn, want) { t.Fatalf("dsn %q missing %q", dsn, want) } } } // ---------- 机器码同步:项目身份 / 机器路径分离 ---------- func TestMachineIDStableAndCached(t *testing.T) { a := newSyncTestApp(t) id1 := a.machineID() if id1 == "" || len(id1) != 16 { t.Fatalf("unexpected machine id: %q", id1) } if id2 := a.machineID(); id2 != id1 { t.Fatalf("machine id not stable: %q vs %q", id1, id2) } if a.store.Meta("machine_id") != id1 { t.Fatal("machine id should be cached in meta") } } func TestProjectsDocV2OmitsPath(t *testing.T) { a := newSyncTestApp(t) dir := t.TempDir() if _, e := a.store.SaveProject(0, ProjectInput{Name: "alpha", Path: dir, Description: "d1"}); e != nil { t.Fatal(e) } doc, e := a.projectsDoc() if e != nil { t.Fatal(e) } if strings.Contains(doc, `"path"`) || strings.Contains(doc, filepath.Base(dir)) { t.Fatalf("v2 doc must not contain machine paths: %s", doc) } if !strings.Contains(doc, `"name":"alpha"`) || !strings.Contains(doc, `"description":"d1"`) { t.Fatalf("doc missing identity fields: %s", doc) } } func TestApplyProjectsDocV1CompatAndPending(t *testing.T) { a := newSyncTestApp(t) dir := t.TempDir() if _, e := a.store.SaveProject(0, ProjectInput{Name: "oldname", Path: dir}); e != nil { t.Fatal(e) } // v1 文档:元素含 path。本地按 path 匹配 → 改名并更新身份;其它机器的项目 → 待绑定,不落库。 doc := `[{"name":"renamed","path":` + strconv.Quote(dir) + `,"description":"from v1","group":"G1","favorite":true},` + `{"name":"remote-only","path":"C:\\other\\pc\\proj","description":"elsewhere"}]` if e := a.applyProjectsDoc(doc); e != nil { t.Fatal(e) } var name, desc string var gid int64 if e := a.store.db.QueryRow(`SELECT name,description,group_id FROM projects WHERE path=?`, dir).Scan(&name, &desc, &gid); e != nil { t.Fatal(e) } if name != "renamed" || desc != "from v1" || gid < 2 { t.Fatalf("v1 doc not applied by path match: %s %s %d", name, desc, gid) } var cnt int _ = a.store.db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&cnt) if cnt != 1 { t.Fatalf("remote-only project must not be inserted, got %d projects", cnt) } pending, e := a.ListCloudPendingProjects() if e != nil { t.Fatal(e) } if len(pending) != 1 || pending[0].Name != "remote-only" || pending[0].Path != "" { t.Fatalf("unexpected pending list: %#v", pending) } } func TestMachinePathsDocRoundtrip(t *testing.T) { a := newSyncTestApp(t) dir1, dir2 := t.TempDir(), t.TempDir() if _, e := a.store.SaveProject(0, ProjectInput{Name: "p1", Path: dir1}); e != nil { t.Fatal(e) } doc, e := a.machinePathsDoc() if e != nil { t.Fatal(e) } if !strings.Contains(doc, `"p1"`) { t.Fatalf("paths doc missing project: %s", doc) } // 远端本机行里多一个项目(如重装后换库)→ 自动入库 var m map[string]string if e := json.Unmarshal([]byte(doc), &m); e != nil { t.Fatal(e) } m["p2"] = dir2 b, _ := json.Marshal(m) if e := a.applyMachinePaths(string(b)); e != nil { t.Fatal(e) } var got string if e := a.store.db.QueryRow(`SELECT path FROM projects WHERE name='p2'`).Scan(&got); e != nil || got != dir2 { t.Fatalf("p2 not inserted from machine paths: %q %v", got, e) } // 同名项目路径变化 → 以远端为准更新 m["p1"] = dir2 + "_moved" b, _ = json.Marshal(m) if e := a.applyMachinePaths(string(b)); e != nil { t.Fatal(e) } if e := a.store.db.QueryRow(`SELECT path FROM projects WHERE name='p1'`).Scan(&got); e != nil || got != dir2+"_moved" { t.Fatalf("p1 path not updated: %q %v", got, e) } } func TestBindCloudProject(t *testing.T) { a := newSyncTestApp(t) // 构造待绑定清单(模拟拉取到云端身份但本机无路径) doc := `[{"name":"cloudproj","description":"云端项目","group":"G2","favorite":true}]` if e := a.applyProjectsDoc(doc); e != nil { t.Fatal(e) } if pending, _ := a.ListCloudPendingProjects(); len(pending) != 1 { t.Fatalf("expect 1 pending, got %d", len(pending)) } if _, e := a.BindCloudProject("cloudproj", filepath.Join(t.TempDir(), "not-exist")); e == nil { t.Fatal("bind to missing dir should fail") } dir := t.TempDir() p, e := a.BindCloudProject("cloudproj", dir) if e != nil { t.Fatal(e) } if p.Name != "cloudproj" || p.Path != dir || p.Description != "云端项目" { t.Fatalf("unexpected bound project: %#v", p) } var fav int _ = a.store.db.QueryRow(`SELECT COUNT(*) FROM favorites WHERE project_id=?`, p.ID).Scan(&fav) if fav != 1 { t.Fatal("favorite flag should be applied on bind") } if pending, _ := a.ListCloudPendingProjects(); len(pending) != 0 { t.Fatalf("pending should be cleared after bind, got %#v", pending) } }