bb96bceb04
- schemas/collector-trigger.v1.schema.json:collector→named-webhook 觸發 payload(source_hash/r2_key/renamed/R6 warnings) - schemas/kbdb-ingest-request.v1.schema.json:source_hash 必填缺=400;儲存映射走 metadata_json.$.source_hash(守 kbdb 表不變鐵律);同 hash 重送=200 already_ingested - schemas/MIGRATION-webhook-payload.md:舊 Gitea push payload 逐欄對照(給 task 4 改寫 workflow) - collector/:Go module(純 stdlib)——manifest 讀寫+mtime fast-path 掃描+renamed hash 配對+40% 大量刪除防呆;CLI collector scan - go test ./... 全綠 7/7(added/modified/removed/renamed/防呆五情境+fast-path+manifest 往返) - Node 版 collector 標 legacy 並存(SDD task 4 拆除) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
288 lines
8.9 KiB
Go
288 lines
8.9 KiB
Go
package main
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
var baseTime = time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||
|
||
func writeFile(t *testing.T, root, rel, content string, mtime time.Time) {
|
||
t.Helper()
|
||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.Chtimes(p, mtime, mtime); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func hashOf(content string) string {
|
||
sum := sha256.Sum256([]byte(content))
|
||
return "sha256:" + hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func newTestManifest() *Manifest {
|
||
return &Manifest{FolderID: "test-folder-id", Entries: map[string]*ManifestEntry{}}
|
||
}
|
||
|
||
// markIngested 模擬「上一輪 ingest 全部成功」:ingested_hash 補成 content_hash。
|
||
func markIngested(m *Manifest) {
|
||
for _, e := range m.Entries {
|
||
e.IngestedHash = e.ContentHash
|
||
e.IngestedAt = 1
|
||
}
|
||
}
|
||
|
||
func mustScan(t *testing.T, root string, m *Manifest) *TriggerPayload {
|
||
t.Helper()
|
||
p, err := Scan(root, m, ScanOptions{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func eventsOfType(p *TriggerPayload, typ string) []Event {
|
||
var out []Event
|
||
for _, e := range p.Events {
|
||
if e.Type == typ {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 情境 1:added——新檔進來,事件帶 source_hash / size / r2_key,manifest 記錄但不標已 ingest。
|
||
func TestAdded(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "hello world\n", baseTime)
|
||
m := newTestManifest()
|
||
|
||
p := mustScan(t, root, m)
|
||
|
||
if p.SchemaVersion != 1 || p.FolderID != "test-folder-id" {
|
||
t.Fatalf("payload 頭部不對: %+v", p)
|
||
}
|
||
if len(p.Events) != 1 {
|
||
t.Fatalf("要 1 個事件,得到 %d: %+v", len(p.Events), p.Events)
|
||
}
|
||
ev := p.Events[0]
|
||
wantHash := hashOf("hello world\n")
|
||
if ev.Type != "added" || ev.Path != "a.md" || ev.SourceHash != wantHash {
|
||
t.Fatalf("added 事件不對: %+v", ev)
|
||
}
|
||
if ev.R2Key != "raw/"+wantHash[len("sha256:"):] {
|
||
t.Fatalf("r2_key 不對: %s", ev.R2Key)
|
||
}
|
||
if ev.Size == nil || *ev.Size != int64(len("hello world\n")) {
|
||
t.Fatalf("size 不對: %+v", ev.Size)
|
||
}
|
||
e := m.Entries["a.md"]
|
||
if e == nil || e.ContentHash != wantHash || e.IngestedHash != "" {
|
||
t.Fatalf("manifest 記錄不對: %+v", e)
|
||
}
|
||
|
||
// R3:模擬 ingest 成功後重掃(=daemon 重啟),未變更檔案零事件。
|
||
markIngested(m)
|
||
p2 := mustScan(t, root, m)
|
||
if len(p2.Events) != 0 {
|
||
t.Fatalf("未變更檔案不得重送,得到: %+v", p2.Events)
|
||
}
|
||
}
|
||
|
||
// 情境 2:modified——內容變(mtime 也變)→ modified 事件帶新 hash;ingested_hash 保留舊值(可重試語意)。
|
||
func TestModified(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "v1\n", baseTime)
|
||
m := newTestManifest()
|
||
mustScan(t, root, m)
|
||
markIngested(m)
|
||
oldIngested := m.Entries["a.md"].IngestedHash
|
||
|
||
writeFile(t, root, "a.md", "v2 changed\n", baseTime.Add(time.Hour))
|
||
p := mustScan(t, root, m)
|
||
|
||
if len(p.Events) != 1 {
|
||
t.Fatalf("要 1 個事件,得到: %+v", p.Events)
|
||
}
|
||
ev := p.Events[0]
|
||
wantHash := hashOf("v2 changed\n")
|
||
if ev.Type != "modified" || ev.Path != "a.md" || ev.SourceHash != wantHash || ev.R2Key == "" {
|
||
t.Fatalf("modified 事件不對: %+v", ev)
|
||
}
|
||
e := m.Entries["a.md"]
|
||
if e.ContentHash != wantHash {
|
||
t.Fatalf("manifest content_hash 未更新: %+v", e)
|
||
}
|
||
if e.IngestedHash != oldIngested {
|
||
t.Fatalf("ingested_hash 不該在掃描階段被動(上傳成功才回寫): %+v", e)
|
||
}
|
||
}
|
||
|
||
// 情境 3:removed——比例低於防呆門檻時正常發下架事件(帶最後已知 hash),manifest 條目移除。
|
||
func TestRemoved(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "aaa\n", baseTime)
|
||
writeFile(t, root, "b.md", "bbb\n", baseTime)
|
||
writeFile(t, root, "c.md", "ccc\n", baseTime)
|
||
m := newTestManifest()
|
||
mustScan(t, root, m)
|
||
markIngested(m)
|
||
|
||
if err := os.Remove(filepath.Join(root, "b.md")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := mustScan(t, root, m) // 1/3 = 33% ≤ 40% → 放行
|
||
|
||
if len(p.Warnings) != 0 {
|
||
t.Fatalf("不該觸發防呆: %+v", p.Warnings)
|
||
}
|
||
rm := eventsOfType(p, "removed")
|
||
if len(rm) != 1 || rm[0].Path != "b.md" || rm[0].SourceHash != hashOf("bbb\n") {
|
||
t.Fatalf("removed 事件不對: %+v", rm)
|
||
}
|
||
if len(p.Events) != 1 {
|
||
t.Fatalf("其餘檔案不該有事件: %+v", p.Events)
|
||
}
|
||
if _, ok := m.Entries["b.md"]; ok {
|
||
t.Fatal("removed 後 manifest 條目應移除")
|
||
}
|
||
if len(m.Entries) != 2 {
|
||
t.Fatalf("manifest 應剩 2 條: %+v", m.Entries)
|
||
}
|
||
}
|
||
|
||
// 情境 4:renamed——改名/搬移以 content_hash 配對,只更新路徑映射:
|
||
// 不重萃(ingested_hash 原樣搬)、不誤刪(零 removed/added 事件)。
|
||
func TestRenamed(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "same content\n", baseTime)
|
||
writeFile(t, root, "keep.md", "keep\n", baseTime)
|
||
m := newTestManifest()
|
||
mustScan(t, root, m)
|
||
markIngested(m)
|
||
ingestedAt := m.Entries["a.md"].IngestedAt
|
||
|
||
// 搬進子資料夾+改名(同內容)
|
||
writeFile(t, root, "sub/b.md", "same content\n", baseTime.Add(time.Hour))
|
||
if err := os.Remove(filepath.Join(root, "a.md")); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := mustScan(t, root, m)
|
||
|
||
if len(p.Events) != 1 {
|
||
t.Fatalf("改名只該有 1 個 renamed 事件,得到: %+v", p.Events)
|
||
}
|
||
ev := p.Events[0]
|
||
if ev.Type != "renamed" || ev.Path != "sub/b.md" || ev.OldPath != "a.md" || ev.SourceHash != hashOf("same content\n") {
|
||
t.Fatalf("renamed 事件不對: %+v", ev)
|
||
}
|
||
if len(eventsOfType(p, "removed"))+len(eventsOfType(p, "added")) != 0 {
|
||
t.Fatalf("改名不得產生 removed/added(誤刪/重萃): %+v", p.Events)
|
||
}
|
||
if _, ok := m.Entries["a.md"]; ok {
|
||
t.Fatal("舊路徑條目應消失")
|
||
}
|
||
ne := m.Entries["sub/b.md"]
|
||
if ne == nil || ne.IngestedHash != hashOf("same content\n") || ne.IngestedAt != ingestedAt {
|
||
t.Fatalf("路徑映射未保留 ingest 狀態(會導致重萃): %+v", ne)
|
||
}
|
||
}
|
||
|
||
// 情境 5:大量刪除防呆——資料夾整個清空(100% > 40%),卡片零下架+警告有發出,manifest 條目保留。
|
||
func TestMassDeleteGuard(t *testing.T) {
|
||
root := t.TempDir()
|
||
names := []string{"a.md", "b.md", "c.md", "d.md", "e.md"}
|
||
for _, n := range names {
|
||
writeFile(t, root, n, n+" content\n", baseTime)
|
||
}
|
||
m := newTestManifest()
|
||
mustScan(t, root, m)
|
||
markIngested(m)
|
||
|
||
for _, n := range names { // 模擬資料夾未掛載/清空
|
||
if err := os.Remove(filepath.Join(root, n)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
p := mustScan(t, root, m)
|
||
|
||
if len(eventsOfType(p, "removed")) != 0 {
|
||
t.Fatalf("防呆觸發時 removed 必須全數壓下: %+v", p.Events)
|
||
}
|
||
if len(p.Warnings) != 1 || p.Warnings[0].Code != "mass_delete_guard" {
|
||
t.Fatalf("警告未發出: %+v", p.Warnings)
|
||
}
|
||
w := p.Warnings[0]
|
||
if w.RemovedCount != 5 || w.ManifestCount != 5 || w.ThresholdRatio != DefaultMaxRemovedRatio {
|
||
t.Fatalf("警告數字不對: %+v", w)
|
||
}
|
||
if len(m.Entries) != 5 {
|
||
t.Fatalf("防呆觸發時 manifest 條目必須保留(下輪重評): %d", len(m.Entries))
|
||
}
|
||
|
||
// 檔案回來(誤刪復原)→ 內容沒變、mtime 相同 → 零事件、警告消失。
|
||
for _, n := range names {
|
||
writeFile(t, root, n, n+" content\n", baseTime)
|
||
}
|
||
p2 := mustScan(t, root, m)
|
||
if len(p2.Events) != 0 || len(p2.Warnings) != 0 {
|
||
t.Fatalf("復原後應零事件零警告: events=%+v warnings=%+v", p2.Events, p2.Warnings)
|
||
}
|
||
}
|
||
|
||
// 附加:mtime fast-path——mtime/size 沒變不重算 hash(用「內容偷偷改但 mtime 不動」驗證 fast-path 真的生效)。
|
||
func TestMtimeFastPath(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "AAAA\n", baseTime) // 與替換內容等長 → size 相同
|
||
m := newTestManifest()
|
||
mustScan(t, root, m)
|
||
markIngested(m)
|
||
|
||
// 同 size、同 mtime、內容不同:fast-path 應沿用舊 hash → 零事件(設計如此:mtime 只是 fast-path)
|
||
writeFile(t, root, "a.md", "BBBB\n", baseTime)
|
||
p := mustScan(t, root, m)
|
||
if len(p.Events) != 0 {
|
||
t.Fatalf("fast-path 未生效(mtime/size 未變不該重算 hash): %+v", p.Events)
|
||
}
|
||
// mtime 一變就會重算 → 抓到真變更
|
||
writeFile(t, root, "a.md", "BBBB\n", baseTime.Add(time.Minute))
|
||
p2 := mustScan(t, root, m)
|
||
if len(p2.Events) != 1 || p2.Events[0].Type != "modified" {
|
||
t.Fatalf("mtime 變更後應抓到 modified: %+v", p2.Events)
|
||
}
|
||
}
|
||
|
||
// 附加:manifest 存讀往返。
|
||
func TestManifestRoundTrip(t *testing.T) {
|
||
dir := t.TempDir()
|
||
mp := filepath.Join(dir, "manifest.json")
|
||
m, err := LoadManifest(mp, "/tmp/kb")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m.FolderID == "" {
|
||
t.Fatal("新 manifest 應生成 folder_id")
|
||
}
|
||
m.Entries["x.md"] = &ManifestEntry{ContentHash: hashOf("x"), Size: 1, Mtime: 2, IngestedHash: hashOf("x"), IngestedAt: 3}
|
||
if err := m.Save(mp); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
m2, err := LoadManifest(mp, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if m2.FolderID != m.FolderID || m2.Entries["x.md"] == nil || m2.Entries["x.md"].IngestedAt != 3 {
|
||
t.Fatalf("往返不一致: %+v", m2)
|
||
}
|
||
}
|