|
|
|
@@ -0,0 +1,324 @@
|
|
|
|
|
package task
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"reflect"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"go-admin/app/goauto/models"
|
|
|
|
|
"go-admin/app/goauto/shopeeproduct"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// This fixture leaves the hook uncalled so tests can change the submitted
|
|
|
|
|
// status, missing fields and association independently of the assertions.
|
|
|
|
|
func seedPartialImageSearch(t *testing.T, db *gorm.DB, deviceID *uint64) (models.CollectionTask, models.ShopeeProduct) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
record := createTask(t, db, deviceID)
|
|
|
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "active").Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
shopee := models.ShopeeProduct{
|
|
|
|
|
ShopeeItemID: "test-item", Title: "test", Currency: "TWD",
|
|
|
|
|
SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"M","source":"import"}]}]`,
|
|
|
|
|
}
|
|
|
|
|
if err := db.Create(&shopee).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
syb := models.SYBProduct{
|
|
|
|
|
OrderCode: "TEST-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID,
|
|
|
|
|
ShopeeProductID: &shopee.ID, TargetColor: "黑色", TargetSize: "M",
|
|
|
|
|
Quantity: 1, UnitPriceCent: 1000, ParseStatus: "success", RawJSON: "{}",
|
|
|
|
|
}
|
|
|
|
|
if err := db.Create(&syb).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
snapshot := ImageSearchSnapshot{
|
|
|
|
|
ShopeeProductID: shopee.ID, RepresentativeSYBProductID: syb.ID, SYBProductIDs: []uint64{syb.ID},
|
|
|
|
|
ReferenceCurrency: "TWD", ReferencePriceCent: 1000, MaxPriceRatio: 3, PriceGuardSkipped: true,
|
|
|
|
|
ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"},
|
|
|
|
|
}
|
|
|
|
|
raw, err := json.Marshal(snapshot)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.Model(&record).Updates(map[string]any{"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": string(raw)}).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return record, shopee
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAutoLinkImageSearchPartialMissingWhitelist(t *testing.T) {
|
|
|
|
|
cases := []struct {
|
|
|
|
|
name, status, missing string
|
|
|
|
|
nilMissing, want bool
|
|
|
|
|
}{
|
|
|
|
|
{name: "completed_empty", status: models.TaskStatusCompleted, missing: "[]", want: true},
|
|
|
|
|
{name: "completed_nil", status: models.TaskStatusCompleted, nilMissing: true, want: true},
|
|
|
|
|
{name: "nil", nilMissing: true},
|
|
|
|
|
{name: "blank"},
|
|
|
|
|
{name: "null", missing: "null"},
|
|
|
|
|
{name: "empty", missing: "[]"},
|
|
|
|
|
{name: "malformed", missing: `["reviewCount"`},
|
|
|
|
|
{name: "object", missing: `{ "reviewCount": true }`},
|
|
|
|
|
{name: "scalar", missing: `"reviewCount"`},
|
|
|
|
|
{name: "number_entry", missing: `["reviewCount",1]`},
|
|
|
|
|
{name: "null_entry", missing: `["reviewCount",null]`},
|
|
|
|
|
{name: "blank_entry", missing: `[""]`},
|
|
|
|
|
{name: "unknown", missing: `["reviewCount","futureField"]`},
|
|
|
|
|
{name: "case_mismatch", missing: `["ReviewCount"]`},
|
|
|
|
|
{name: "whitespace", missing: `[" reviewCount "]`},
|
|
|
|
|
{name: "color", missing: `["reviewCount","color"]`},
|
|
|
|
|
{name: "size", missing: `["size"]`},
|
|
|
|
|
{name: "price", missing: `["price"]`},
|
|
|
|
|
{name: "color_price", missing: `["reviewCount","price:黑色"]`},
|
|
|
|
|
{name: "unsupported_dimension", missing: `["unsupportedDimension:款式"]`},
|
|
|
|
|
{name: "invalid_dimension", missing: `["spec_dimension_invalid:size"]`},
|
|
|
|
|
{name: "pending", status: models.TaskStatusPending, missing: `["reviewCount"]`},
|
|
|
|
|
{name: "failed", status: models.TaskStatusFailed, missing: `["reviewCount"]`},
|
|
|
|
|
{name: "duplicate_optional", missing: `["reviewCount","reviewCount"]`, want: true},
|
|
|
|
|
}
|
|
|
|
|
fields := []string{"reviewCount", "salesText", "shopName"}
|
|
|
|
|
for mask := 1; mask < 8; mask++ {
|
|
|
|
|
var missing []string
|
|
|
|
|
for i, field := range fields {
|
|
|
|
|
if mask&(1<<i) != 0 {
|
|
|
|
|
missing = append(missing, field)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
raw, _ := json.Marshal(missing)
|
|
|
|
|
cases = append(cases, struct {
|
|
|
|
|
name, status, missing string
|
|
|
|
|
nilMissing, want bool
|
|
|
|
|
}{name: fmt.Sprintf("allowed_%d", mask), missing: string(raw), want: true})
|
|
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
db := openTaskDatabase(t)
|
|
|
|
|
record, shopee := seedPartialImageSearch(t, db, nil)
|
|
|
|
|
status := tc.status
|
|
|
|
|
if status == "" {
|
|
|
|
|
status = models.TaskStatusCompletedPartial
|
|
|
|
|
}
|
|
|
|
|
var missing *string
|
|
|
|
|
if !tc.nilMissing {
|
|
|
|
|
missing = &tc.missing
|
|
|
|
|
}
|
|
|
|
|
if err := record.SetStatus(status); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.Model(&record).Updates(map[string]any{"status": status, "missing_json": missing, "active_slot": record.ActiveSlot, "device_run_slot": record.DeviceRunSlot}).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
linked, err := NewService(db).autoLinkImageSearch(context.Background(), record.ID)
|
|
|
|
|
if err != nil || linked.Linked != tc.want {
|
|
|
|
|
t.Fatalf("Linked=%v want=%v error=%v", linked.Linked, tc.want, err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if shopee.ImageSearchLinked != tc.want || (shopee.PDDProductID != nil) != tc.want {
|
|
|
|
|
t.Fatalf("association/marker mismatch: %+v", shopee)
|
|
|
|
|
}
|
|
|
|
|
if tc.want && (*shopee.PDDProductID != *record.PDDProductID || len(linked.SYBProductIDs) != 1) {
|
|
|
|
|
t.Fatal("linked target or downstream SYB identifiers lost")
|
|
|
|
|
}
|
|
|
|
|
if err := db.First(&record, record.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if record.Status != status || !reflect.DeepEqual(record.MissingJSON, missing) {
|
|
|
|
|
t.Fatal("eligibility must not rewrite collection status or missing fields")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmitImageSearchPartialLinkAndMatch(t *testing.T) {
|
|
|
|
|
for _, scenario := range []string{"optional_partial", "completed", "critical_missing", "admin_source", "current_page_source", "disabled_pdd", "association_changed", "snapshot_missing", "ai_unavailable", "request_cancelled_during_match"} {
|
|
|
|
|
t.Run(scenario, func(t *testing.T) {
|
|
|
|
|
requestContext, cancelRequest := context.WithCancel(context.Background())
|
|
|
|
|
defer cancelRequest()
|
|
|
|
|
var syncCalls atomic.Int32
|
|
|
|
|
erp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
syncCalls.Add(1)
|
|
|
|
|
if scenario == "request_cancelled_during_match" {
|
|
|
|
|
// Cancel only after the real post-result matching hook has
|
|
|
|
|
// started. Its detached context must still persist mappings.
|
|
|
|
|
cancelRequest()
|
|
|
|
|
}
|
|
|
|
|
if scenario == "ai_unavailable" {
|
|
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
fmt.Fprint(w, `{"data":{"color":["黑色"],"size":["M"]}}`)
|
|
|
|
|
}))
|
|
|
|
|
defer erp.Close()
|
|
|
|
|
t.Setenv("GOAUTO_ERPGO_BASE_URL", erp.URL)
|
|
|
|
|
t.Setenv("GOAUTO_ERPGO_APIKEY", "synthetic-test-key")
|
|
|
|
|
db := openTaskDatabase(t)
|
|
|
|
|
device, token := registerTaskDevice(t, db, "partial-image-search")
|
|
|
|
|
record, shopee := seedPartialImageSearch(t, db, &device.ID)
|
|
|
|
|
service := newTaskService(db)
|
|
|
|
|
// Execution is outside this test's scope; submit through the real
|
|
|
|
|
// authenticated running-owner path with a persisted image-search snapshot.
|
|
|
|
|
if err := db.Model(&record).Update("status", models.TaskStatusRunning).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
title, shop, sales, reviews := "商品", "店铺", "已拼100件", int64(12)
|
|
|
|
|
request := ResultRequest{
|
|
|
|
|
RequestID: uuid.NewString(), Status: models.TaskStatusCompletedPartial,
|
|
|
|
|
Product: ResultProduct{PDDGoodsID: record.GoodsIDSnapshot, Title: &title, ShopName: &shop, SalesText: &sales},
|
|
|
|
|
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}, {Key: "size", Name: "尺码", Values: []string{"M"}}},
|
|
|
|
|
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1000}},
|
|
|
|
|
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色", "size": "M"}, PriceCent: 1000, Available: true}},
|
|
|
|
|
Missing: []string{"reviewCount"},
|
|
|
|
|
}
|
|
|
|
|
wantLinked, wantMapped := true, true
|
|
|
|
|
var unchangedPDD *uint64
|
|
|
|
|
switch scenario {
|
|
|
|
|
case "completed":
|
|
|
|
|
request.Status, request.Missing, request.Product.ReviewCount = models.TaskStatusCompleted, []string{}, &reviews
|
|
|
|
|
case "critical_missing":
|
|
|
|
|
request.Missing = append(request.Missing, "size")
|
|
|
|
|
wantLinked, wantMapped = false, false
|
|
|
|
|
case "admin_source", "current_page_source":
|
|
|
|
|
source := models.CollectionTaskSourceAdmin
|
|
|
|
|
if scenario == "current_page_source" {
|
|
|
|
|
source = models.CollectionTaskSourceAgentCurrentPage
|
|
|
|
|
}
|
|
|
|
|
if err := db.Model(&record).Update("source", source).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
wantLinked, wantMapped = false, false
|
|
|
|
|
case "disabled_pdd":
|
|
|
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *record.PDDProductID).Update("status", "disabled").Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
wantLinked, wantMapped = false, false
|
|
|
|
|
case "association_changed":
|
|
|
|
|
manual := seedAutoLinkPDD(t, db, "manual-pdd")
|
|
|
|
|
unchangedPDD = &manual.ID
|
|
|
|
|
if err := db.Model(&shopee).Update("pdd_product_id", manual.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
wantLinked, wantMapped = false, false
|
|
|
|
|
case "snapshot_missing":
|
|
|
|
|
if err := db.Model(&record).Update("image_search_snapshot", nil).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
wantLinked, wantMapped = false, false
|
|
|
|
|
case "ai_unavailable":
|
|
|
|
|
// No exact match and no configured AI provider: the existing
|
|
|
|
|
// matching path must not invent mappings or undo successful collection.
|
|
|
|
|
request.Dimensions[0].Values = []string{"白色"}
|
|
|
|
|
request.Dimensions[1].Values = []string{"L"}
|
|
|
|
|
request.ColorPrices[0].Color = "白色"
|
|
|
|
|
request.SKUs[0].Specs = map[string]string{"color": "白色", "size": "L"}
|
|
|
|
|
wantMapped = false
|
|
|
|
|
}
|
|
|
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
if attempt == 0 {
|
|
|
|
|
ctx = requestContext
|
|
|
|
|
}
|
|
|
|
|
detail, err := service.SubmitResult(ctx, record.ID, request, token)
|
|
|
|
|
if scenario == "request_cancelled_during_match" && attempt == 0 {
|
|
|
|
|
// Detail still uses the original request context. Preserve that
|
|
|
|
|
// existing return behavior, then inspect the committed state
|
|
|
|
|
// independently; do not pretend the disconnected client got OK.
|
|
|
|
|
if requestContext.Err() != context.Canceled || err == nil {
|
|
|
|
|
t.Fatalf("expected cancelled response read, got %v", err)
|
|
|
|
|
}
|
|
|
|
|
detail, err = service.Detail(context.Background(), record.ID)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("submit attempt %d: %v", attempt, err)
|
|
|
|
|
}
|
|
|
|
|
if detail.Task.Status != request.Status || !reflect.DeepEqual(detail.Missing, request.Missing) || detail.Replayed != (attempt == 1) {
|
|
|
|
|
t.Fatalf("submission/replay changed result: status=%s missing=%v replayed=%v", detail.Task.Status, detail.Missing, detail.Replayed)
|
|
|
|
|
}
|
|
|
|
|
if len(detail.SKUs) != 1 || len(detail.Dimensions) != 2 {
|
|
|
|
|
t.Fatal("result children missing or duplicated")
|
|
|
|
|
}
|
|
|
|
|
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if shopee.ImageSearchLinked != wantLinked {
|
|
|
|
|
t.Fatalf("image search marker=%v", shopee.ImageSearchLinked)
|
|
|
|
|
}
|
|
|
|
|
wantPDD := unchangedPDD
|
|
|
|
|
if wantLinked {
|
|
|
|
|
wantPDD = record.PDDProductID
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(shopee.PDDProductID, wantPDD) {
|
|
|
|
|
t.Fatal("wrong association or concurrent change overwritten")
|
|
|
|
|
}
|
|
|
|
|
wantCalls := int32(0)
|
|
|
|
|
if wantLinked {
|
|
|
|
|
wantCalls = 1
|
|
|
|
|
}
|
|
|
|
|
if syncCalls.Load() != wantCalls {
|
|
|
|
|
t.Fatalf("matching hook calls=%d want=%d", syncCalls.Load(), wantCalls)
|
|
|
|
|
}
|
|
|
|
|
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(specs) != 2 {
|
|
|
|
|
t.Fatalf("unexpected archive dimensions: %d", len(specs))
|
|
|
|
|
}
|
|
|
|
|
for _, dimension := range specs {
|
|
|
|
|
for _, value := range dimension.Values {
|
|
|
|
|
if wantMapped {
|
|
|
|
|
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed || value.Mapping.PDDValue != value.Name {
|
|
|
|
|
t.Fatalf("matching not persisted: %+v", value)
|
|
|
|
|
}
|
|
|
|
|
} else if value.Mapping != nil {
|
|
|
|
|
t.Fatalf("unexpected mapping: %+v", value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if scenario == "optional_partial" {
|
|
|
|
|
// A second result for the same image carries the same original
|
|
|
|
|
// association snapshot. The first committed link wins; no fallback
|
|
|
|
|
// to another result or a second matching pass is introduced.
|
|
|
|
|
if err := db.First(&record, record.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
second := createTask(t, db, &device.ID)
|
|
|
|
|
if err := db.Model(&second).Updates(map[string]any{
|
|
|
|
|
"source": models.CollectionTaskSourceImageSearch, "image_search_snapshot": record.ImageSearchSnapshot,
|
|
|
|
|
"status": models.TaskStatusRunning,
|
|
|
|
|
}).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
request.RequestID, request.Product.PDDGoodsID = uuid.NewString(), second.GoodsIDSnapshot
|
|
|
|
|
if _, err := service.SubmitResult(context.Background(), second.ID, request, token); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.First(&shopee, shopee.ID).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(shopee.PDDProductID, record.PDDProductID) || syncCalls.Load() != 1 {
|
|
|
|
|
t.Fatal("competing image-search result replaced association or retriggered matching")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var purchases int64
|
|
|
|
|
if err := db.Model(&models.PurchaseTask{}).Count(&purchases).Error; err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if purchases != 0 {
|
|
|
|
|
t.Fatal("matching must not create a purchase task")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|