Files
cmsp/internal/erpgo/sync_test.go
T

288 lines
10 KiB
Go

package erpgo
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"cmsp/internal/store"
)
func syncTestDB(t *testing.T) *store.Store {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "fictional.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.SyncProducts([]store.Product{{ID: "a-product", PlatformShopID: "a", ItemName: "old-title"}}, map[string][]store.Diagnosis{"a-product": {{Type: "old-diagnosis"}}}, "old-time"); err != nil {
t.Fatal(err)
}
if err := db.UpdateProductStatus("a-product", store.VideoFound, store.DownloadDone, store.UploadDone, "old-error"); err != nil {
t.Fatal(err)
}
if err := db.ReplaceVideos("a-product", []store.Video{{SourceItem: "fictional-source", Status: store.VideoStatusUploaded, RemoteURL: "https://example.invalid/video.mp4"}}, "old-time"); err != nil {
t.Fatal(err)
}
if err := db.ReplaceShops([]store.Shop{{ID: "cached", PlatformShopID: "cached", ShopName: "cached"}}, "old-time"); err != nil {
t.Fatal(err)
}
return db
}
func writeSyncShops(w http.ResponseWriter, ids ...string) {
items := make([]Shop, 0, len(ids))
for _, id := range ids {
items = append(items, Shop{ID: "fictional-" + id, PlatformShopID: id, ShopName: "fictional " + id, Platform: "0"})
}
writeResponse(w, 200, map[string]any{"source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "items": items}, "")
}
func syncTestPage(shop string, current, pages int, id string) productPage {
p := sampleProduct(id)
p.PlatformShopID, p.ItemName = shop, "new-title"
page := samplePage(current, p)
page.PlatformShopID, page.Pages = shop, pages
if pages == 1 {
page.Total = 1
}
return page
}
func assertSyncProduct(t *testing.T, db *store.Store, unchanged bool) {
t.Helper()
p, found, err := db.GetProduct("a-product")
d, de := db.ListDiagnoses("a-product")
v, ve := db.ListVideos("a-product")
wantTitle, wantDiagnosis := "new-title", "缺少视频"
if unchanged {
wantTitle, wantDiagnosis = "old-title", "old-diagnosis"
}
if err != nil || !found || de != nil || ve != nil || p.ItemName != wantTitle || p.VideoStatus != store.VideoFound || p.DownloadStatus != store.DownloadDone || p.UploadStatus != store.UploadDone || p.LastError != "old-error" || len(d) != 1 || d[0].Type != wantDiagnosis || len(v) != 1 || v[0].Status != store.VideoStatusUploaded || v[0].RemoteURL != "https://example.invalid/video.mp4" {
t.Fatalf("product data/state/video changed unexpectedly: %+v", p)
}
if unchanged && p.SyncedAt != "old-time" {
t.Fatal("failed shop was committed")
}
}
func TestSyncAllShopsSerialPagesAndPartialFailure(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(strconv.FormatBool(fail), func(t *testing.T) {
db := syncTestDB(t)
var mu sync.Mutex
var requests []string
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.Header.Get("X-API-Key") != fictionalKey {
t.Error("unexpected request")
}
mu.Lock()
requests = append(requests, r.URL.Path+"?"+r.URL.RawQuery)
mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/shops") {
writeSyncShops(w, "a", "b")
return
}
shop := r.URL.Query().Get("platformShopId")
current, _ := strconv.Atoi(r.URL.Query().Get("current"))
if shop == "a" {
if current == 2 && fail {
writeResponse(w, 502, nil, "HHH_UPSTREAM_ERROR")
return
}
if current == 2 {
assertSyncProduct(t, db, true)
}
writeResponse(w, 200, syncTestPage(shop, current, 2, "a-product"), "")
return
}
if shop != "b" {
t.Error("did not use latest shop list")
}
assertSyncProduct(t, db, fail)
writeResponse(w, 200, syncTestPage(shop, 1, 1, "b-product"), "")
})
result, err := client.SyncProductData(context.Background(), db, " ", nil)
if err != nil || !result.AllShops || result.TotalShops != 2 || result.SkippedShops != 0 || result.StopError != nil {
t.Fatalf("unexpected result: %+v, %v", result, err)
}
wantSucceeded, wantFailed := 2, 0
if fail {
wantSucceeded, wantFailed = 1, 1
}
if result.SucceededShops != wantSucceeded || result.ProductCount != wantSucceeded || result.FailedShops != wantFailed || len(result.Failures) != wantFailed {
t.Fatalf("incorrect actual counts: %+v", result)
}
if fail {
assertCode(t, result.Failures[0].Error, "HHH_UPSTREAM_ERROR")
}
assertSyncProduct(t, db, fail)
mu.Lock()
defer mu.Unlock()
prefix := "/api/v1/integrations/huohanhan/"
want := []string{prefix + "shops?", prefix + "products?current=1&platformShopId=a&size=200", prefix + "products?current=2&platformShopId=a&size=200", prefix + "products?current=1&platformShopId=b&size=200"}
if !reflect.DeepEqual(requests, want) {
t.Fatalf("requests not serial: %v", requests)
}
encoded, _ := json.Marshal(result)
if strings.Contains(string(encoded), fictionalKey) {
t.Fatal("raw upstream error leaked")
}
})
}
}
func TestSyncGlobalErrorsStopButShopErrorsContinue(t *testing.T) {
cases := []struct {
code string
status int
stop bool
}{
{"API_KEY_INVALID", 401, true}, {"HHH_AUTH_FAILED", 502, true}, {"RATE_LIMITED", 429, true}, {"SERVICE_UNAVAILABLE", 503, true}, {"INTERNAL_ERROR", 500, true}, {"NETWORK_ERROR", 502, true}, {"REQUEST_CANCELLED", 502, true},
{"SHOP_ACCESS_DENIED", 403, false}, {"INVALID_ARGUMENT", 400, false}, {"HHH_UPSTREAM_ERROR", 502, false}, {"HHH_UPSTREAM_TIMEOUT", 504, false}, {"INVALID_RESPONSE", 502, false},
}
for _, tc := range cases {
t.Run(tc.code, func(t *testing.T) {
db := syncTestDB(t)
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/shops") {
writeSyncShops(w, "a", "b")
return
}
if r.URL.Query().Get("platformShopId") == "a" {
writeResponse(w, tc.status, nil, tc.code)
return
}
if tc.stop {
t.Error("global failure did not stop requests")
}
writeResponse(w, 200, syncTestPage("b", 1, 1, "b-product"), "")
})
result, err := client.SyncProductData(context.Background(), db, "", nil)
if err != nil || result.FailedShops != 1 || len(result.Failures) != 1 {
t.Fatalf("missing partial report: %+v %v", result, err)
}
assertCode(t, result.Failures[0].Error, tc.code)
if tc.stop {
if result.StopError == nil || result.SkippedShops != 1 || result.SucceededShops != 0 {
t.Fatalf("incorrect stop: %+v", result)
}
} else if result.StopError != nil || result.SkippedShops != 0 || result.SucceededShops != 1 {
t.Fatalf("shop error stopped batch: %+v", result)
}
assertSyncProduct(t, db, true)
})
}
}
func TestSyncEmptyShopsAndListFailure(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(strconv.FormatBool(fail), func(t *testing.T) {
db := syncTestDB(t)
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/shops") {
t.Error("unexpected products request")
}
if fail {
writeResponse(w, 401, nil, "API_KEY_INVALID")
} else {
writeSyncShops(w)
}
})
result, err := client.SyncProductData(context.Background(), db, "", nil)
if fail {
assertCode(t, err, "API_KEY_INVALID")
} else if err != nil || result.TotalShops != 0 || result.ProductCount != 0 {
t.Fatalf("empty shops failed: %+v %v", result, err)
}
shops, err := db.ListShops()
if err != nil || (fail && (len(shops) != 1 || shops[0].PlatformShopID != "cached")) || (!fail && len(shops) != 0) {
t.Fatal("shop cache did not preserve/refresh correctly")
}
assertSyncProduct(t, db, true)
})
}
}
func TestSyncCancellationDoesNotCommitPartialShop(t *testing.T) {
db := syncTestDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/shops") {
writeSyncShops(w, "a", "b")
return
}
if r.URL.Query().Get("platformShopId") != "a" || r.URL.Query().Get("current") != "1" {
t.Error("request continued after cancellation")
}
writeResponse(w, 200, syncTestPage("a", 1, 2, "a-product"), "")
})
result, err := client.SyncProductData(ctx, db, "", func(p SyncProgress) {
if p.Current == 1 {
cancel()
}
})
if err != nil || result.FailedShops != 1 || result.SkippedShops != 1 || result.ProductCount != 0 {
t.Fatalf("cancellation failed: %+v %v", result, err)
}
assertCode(t, result.StopError, "REQUEST_CANCELLED")
assertSyncProduct(t, db, true)
}
func TestSyncCrossShopIDConflictDoesNotOverwriteCommittedShop(t *testing.T) {
db := syncTestDB(t)
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/shops") {
writeSyncShops(w, "a", "b", "c")
return
}
shop := r.URL.Query().Get("platformShopId")
id := "a-product"
if shop == "c" {
id = "c-product"
}
writeResponse(w, 200, syncTestPage(shop, 1, 1, id), "")
})
result, err := client.SyncProductData(context.Background(), db, "", nil)
if err != nil || result.SucceededShops != 2 || result.FailedShops != 1 || result.ProductCount != 2 {
t.Fatalf("incorrect conflict result: %+v %v", result, err)
}
assertCode(t, result.Failures[0].Error, "INVALID_RESPONSE")
p, _, _ := db.GetProduct("a-product")
if p.PlatformShopID != "a" {
t.Fatal("conflicting ID overwritten")
}
assertSyncProduct(t, db, false)
}
func TestSyncLocalTransactionFailureContinuesNextShop(t *testing.T) {
db := syncTestDB(t)
if _, err := db.DB().Exec(`CREATE TRIGGER fictional_diagnosis_failure BEFORE INSERT ON product_diagnoses WHEN NEW.product_id='a-product' BEGIN SELECT RAISE(ABORT, 'fictional failure'); END`); err != nil {
t.Fatal(err)
}
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/shops") {
writeSyncShops(w, "a", "b")
return
}
shop := r.URL.Query().Get("platformShopId")
writeResponse(w, 200, syncTestPage(shop, 1, 1, shop+"-product"), "")
})
result, err := client.SyncProductData(context.Background(), db, "", nil)
if err != nil || result.SucceededShops != 1 || result.FailedShops != 1 || result.ProductCount != 1 || result.StopError != nil {
t.Fatalf("local failure lost later shop: %+v %v", result, err)
}
assertCode(t, result.Failures[0].Error, "LOCAL_SYNC_FAILED")
assertSyncProduct(t, db, true)
if _, found, err := db.GetProduct("b-product"); err != nil || !found {
t.Fatal("later shop not committed")
}
}