package main import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "cmsp/internal/config" "cmsp/internal/erpgo" "cmsp/internal/store" ) func TestAppProductSyncRejectsOverlappingRequests(t *testing.T) { started, release := make(chan struct{}), make(chan struct{}) a := newSyncTestApp(t, func(w http.ResponseWriter, r *http.Request) { close(started) <-release queryResponse(w, 200, appProductPage(1, 1), "") }) completed := make(chan error, 1) go func() { _, err := a.DownloadProductData("demo-shop"); completed <- err }() defer func() { close(release) select { case err := <-completed: if err != nil { t.Errorf("first sync failed: %v", err) } case <-time.After(5 * time.Second): t.Error("first sync did not finish") } }() select { case <-started: case <-time.After(5 * time.Second): t.Fatal("first sync did not start") } _, err := a.DownloadProductData("") if e, ok := err.(*erpgo.Error); !ok || e.Code != "SYNC_IN_PROGRESS" { t.Fatalf("overlapping sync was accepted: %v", err) } assertOldSyncData(t, a) } func newSyncTestApp(t *testing.T, handler http.HandlerFunc) *App { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) a := NewApp() a.ctx = context.Background() a.cfg.ERPGo = config.ERPGoConfig{BaseURL: server.URL, APIKey: "fictional-query-key"} db, err := store.Open(filepath.Join(t.TempDir(), "demo.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) a.db = db if err := db.SyncProducts([]store.Product{{ID: "demo-product", ItemID: "demo-item", PlatformShopID: "demo-shop", ItemName: "old-title", VideoDiagnosis: store.VideoDiagnosisOK}}, map[string][]store.Diagnosis{"demo-product": {{Type: "old-diagnosis"}}}, "old-time"); err != nil { t.Fatal(err) } if err := db.UpdateProductStatus("demo-product", store.VideoFound, store.DownloadDone, store.UploadDone, "old-error"); err != nil { t.Fatal(err) } if err := db.ReplaceShops([]store.Shop{{ID: "old-shop", PlatformShopID: "demo-shop", ShopName: "old-name", Platform: "0"}}, "old-time"); err != nil { t.Fatal(err) } return a } func queryResponse(w http.ResponseWriter, status int, data any, code string) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data, "errorCode": code, "msg": "fictional-query-key", "requestId": "demo-reference"}) } func appProductPage(current, pages int) map[string]any { total := 1 if pages == 2 { total = 201 } return map[string]any{ "source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "platformShopId": "demo-shop", "itemStatus": "NORMAL", "size": 200, "current": current, "pages": pages, "total": total, "records": []any{map[string]any{"id": "demo-product", "itemId": "demo-item", "platformShopId": "demo-shop", "itemStatus": "NORMAL", "itemName": "new-title", "createTime": "2026-09-01 10:00:00", "videoDiagnosis": "missing", "diagnoses": []any{map[string]any{"field": "ALL", "type": "缺少视频", "solution": "上传视频"}}, "uploadStatus": "pending", "downloadStatus": "pending", "videoStatus": "none"}}, } } func assertOldSyncData(t *testing.T, a *App) { t.Helper() p, _, err := a.db.GetProduct("demo-product") d, _ := a.db.ListDiagnoses("demo-product") if err != nil || p.ItemName != "old-title" || p.SyncedAt != "old-time" || p.VideoStatus != store.VideoFound || p.DownloadStatus != store.DownloadDone || p.UploadStatus != store.UploadDone || p.LastError != "old-error" || len(d) != 1 || d[0].Type != "old-diagnosis" { t.Fatal("failed sync modified original data or states") } } func TestAppQueryAndAtomicProductSync(t *testing.T) { for _, failSecondPage := range []bool{false, true} { t.Run(map[bool]string{false: "success", true: "second-page-failure"}[failSecondPage], func(t *testing.T) { a := newSyncTestApp(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.Header.Get("X-API-Key") != "fictional-query-key" || r.URL.Path != "/api/v1/integrations/huohanhan/products" { t.Error("unexpected request or side effect") } if failSecondPage && r.URL.Query().Get("current") == "2" { queryResponse(w, 502, nil, "HHH_UPSTREAM_ERROR") return } pages := 1 if failSecondPage { pages = 2 } queryResponse(w, 200, appProductPage(1, pages), "") }) _, err := a.DownloadProductData("demo-shop") if failSecondPage { if err == nil || !strings.Contains(err.Error(), "HHH_UPSTREAM_ERROR") { t.Fatalf("missing stable error: %v", err) } assertOldSyncData(t, a) } else { p, _, _ := a.db.GetProduct("demo-product") d, _ := a.db.ListDiagnoses("demo-product") if err != nil || p.ItemName != "new-title" || p.VideoDiagnosis != store.VideoDiagnosisMissing || p.CreatedAt != "2026-09-01 10:00:00" || p.VideoStatus != store.VideoFound || p.DownloadStatus != store.DownloadDone || p.UploadStatus != store.UploadDone || p.LastError != "old-error" || len(d) != 1 || d[0].Type != "缺少视频" { t.Fatalf("sync lost local states or mapping: %v", err) } } if strings.Contains(a.log.Text(), "fictional-query-key") { t.Fatal("query credential exposed in logs") } }) } } func TestAppDiagnosisWriteFailureRollsBackSync(t *testing.T) { a := newSyncTestApp(t, func(w http.ResponseWriter, _ *http.Request) { queryResponse(w, 200, appProductPage(1, 1), "") }) if _, err := a.db.DB().Exec(`CREATE TRIGGER fail_sync BEFORE INSERT ON product_diagnoses BEGIN SELECT RAISE(ABORT,'fictional write failure'); END`); err != nil { t.Fatal(err) } _, err := a.DownloadProductData("demo-shop") if err == nil || !strings.Contains(err.Error(), "LOCAL_SYNC_FAILED") { t.Fatalf("missing write error: %v", err) } assertOldSyncData(t, a) } func TestAppShopsFailurePreservesCacheAndSuccessRefreshes(t *testing.T) { for _, fail := range []bool{false, true} { a := newSyncTestApp(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" || r.URL.Path != "/api/v1/integrations/huohanhan/shops" { t.Error("unexpected shop query") } if fail { queryResponse(w, 401, nil, "API_KEY_INVALID") return } queryResponse(w, 200, map[string]any{"source": "huohanhan", "fetchedAt": "2026-09-28T10:00:00+08:00", "items": []any{map[string]any{"id": "demo-internal-shop", "platformShopId": "demo-shop", "shopName": "new-name", "platform": "0"}}}, "") }) _, err := a.RefreshShops() shops, _ := a.GetCachedShops() if len(shops) != 1 { t.Fatal("cache disappeared") } if fail && (err == nil || shops[0].ShopName != "old-name" || shops[0].UpdatedAt != "old-time") { t.Fatal("failed shop query changed cache") } if !fail && (err != nil || shops[0].ShopName != "new-name") { t.Fatal("shops not saved") } if strings.Contains(a.log.Text(), "fictional-query-key") { t.Fatal("credential in log") } } } func TestAppMissingQuerySettingsRetainsDataAndUploadSettings(t *testing.T) { a := newSyncTestApp(t, func(w http.ResponseWriter, _ *http.Request) { t.Error("unconfigured query made a request") }) a.cfg.ERPGo = config.ERPGoConfig{} a.cfg.Huohanhan.Account = "fictional-upload-account" a.cfg.Huohanhan.Password = "fictional-upload-password" _, err := a.DownloadProductData("demo-shop") if err == nil || !strings.Contains(err.Error(), "ERPGo_NOT_CONFIGURED") { t.Fatal("missing configuration not reported") } assertOldSyncData(t, a) a.cfgPath = filepath.Join(t.TempDir(), "config.yaml") if err := a.SaveConfig(a.cfg); err != nil { t.Fatal(err) } loaded, err := config.Load(a.cfgPath) if err != nil || loaded.Huohanhan.Password != "fictional-upload-password" { t.Fatal("old upload settings changed") } if strings.Contains(a.log.Text(), "fictional-upload-password") { t.Fatal("save exposed upload password") } }