Files
code-utils/sync_test.go

282 lines
9.5 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
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() })
// 显式指向本机未监听端口,让远端访问立即失败(离线语义):
2026-08-15 17:18:00 +08:00
// 空配置会回落到打包默认 API单测可能误连开发机上的真实服务。
_ = s.SetMeta("sync_base_url", "http://127.0.0.1:1")
2026-08-14 07:52:01 +08:00
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)
}
// 其它设备的 noteuuid 不同)按多条模型 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) {
2026-08-15 17:18:00 +08:00
if syncConfigComplete(SyncConfig{}) {
2026-08-14 07:52:01 +08:00
t.Fatal("incomplete config accepted")
}
2026-08-15 17:18:00 +08:00
c := SyncConfig{BaseURL: "http://api.example:8788"}
2026-08-14 07:52:01 +08:00
if !syncConfigComplete(c) {
t.Fatal("complete config rejected")
}
}
// ---------- 机器码同步:项目身份 / 机器路径分离 ----------
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)
}
}