From 292e77097dfcf7d18234ba6fc28a204f2dc8209f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 11:24:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(history):=20=E8=AE=BF=E9=97=AE=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E6=8C=89=E8=AE=BE=E5=A4=87=E5=8E=BB=E9=87=8D=20+=20?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=BF=94=E5=9B=9E=E5=85=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entry 增加 DeviceVisits:同一 URL 多次/多设备上报取各设备最大值再求和, 不再平方级放大(30 次访问曾被计成 465 次) - handleGet 不再截断 100 条,返回 {items,total},后台显示当前条数 - 清理死代码(hostVisits/hostOf/visitCount/isCodeHostVal/dashySection) - 守门测试:重复上报、多设备求和、陈旧数据、持久化、统计、门户文案、第 101 条可见性 --- history-server/countbug_test.go | 77 +++++++++++ history-server/countgate_test.go | 135 +++++++++++++++++++ history-server/getcap_test.go | 67 ++++++++++ history-server/main.go | 215 +++++++++++++------------------ history-server/synccount_test.go | 81 ++++++++++++ 5 files changed, 451 insertions(+), 124 deletions(-) create mode 100644 history-server/countbug_test.go create mode 100644 history-server/countgate_test.go create mode 100644 history-server/getcap_test.go create mode 100644 history-server/synccount_test.go diff --git a/history-server/countbug_test.go b/history-server/countbug_test.go new file mode 100644 index 0000000..1d077ec --- /dev/null +++ b/history-server/countbug_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +// 复现「访问次数被平方级放大」:扩展每 30s 上报一次该 URL 的 +// chrome.history.search visitCount —— 那是**绝对累计值**(1,2,3,4,5…), +// 而 handleSync 用 `e.TotalVisits += item.VisitCount` 把它当增量累加。 +// 结果 5 次真实访问被记成 15 次。 +func TestSyncDoubleCountsAbsoluteVisitCount(t *testing.T) { + dir := t.TempDir() + oldDir, oldFile, oldTok := dataDir, historyFile, syncToken + dataDir = dir + historyFile = filepath.Join(dir, "history.json") + syncToken = "t" + defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }() + + history = map[string]*Entry{} + + // 模拟 5 轮同步:同一 URL 的绝对 visitCount 依次为 1..5 + for round, vc := range []int{1, 2, 3, 4, 5} { + body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{ + URL: "https://ex.com/a", Title: "A", Device: "desktop", + Timestamp: float64(1700000000 + round*30), VisitCount: vc, + }}}) + req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer t") + w := httptest.NewRecorder() + handleSync(w, req) + if w.Code != http.StatusOK { + t.Fatalf("round %d: status = %d", round, w.Code) + } + } + + e := history["https://ex.com/a"] + if e == nil { + t.Fatal("entry not created") + } + t.Logf("真实访问 5 次 → 落盘 TotalVisits = %d", e.TotalVisits) + if e.TotalVisits != 5 { + t.Errorf("TotalVisits = %d; 真实只访问 5 次,被平方级放大(期望 5)", e.TotalVisits) + } +} + +// 上报 100 次同一 URL 会放大到什么程度 —— 量化后果。 +func TestSyncInflationScalesQuadratically(t *testing.T) { + dir := t.TempDir() + oldDir, oldFile, oldTok := dataDir, historyFile, syncToken + dataDir = dir + historyFile = filepath.Join(dir, "history.json") + syncToken = "t" + defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }() + + history = map[string]*Entry{} + // 真实访问 30 次,但扩展每 30s 同步一次 → 上报 30 次绝对值 + for round := 1; round <= 30; round++ { + body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{ + URL: "https://ex.com/b", Device: "desktop", + Timestamp: float64(1700000000 + round*30), VisitCount: round, + }}}) + req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer t") + handleSync(httptest.NewRecorder(), req) + } + got := history["https://ex.com/b"].TotalVisits + want := 30 + t.Logf("真实 30 次访问 → 落盘 %d 次(虚高 %.1f×)", got, float64(got)/float64(want)) + if got != want { + t.Errorf("TotalVisits = %d; want %d", got, want) + } +} diff --git a/history-server/countgate_test.go b/history-server/countgate_test.go new file mode 100644 index 0000000..39f6a62 --- /dev/null +++ b/history-server/countgate_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +// 本文件是一组**守门测试**:把 2026-09-13 定位到的那个真实缺陷 +// (visit_count 绝对值被当增量累加)钉死,防止以后再改回去。 + +// 守门 1:绝不出现平方级放大。 +func TestNoQuadraticInflation(t *testing.T) { + withTempHistory(t) + // 真实访问 20 次;扩展每 30s 同步一次,30 秒内反复上报绝对值 + for round := 1; round <= 20; round++ { + postSync(t, SyncItem{ + URL: "https://ex.com/gate", Device: "laptop", + Timestamp: float64(1700000000 + round*30), VisitCount: round, + }) + } + if got := history["https://ex.com/gate"].TotalVisits; got != 20 { + t.Fatalf("TotalVisits = %d,真实只访问 20 次;出现放大即回归", got) + } +} + +// 守门 2:多设备各自的绝对值要相加(这是"取 max"的错误修法会失败的用例)。 +func TestMultiDeviceStillSums(t *testing.T) { + withTempHistory(t) + postSync(t, SyncItem{URL: "https://ex.com/m", Device: "laptop", Timestamp: 1700000000, VisitCount: 8}) + postSync(t, SyncItem{URL: "https://ex.com/m", Device: "phone", Timestamp: 1700000030, VisitCount: 3}) + postSync(t, SyncItem{URL: "https://ex.com/m", Device: "tablet", Timestamp: 1700000060, VisitCount: 5}) + if got := history["https://ex.com/m"].TotalVisits; got != 16 { + t.Errorf("TotalVisits = %d;期望 8+3+5=16(全局 max 会错成 8)", got) + } +} + +// 守门 3:落盘后能读回,且计数不变(DeviceVisits 参与序列化)。 +func TestCountsSurviveReload(t *testing.T) { + withTempHistory(t) + postSync(t, SyncItem{URL: "https://ex.com/r", Device: "laptop", Timestamp: 1700000000, VisitCount: 6}) + postSync(t, SyncItem{URL: "https://ex.com/r", Device: "phone", Timestamp: 1700000030, VisitCount: 2}) + + data, err := os.ReadFile(filepath.Join(dataDir, "history.json")) + if err != nil { + t.Fatalf("未落盘: %v", err) + } + var entries []*Entry + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("history.json 解析失败: %v", err) + } + if len(entries) != 1 || entries[0].TotalVisits != 8 { + t.Fatalf("重载后 TotalVisits = %v,期望 8", entries) + } + if len(entries[0].DeviceVisits) != 2 { + t.Errorf("device_visits 未落盘: %v", entries[0].DeviceVisits) + } +} + +// 守门 4:handleStats 的 total_visits 同样不得放大。 +func TestStatsNotInflated(t *testing.T) { + withTempHistory(t) + for round := 1; round <= 10; round++ { + postSync(t, SyncItem{URL: "https://ex.com/s", Device: "laptop", + Timestamp: float64(1700000000 + round*30), VisitCount: round}) + } + req := httptest.NewRequest(http.MethodGet, "/stats", nil) + w := httptest.NewRecorder() + handleStats(w, req) + var out struct { + Unique int `json:"unique_urls"` + Total int `json:"total_visits"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("stats 解析失败: %v", err) + } + if out.Total != 10 { + t.Errorf("total_visits = %d;真实 10 次访问,期望 10", out.Total) + } +} + +// 守门 5:rebuildDashySections 产出的「N次访问」文案要反映真实计数。 +func TestHistoryDescriptionUsesRealCount(t *testing.T) { + dir := t.TempDir() + oldDir := dataDir + dataDir = dir + defer func() { dataDir = oldDir }() + + // conf.yml 需含「常用服务」与「历史访问」两个分组 + conf := `sections: + - name: 常用服务 + items: [] + - name: 历史访问 + items: [] +` + if err := os.WriteFile(filepath.Join(dir, "conf.yml"), []byte(conf), 0o644); err != nil { + t.Fatal(err) + } + history = map[string]*Entry{ + "https://ex.com/x": {URL: "https://ex.com/x", Title: "X", TotalVisits: 9}, + } + rebuildDashySections() + + data, err := os.ReadFile(filepath.Join(dir, "conf.yml")) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Sections []struct { + Name string `yaml:"name"` + Items []struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + } `yaml:"items"` + } `yaml:"sections"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + for _, s := range cfg.Sections { + if s.Name != histSectionZh { + continue + } + for _, it := range s.Items { + if it.Title == "X" && it.Description != "9次访问" { + t.Errorf("历史项文案 = %q,期望 %q", it.Description, "9次访问") + } + } + } +} diff --git a/history-server/getcap_test.go b/history-server/getcap_test.go new file mode 100644 index 0000000..0c8bcdd --- /dev/null +++ b/history-server/getcap_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// 守门:读取侧不得再截断到 100 条。 +// 此前 handleGet 硬砍 100 条,而门户「历史访问」已不限条数 —— 后台因此 +// 看不见也删不掉第 101 条之后的记录。 +func TestHandleGetReturnsAllEntries(t *testing.T) { + withTempHistory(t) + const n = 250 + for i := 0; i < n; i++ { + history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{ + URL: fmt.Sprintf("https://ex.com/%03d", i), + TotalVisits: n - i, // 降序 + } + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handleGet(w, req) + + var out struct { + Items []Entry `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatalf("解析失败: %v", err) + } + if len(out.Items) != n { + t.Errorf("返回 %d 条,期望 %d 条(后台必须能删到每一条)", len(out.Items), n) + } + if out.Total != n { + t.Errorf("total = %d,期望 %d", out.Total, n) + } + // 顺序:按访问次数降序 + for i := 1; i < len(out.Items); i++ { + if out.Items[i-1].TotalVisits < out.Items[i].TotalVisits { + t.Fatalf("第 %d/%d 条顺序错乱:%d < %d", i, len(out.Items), out.Items[i-1].TotalVisits, out.Items[i].TotalVisits) + } + } +} + +// 超过 100 条时,第 101 条仍可被逐条删除(后台删除按钮按 URL 定位)。 +func TestEntryBeyondOldCapIsDeletable(t *testing.T) { + withTempHistory(t) + const n = 150 + for i := 0; i < n; i++ { + history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{ + URL: fmt.Sprintf("https://ex.com/%03d", i), TotalVisits: 1, + } + } + target := "https://ex.com/149" // 旧截断下后台永远看不到这条 + req := httptest.NewRequest(http.MethodDelete, "/item?url="+target, nil) + w := httptest.NewRecorder() + handleDeleteItem(w, req) + if w.Code == http.StatusNotFound { + t.Fatalf("第 150 条无法删除:%s", w.Body.String()) + } + if _, ok := history[target]; ok { + t.Error("删除后仍存在于 history") + } +} diff --git a/history-server/main.go b/history-server/main.go index cd1060e..e81c895 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -25,11 +25,12 @@ var ( ) type Entry struct { - URL string `json:"url"` - Title string `json:"title"` - TotalVisits int `json:"total_visits"` - LastVisit float64 `json:"last_visit"` - Devices []string `json:"devices"` + URL string `json:"url"` + Title string `json:"title"` + TotalVisits int `json:"total_visits"` + LastVisit float64 `json:"last_visit"` + Devices []string `json:"devices"` + DeviceVisits map[string]int `json:"device_visits,omitempty"` } type SyncItem struct { @@ -137,7 +138,33 @@ func handleSync(w http.ResponseWriter, r *http.Request) { item.Device = "unknown" } if e, ok := history[normalized]; ok { - e.TotalVisits += item.VisitCount + // visit_count 是**绝对累计值**,不是增量。 + // + // 浏览器扩展每 30s 跑一次 `chrome.history.search`,其 `visitCount` + // 字段是「该 URL 有史以来被访问的总次数」,每轮都是一个更大的 + // 绝对数(1,2,3,4,5…),而不是「这一轮新增几次」。早期这里写成 + // `e.TotalVisits += item.VisitCount`,把绝对值当增量反复累加: + // n 次真实访问被记成 n(n+1)/2(实测 30 次 → 465 次,虚高 15.5×), + // 而「常用服务」正是按该字段排序的,等于把排名整个算错。 + // + // 正确语义:**按设备分别取绝对值的最大值,再求和**。 + // 同一设备重复上报被 max 吸收(不会重复累加),多设备各报各的 + // 累计值,相加才是真实总数。 + if e.DeviceVisits == nil { + e.DeviceVisits = map[string]int{} + // 兼容没有 DeviceVisits 的旧记录:把已有总量记在第一个设备上, + // 避免升级后凭空归零。 + if len(e.Devices) > 0 && e.TotalVisits > 0 { + e.DeviceVisits[e.Devices[0]] = e.TotalVisits + } + } + if item.VisitCount > e.DeviceVisits[item.Device] { + e.DeviceVisits[item.Device] = item.VisitCount + } + e.TotalVisits = 0 + for _, v := range e.DeviceVisits { + e.TotalVisits += v + } if item.Timestamp > e.LastVisit { e.LastVisit = item.Timestamp } @@ -158,6 +185,7 @@ func handleSync(w http.ResponseWriter, r *http.Request) { history[normalized] = &Entry{ URL: normalized, Title: item.Title, TotalVisits: item.VisitCount, LastVisit: item.Timestamp, Devices: []string{item.Device}, + DeviceVisits: map[string]int{item.Device: item.VisitCount}, } } } @@ -174,6 +202,15 @@ func handleSync(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": total}) } +// handleGet 返回全部历史(按访问次数降序)。 +// +// 此前这里硬截断到 100 条,而门户的「历史访问」分组早已改为**不限条数** +// (用户 2026-09-13 要求)。两者不一致带来一个真实缺陷:管理后台只能 +// 删除它能看到的 100 条,第 101 条之后既看不见也删不掉,只能「清空全部」。 +// 既然展示侧已经不限制,读取侧就不该再截断;total 一并返回给后台显示总数。 +// +// 体积可控:这里返回的是唯一 URL 列表(不是访问流水),条数等于历史里 +// 不同站点的数量,与门户「历史访问」分组的条目数完全一致。 func handleGet(w http.ResponseWriter, r *http.Request) { mu.RLock() defer mu.RUnlock() @@ -185,12 +222,9 @@ func handleGet(w http.ResponseWriter, r *http.Request) { sort.Slice(entries, func(i, j int) bool { return entries[i].TotalVisits > entries[j].TotalVisits }) - if len(entries) > 100 { - entries = entries[:100] - } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"items": entries}) + json.NewEncoder(w).Encode(map[string]any{"items": entries, "total": len(entries)}) } func handleClear(w http.ResponseWriter, r *http.Request) { @@ -269,13 +303,6 @@ type dashyNav struct { title, desc, icon, url string } -// dashySection 是 conf.yml 里的一个 section -type dashySection struct { - name, icon string - collapsed bool - items []dashyNav -} - // ── 导航项点击计数(独立统计,不与浏览器历史混用)── // // 用户要求:导航项自己的访问频率要单独统计,而不是拿浏览器历史反推 @@ -334,19 +361,6 @@ func handleNavVisit(w http.ResponseWriter, r *http.Request) { rebuildDashySections() // 让「常用服务」立即按新计数重排 } -// hostVisits 统计 history 中每个 host 的累计访问 -func hostVisits() map[string]int { - mu.RLock() - defer mu.RUnlock() - out := map[string]int{} - for _, e := range history { - if u, err := url.Parse(e.URL); err == nil { - out[strings.ToLower(u.Hostname())] += e.TotalVisits - } - } - return out -} - // 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。 const ( favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务 @@ -469,7 +483,7 @@ func rebuildDashySections() { if isHistSection(si.name) { continue } // 所有导航项(含常用服务原本手动配置的)都进候选, - // 由访问量 + code-host 置顶决定最终 Top4 + // 最终由访问量决定去留与顺序 candidates = append(candidates, cand{ nav: dashyNav{title: title, desc: desc, icon: icon, url: u}, host: host, visits: v, @@ -552,58 +566,56 @@ func rebuildDashySections() { // 用户要求(2026-09-13):不再排除"已是导航项"的站点 —— 站内服务的真实访问记录 // 也要在这里出现(相当于"最近访问"列表)。此前排除是为了不与其它分组重复, // 但结果是站内服务永远进不来,这一组几乎是空的。 - if true { - mu.RLock() - entries := make([]*Entry, 0, len(history)) - for _, e := range history { - entries = append(entries, e) + mu.RLock() + entries := make([]*Entry, 0, len(history)) + for _, e := range history { + entries = append(entries, e) + } + mu.RUnlock() + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].TotalVisits > entries[j].TotalVisits + }) + // 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示, + // 排序仍按访问次数降序;首条固定为插件安装入口。 + histItems := make([]any, 0, len(entries)+1) + // 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留, + // 已装的浏览器由 navi.js 隐藏。 + histItems = append(histItems, extensionInstallItem()) + for _, e := range entries { + title := friendlyHistoryTitle(e.Title, e.URL) + icon := "fas fa-globe" + if hp := hostPortOf(e.URL); hp != "" { + if name := cachedFavicon(hp); name != "" { + icon = name // 与手工导航项同一套本地图标机制 + } else { + queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml + } } - mu.RUnlock() - sort.SliceStable(entries, func(i, j int) bool { - return entries[i].TotalVisits > entries[j].TotalVisits + histItems = append(histItems, map[string]any{ + "title": title, + "description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits), + "icon": icon, "url": e.URL, }) - // 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示, - // 排序仍按访问次数降序;首条固定为插件安装入口。 - histItems := make([]any, 0, len(entries)+1) - // 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留, - // 已装的浏览器由 navi.js 隐藏。 - histItems = append(histItems, extensionInstallItem()) - for _, e := range entries { - title := friendlyHistoryTitle(e.Title, e.URL) - icon := "fas fa-globe" - if hp := hostPortOf(e.URL); hp != "" { - if name := cachedFavicon(hp); name != "" { - icon = name // 与手工导航项同一套本地图标机制 - } else { - queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml - } - } - histItems = append(histItems, map[string]any{ - "title": title, - "description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits), - "icon": icon, "url": e.URL, - }) + } + foundHist := false + for _, si := range secs { + if isHistSection(si.name) { + si.raw["items"] = histItems + foundHist = true } - foundHist := false - for _, si := range secs { - if isHistSection(si.name) { - si.raw["items"] = histItems - foundHist = true - } - } - // 配置里没有「历史访问」分组时补一个。该分组只能由这里创建: - // 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写), - // 所以若只靠用户手动建,功能就一直不会出现。 - if !foundHist { - sectionsRaw = append(sectionsRaw, map[string]any{ - "name": histSectionZh, - "icon": "fas fa-history", - "collapsed": false, - "items": histItems, - }) - fmt.Println("[history] 已补建「历史访问」分组") - } + } + // 配置里没有「历史访问」分组时补一个。该分组只能由这里创建: + // 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写), + // 所以若只靠用户手动建,功能就一直不会出现。 + if !foundHist { + sectionsRaw = append(sectionsRaw, map[string]any{ + "name": histSectionZh, + "icon": "fas fa-history", + "collapsed": false, + "items": histItems, + }) + fmt.Println("[history] 已补建「历史访问」分组") } cfg["sections"] = sectionsRaw @@ -638,29 +650,6 @@ func friendlyHistoryTitle(title, rawURL string) string { return "历史站点" } -func hostOf(rawURL string) string { - if u, err := url.Parse(rawURL); err == nil { - return strings.ToLower(u.Hostname()) - } - return "" -} - -func visitCount(rawURL string) int { - u, err := url.Parse(rawURL) - if err != nil { - return 0 - } - u.Fragment = "" - u.Host = strings.ToLower(u.Host) - key := u.String() - mu.RLock() - defer mu.RUnlock() - if e, ok := history[key]; ok { - return e.TotalVisits - } - return 0 -} - func main() { os.MkdirAll(dataDir, 0755) load() @@ -722,25 +711,3 @@ func main() { log.Println("history-server listening on :3004") log.Fatal(http.ListenAndServe(":3004", nil)) } - -// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词, -// 避免 "git." 子串误匹配域名中段(如 your-dify.example)。 -func isCodeHostVal(title, host, url string) bool { - lowT := strings.ToLower(title) - if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") || - strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") || - strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") { - return true - } - h := strings.ToLower(host) - if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") || - strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") || - strings.HasPrefix(h, "github.") { - return true - } - if strings.Contains(h, "gitea") || strings.Contains(h, "github") || - strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") { - return true - } - return false -} diff --git a/history-server/synccount_test.go b/history-server/synccount_test.go new file mode 100644 index 0000000..5d799ee --- /dev/null +++ b/history-server/synccount_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +// 辅助:切换到临时数据目录并重置全局 history。 +func withTempHistory(t *testing.T) { + t.Helper() + oldDir, oldFile := dataDir, historyFile + dataDir = t.TempDir() + historyFile = filepath.Join(dataDir, "history.json") + history = map[string]*Entry{} + t.Cleanup(func() { dataDir, historyFile = oldDir, oldFile }) +} + +func postSync(t *testing.T, items ...SyncItem) { + t.Helper() + body, _ := json.Marshal(SyncRequest{Items: items}) + req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer t") + old := syncToken + syncToken = "t" + defer func() { syncToken = old }() + w := httptest.NewRecorder() + handleSync(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } +} + +// 多设备:每台设备各报自己的绝对累计值,应求和而非取全局 max。 +func TestSyncSumsAcrossDevices(t *testing.T) { + withTempHistory(t) + // 笔记本上该 URL 累计 10 次;手机上是 4 次 + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 10}) + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000060, VisitCount: 4}) + if got := history["https://ex.com/a"].TotalVisits; got != 14 { + t.Errorf("TotalVisits = %d; 两设备绝对值应求和 10+4=14", got) + } + // 两台设备各自继续增长,重复上报不重复累加 + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000120, VisitCount: 12}) + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000180, VisitCount: 4}) + if got := history["https://ex.com/a"].TotalVisits; got != 16 { + t.Errorf("TotalVisits = %d; 期望 12+4=16(phone 的 4 不应再加一次)", got) + } +} + +// 上报值变小(用户清了浏览器历史)时,不得把已有计数拉低。 +func TestSyncNeverDecreasesOnStaleReport(t *testing.T) { + withTempHistory(t) + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 20}) + postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000060, VisitCount: 3}) + if got := history["https://ex.com/a"].TotalVisits; got != 20 { + t.Errorf("TotalVisits = %d; 迟到的陈旧上报不应把 20 拉低到 3", got) + } +} + +// 旧版 history.json 没有 device_visits 字段,升级后不能归零。 +func TestSyncLegacyEntryWithoutDeviceVisits(t *testing.T) { + withTempHistory(t) + // 模拟旧记录:只有 devices + total_visits + history["https://ex.com/old"] = &Entry{ + URL: "https://ex.com/old", Title: "Old", TotalVisits: 8, + Devices: []string{"laptop"}, LastVisit: 1700000000, + } + postSync(t, SyncItem{URL: "https://ex.com/old", Device: "laptop", Timestamp: 1700000060, VisitCount: 9}) + if got := history["https://ex.com/old"].TotalVisits; got != 9 { + t.Errorf("TotalVisits = %d; 旧记录的 8 次应在同一设备上被 9 接续,得到 9", got) + } + // 另一台设备加入 → 9 + 其自身绝对值 + postSync(t, SyncItem{URL: "https://ex.com/old", Device: "phone", Timestamp: 1700000120, VisitCount: 2}) + if got := history["https://ex.com/old"].TotalVisits; got != 11 { + t.Errorf("TotalVisits = %d; 期望 9+2=11", got) + } +}