更新若干功能
This commit is contained in:
290
sync_test.go
Normal file
290
sync_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user