288 lines
10 KiB
Go
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")
|
|
}
|
|
}
|