Persist how many return items each sync run flipped to "missing" and how many came back to "ok" (yeeke_sync_run.missing_marked_count / recovered_count, migration 1789801000000), return them from the sync-runs API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the 20% safety valve skips marking the count stays 0 and the reason remains in error_message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
407 lines
15 KiB
Go
407 lines
15 KiB
Go
package yeeke
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"go-admin/app/goauto/models"
|
|
"go-admin/app/goauto/yeekeclient"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fivePackagesFirstReport builds a first-sync page of 5 distinct packages
|
|
// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%,
|
|
// the safety-valve boundary, which is not ">20%") still marks it missing.
|
|
func fivePackagesFirstReport() []string {
|
|
recs := make([]string, 0, 5)
|
|
for i := 1; i <= 5; i++ {
|
|
id := fmt.Sprintf("p%d", i)
|
|
recs = append(recs, record(id, "i", "v"+id, 1))
|
|
}
|
|
return recs
|
|
}
|
|
|
|
// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an
|
|
// earlier sync of 5 packages; a later, naturally complete run only reports
|
|
// the other 4 (p2..p5). p1's item and package must both flip to
|
|
// sync_status="missing" with missing_since set; p2..p5 must stay "ok".
|
|
func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 10})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync: %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
rep, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("second sync: %v", err)
|
|
}
|
|
if rep.Status != "succeeded" {
|
|
t.Fatalf("rep=%+v", rep)
|
|
}
|
|
if rep.MissingMarked != 1 {
|
|
t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked)
|
|
}
|
|
|
|
var p1 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
|
|
t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1)
|
|
}
|
|
var i1 models.YeekeReturnItem
|
|
if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if i1.SyncStatus != "missing" || i1.MissingSince == nil {
|
|
t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1)
|
|
}
|
|
|
|
var p2 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
|
t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2)
|
|
}
|
|
}
|
|
|
|
// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed".
|
|
// Nothing must be marked missing even though p1 (from an earlier run) is
|
|
// absent from this run's (incomplete) output.
|
|
func TestFailedPageStopsMarking(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync: %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
var calls int32
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n := atomic.AddInt32(&calls, 1)
|
|
if n == 1 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
|
|
return
|
|
}
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err == nil {
|
|
t.Fatal("expected the second run (failed page 2) to error")
|
|
}
|
|
|
|
var p1 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
|
|
t.Fatalf("p1 must stay ok after a failed page, got %+v", p1)
|
|
}
|
|
}
|
|
|
|
// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends
|
|
// with an error and must not mark anything missing.
|
|
func TestCtxCancelStopsMarking(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync: %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var calls int32
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n := atomic.AddInt32(&calls, 1)
|
|
if n == 1 {
|
|
cancel()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
|
|
return
|
|
}
|
|
http.Error(w, "should not be reached", http.StatusInternalServerError)
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
if _, err := s.Sync(ctx, "manual"); err == nil {
|
|
t.Fatal("expected a context-cancellation error")
|
|
}
|
|
|
|
var p1 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
|
|
t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1)
|
|
}
|
|
}
|
|
|
|
// TestDuplicateFingerprintStopsMarking: the walk ends via the
|
|
// duplicate-page break, not a natural stop, so nothing must be marked even
|
|
// though only p1 (not p2 from an earlier sync) is ever reported.
|
|
func TestDuplicateFingerprintStopsMarking(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 1})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync (seeds p2): %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
// Second run: page 1 returns p1, but the server (mis)reports pages=5 and
|
|
// then serves the exact same page again, triggering the duplicate break
|
|
// before ever reaching a natural stop.
|
|
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
|
|
var calls int32
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&calls, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, body)
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
rep, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("second sync: %v", err)
|
|
}
|
|
if rep.MissingMarked != 0 {
|
|
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
|
|
}
|
|
|
|
var p2 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
|
t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2)
|
|
}
|
|
}
|
|
|
|
// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any
|
|
// natural stop condition being hit, so nothing must be marked.
|
|
func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 1})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync (seeds p2): %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
// Second run: every page returns a distinct full page (never short,
|
|
// never empty, pages always reported far beyond MaxPages), so the walk
|
|
// only stops because MaxPages is exhausted.
|
|
var n int32
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
k := atomic.AddInt32(&n, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000))
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
s.cfg.MaxPages = 2
|
|
rep, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("second sync: %v", err)
|
|
}
|
|
if rep.MissingMarked != 0 {
|
|
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
|
|
}
|
|
|
|
var p2 models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
|
|
t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2)
|
|
}
|
|
}
|
|
|
|
// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a
|
|
// complete follow-up run only reports 1 of them (2 of 3 = 67% would be
|
|
// marked, well over the 20% threshold). Nothing must be marked, and the
|
|
// run's error_message must explain why.
|
|
func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 10})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1), record("p2", "i", "v2", 1), record("p3", "i", "v3", 1)}, 3, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("first sync: %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 1, 1))
|
|
}))
|
|
defer srv2.Close()
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
rep, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("second sync: %v", err)
|
|
}
|
|
if rep.Status != "succeeded" {
|
|
t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status)
|
|
}
|
|
if rep.MissingMarked != 0 {
|
|
t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked)
|
|
}
|
|
var run models.YeekeSyncRun
|
|
if e := db.First(&run, rep.RunID).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if !strings.Contains(run.ErrorMessage, "20%") {
|
|
t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage)
|
|
}
|
|
|
|
for _, ext := range []string{"p2", "p3"} {
|
|
var p models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p.SyncStatus != "ok" || p.MissingSince != nil {
|
|
t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReappearingRecordRecoversFromMissing: a package/item marked missing by
|
|
// an earlier complete run reappears in a later sync and must recover to
|
|
// sync_status="ok" with missing_since cleared.
|
|
func TestReappearingRecordRecoversFromMissing(t *testing.T) {
|
|
db := testDB(t)
|
|
c, _ := yeekeclient.New("http://unused.invalid")
|
|
s := NewService(db, c, Config{PageSize: 10})
|
|
|
|
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv1.URL)
|
|
if _, err := s.Sync(context.Background(), "manual"); err != nil {
|
|
t.Fatalf("run 1: %v", err)
|
|
}
|
|
srv1.Close()
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
|
|
}))
|
|
s.client, _ = yeekeclient.New(srv2.URL)
|
|
rep2, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("run 2: %v", err)
|
|
}
|
|
if rep2.MissingMarked != 1 {
|
|
t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked)
|
|
}
|
|
srv2.Close()
|
|
|
|
var p1 models.YeekeReturnPackage
|
|
db.Where("external_id = ?", "p1").First(&p1)
|
|
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
|
|
t.Fatalf("p1 must be missing before recovery, got %+v", p1)
|
|
}
|
|
|
|
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
|
|
}))
|
|
defer srv3.Close()
|
|
s.client, _ = yeekeclient.New(srv3.URL)
|
|
rep3, err := s.Sync(context.Background(), "manual")
|
|
if err != nil {
|
|
t.Fatalf("run 3 (recovery): %v", err)
|
|
}
|
|
if rep3.Recovered != 1 || rep3.MissingMarked != 0 {
|
|
t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked)
|
|
}
|
|
// Both counters are persisted on the run rows shown by the sync-runs page.
|
|
var run2, run3 models.YeekeSyncRun
|
|
if e := db.First(&run2, rep2.RunID).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if e := db.First(&run3, rep3.RunID).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 {
|
|
t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount)
|
|
}
|
|
|
|
// A fresh variable is used here (not the p1 declared above): GORM's Scan
|
|
// does not reset an already non-nil pointer field to nil when the new
|
|
// row's column is NULL, so reusing the earlier struct would misreport a
|
|
// stale MissingSince even though the row itself recovered correctly.
|
|
var recovered models.YeekeReturnPackage
|
|
if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if recovered.SyncStatus != "ok" || recovered.MissingSince != nil {
|
|
t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered)
|
|
}
|
|
var i1 models.YeekeReturnItem
|
|
if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if i1.SyncStatus != "ok" || i1.MissingSince != nil {
|
|
t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1)
|
|
}
|
|
}
|