Files
code-utils/sync_test.go
2026-08-15 17:18:00 +08:00

282 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() })
// 显式指向本机未监听端口,让远端访问立即失败(离线语义):
// 空配置会回落到打包默认 API单测可能误连开发机上的真实服务。
_ = s.SetMeta("sync_base_url", "http://127.0.0.1:1")
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) {
if syncConfigComplete(SyncConfig{}) {
t.Fatal("incomplete config accepted")
}
c := SyncConfig{BaseURL: "http://api.example:8788"}
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)
}
}