Files
cmsp/app_upload_test.go
T

390 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"cmsp/internal/downloader"
"cmsp/internal/erpgo"
"cmsp/internal/store"
)
func TestUploadBatchRespectsConfiguredConcurrency(t *testing.T) {
for _, limit := range []int{1, 3} {
t.Run(fmt.Sprintf("concurrency-%d", limit), func(t *testing.T) {
var active, maximum, puts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 7 {
t.Errorf("unexpected path: %s", r.URL.Path)
return
}
itemID := parts[5]
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/video/operation"):
fmt.Fprintf(w, `{"code":200,"data":{"operationId":"op-1","shopeeId":%q,"status":"succeeded","videoUrl":"https://example.invalid/video.mp4"}}`, itemID)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/video"):
fmt.Fprintf(w, `{"code":200,"data":{"shopeeId":%q,"source":"huohanhan","fetchedAt":"2026-09-30T00:00:00Z","video":[]}}`, itemID)
case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/video"):
puts.Add(1)
current := active.Add(1)
for old := maximum.Load(); current > old; old = maximum.Load() {
if maximum.CompareAndSwap(old, current) {
break
}
}
time.Sleep(60 * time.Millisecond)
active.Add(-1)
w.WriteHeader(http.StatusAccepted)
fmt.Fprintf(w, `{"code":202,"data":{"operationId":"op-1","shopeeId":%q,"status":"submitted"}}`, itemID)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key"
a.cfg.Upload.Concurrency = limit
a.cfg.Download.VideoDir = t.TempDir()
a.probe = func(context.Context, string) (downloader.ProbeResult, error) {
return downloader.ProbeResult{Duration: 20, FormatName: "mp4", Width: 640, Height: 480}, nil
}
var ids []string
for i := 0; i < 5; i++ {
itemID := fmt.Sprintf("%d", 100+i)
internalID := fmt.Sprintf("internal-%d", i)
ids = append(ids, internalID)
if err := db.UpsertProducts([]store.Product{{ID: internalID, ItemID: itemID, UploadStatus: store.UploadPending}}, "2026-09-30 00:00:00"); err != nil {
t.Fatal(err)
}
dir := filepath.Join(a.cfg.Download.VideoDir, itemID)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("fictional-mp4"), 0600); err != nil {
t.Fatal(err)
}
}
if err := a.UploadVideos(ids); err != nil {
t.Fatal(err)
}
if puts.Load() != 5 || maximum.Load() != int32(limit) {
t.Fatalf("PUT 数量=%d,最大并发=%d,期望并发=%d", puts.Load(), maximum.Load(), limit)
}
for _, id := range ids {
product, _, err := db.GetProduct(id)
if err != nil || product.UploadStatus != store.UploadDone {
t.Fatalf("商品 %s 状态=%s,err=%v", id, product.UploadStatus, err)
}
}
})
}
}
func TestVideoUploadUsesERPGoAndPersistsOutcome(t *testing.T) {
for _, status := range []string{"succeeded", "unknown"} {
t.Run(status, func(t *testing.T) {
putCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Header.Get("X-API-Key") != "fictional-key" {
t.Error("missing API key")
}
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/shopee/products/123/video":
fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-09-30T00:00:00Z","video":[]}}`)
case r.Method == "PUT" && r.URL.Path == "/api/v1/shopee/products/123/video":
putCount++
if r.Header.Get("Idempotency-Key") == "" {
t.Error("missing idempotency key")
}
w.WriteHeader(202)
fmt.Fprint(w, `{"code":202,"data":{"operationId":"op-1","shopeeId":"123","status":"submitted"}}`)
case r.Method == "GET" && r.URL.Path == "/api/v1/shopee/products/123/video/operation":
fmt.Fprintf(w, `{"code":200,"data":{"operationId":"op-1","shopeeId":"123","status":%q,"videoUrl":"https://example.invalid/video.mp4"}}`, status)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123", UploadStatus: store.UploadPending}}, "2026-09-30 00:00:00"); err != nil {
t.Fatal(err)
}
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key"
a.cfg.Download.VideoDir = t.TempDir()
a.probe = func(context.Context, string) (downloader.ProbeResult, error) {
return downloader.ProbeResult{Duration: 20, FormatName: "mp4", Width: 640, Height: 480}, nil
}
dir := filepath.Join(a.cfg.Download.VideoDir, "123")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("fictional-mp4"), 0600); err != nil {
t.Fatal(err)
}
client, err := erpgo.NewClient(a.cfg.ERPGo, nil)
if err != nil {
t.Fatal(err)
}
a.uploadOneVideo(context.Background(), client, "internal-1", 1)
p, _, err := db.GetProduct("internal-1")
if err != nil {
t.Fatal(err)
}
want := store.UploadUnconfirmed
if status == "succeeded" {
want = store.UploadDone
}
if p.UploadStatus != want || putCount != 1 {
t.Fatalf("status=%s put=%d", p.UploadStatus, putCount)
}
if status == "succeeded" {
videos, err := db.ListVideos(p.ID)
if err != nil || len(videos) != 1 || videos[0].Status != store.VideoStatusUploaded {
t.Fatalf("videos=%+v err=%v", videos, err)
}
} else {
a.uploadOneVideo(context.Background(), client, "internal-1", 1)
if putCount != 1 {
t.Fatalf("unknown outcome repeated PUT: %d", putCount)
}
}
})
}
}
func TestUploadPreviewFailureIsLoggedWithoutKey(t *testing.T) {
const key = "fictional-secret-key"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"code":401,"errorCode":"API_KEY_INVALID","requestId":"fictional-request"}`)
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123"}}, "2026-09-30 00:00:00"); err != nil {
t.Fatal(err)
}
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, key
a.cfg.Download.VideoDir = t.TempDir()
a.probe = func(context.Context, string) (downloader.ProbeResult, error) {
return downloader.ProbeResult{Duration: 20, FormatName: "mp4", Width: 640, Height: 480}, nil
}
dir := filepath.Join(a.cfg.Download.VideoDir, "123")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("fictional-mp4"), 0600); err != nil {
t.Fatal(err)
}
if _, err := a.GetUploadPreview([]string{"internal-1"}); err == nil {
t.Fatal("expected preview failure")
}
entries := a.GetLogs()
if len(entries) != 1 || !strings.Contains(entries[0].Message, "API_KEY_INVALID") || strings.Contains(entries[0].Message, key) {
t.Fatalf("wrong log: %+v", entries)
}
}
func TestUploadStartFailureIsLogged(t *testing.T) {
a := NewApp()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
a.db = db
if err := a.UploadVideos([]string{"internal-1"}); err == nil {
t.Fatal("expected unconfigured erpgo")
}
entries := a.GetLogs()
if len(entries) != 1 || !strings.Contains(entries[0].Message, "ERPGo_NOT_CONFIGURED") {
t.Fatalf("wrong log: %+v", entries)
}
}
func TestUnknownCurrentVideoDoesNotSubmitUpload(t *testing.T) {
putCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
putCount++
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
fmt.Fprint(w, `{"code":502,"errorCode":"HHH_UPSTREAM_ERROR","stage":"check","submissionState":"not_submitted","requestId":"fictional-request"}`)
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123", UploadStatus: store.UploadPending}}, "2026-09-30 00:00:00"); err != nil {
t.Fatal(err)
}
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key"
a.cfg.Download.VideoDir = t.TempDir()
a.probe = func(context.Context, string) (downloader.ProbeResult, error) {
return downloader.ProbeResult{Duration: 20, FormatName: "mp4", Width: 640, Height: 480}, nil
}
dir := filepath.Join(a.cfg.Download.VideoDir, "123")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
videoPath := filepath.Join(dir, "video.mp4")
if err := os.WriteFile(videoPath, []byte("fictional-mp4"), 0600); err != nil {
t.Fatal(err)
}
if _, err := a.GetUploadPreview([]string{"internal-1"}); err == nil {
t.Fatal("unknown state must block preview")
}
p, _, err := db.GetProduct("internal-1")
if err != nil || p.UploadStatus != store.UploadPending || putCount != 0 {
t.Fatalf("preview changed state: %+v %v put=%d", p, err, putCount)
}
if _, found, err := db.GetUploadOperation(p.ID); err != nil || found {
t.Fatalf("preview created operation: %v %v", found, err)
}
client, err := erpgo.NewClient(a.cfg.ERPGo, nil)
if err != nil {
t.Fatal(err)
}
a.uploadOneVideo(context.Background(), client, p.ID, 2)
p, _, err = db.GetProduct(p.ID)
if err != nil || p.UploadStatus != store.UploadUnconfirmed || !strings.Contains(p.LastError, "fictional-request") || putCount != 0 {
t.Fatalf("unknown state was not preserved: %+v %v put=%d", p, err, putCount)
}
if _, err := os.Stat(videoPath); err != nil {
t.Fatalf("local video removed: %v", err)
}
if _, found, err := db.GetUploadOperation(p.ID); err != nil || found {
t.Fatalf("unknown state created operation: %v %v", found, err)
}
}
func TestProcessingVideoDoesNotCreateUploadOperation(t *testing.T) {
for _, tc := range []struct {
name, tempURL, uploadID string
}{
{"tempVideoUrl", "https://example.invalid/processing.mp4", ""},
{"videoUploadIdStr", "", "fictional-processing-id"},
} {
t.Run(tc.name, func(t *testing.T) {
putCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
putCount++
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-10-05T00:00:00Z","video":[],"tempVideoUrl":%q,"videoUploadIdStr":%q}}`, tc.tempURL, tc.uploadID)
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123", UploadStatus: store.UploadPending}}, "2026-10-05 00:00:00"); err != nil {
t.Fatal(err)
}
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key"
a.cfg.Download.VideoDir = t.TempDir()
client, err := erpgo.NewClient(a.cfg.ERPGo, nil)
if err != nil {
t.Fatal(err)
}
if _, err := a.GetUploadPreview([]string{"internal-1"}); err == nil || !strings.Contains(err.Error(), "VIDEO_ALREADY_PROCESSING") {
t.Fatalf("preview should block processing video: %v", err)
}
a.uploadOneVideo(context.Background(), client, "internal-1", 1)
p, _, err := db.GetProduct("internal-1")
if err != nil || p.UploadStatus != store.UploadUnconfirmed || p.LastError != "VIDEO_ALREADY_PROCESSING" || putCount != 0 {
t.Fatalf("unexpected upload state: %+v err=%v put=%d", p, err, putCount)
}
if _, found, err := db.GetUploadOperation("internal-1"); err != nil || found {
t.Fatalf("processing video created operation: found=%v err=%v", found, err)
}
})
}
}
func TestUploadPreviewResumesExistingOperationWithoutNewPut(t *testing.T) {
currentReads, operationReads, putCount := 0, 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/video/operation"):
operationReads++
if r.URL.Query().Get("idempotencyKey") != "fictional-operation-key" {
t.Error("operation key changed")
}
fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","status":"unknown"}}`)
case r.Method == http.MethodGet:
currentReads++
fmt.Fprint(w, `{"code":200,"data":{"shopeeId":"123","source":"huohanhan","fetchedAt":"2026-10-05T00:00:00Z","video":[{}]}}`)
case r.Method == http.MethodPut:
putCount++
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.UpsertProducts([]store.Product{{ID: "internal-1", ItemID: "123", UploadStatus: store.UploadPending}}, "2026-10-05 00:00:00"); err != nil {
t.Fatal(err)
}
if err := db.BeginUploadOperation(store.UploadOperation{ProductID: "internal-1", ShopeeID: "123", IdempotencyKey: "fictional-operation-key", Status: "created", UpdatedAt: "2026-10-05T00:00:00Z"}); err != nil {
t.Fatal(err)
}
a := NewApp()
a.db = db
a.cfg.ERPGo.BaseURL, a.cfg.ERPGo.APIKey = server.URL, "fictional-key"
preview, err := a.GetUploadPreview([]string{"internal-1"})
if err != nil || !preview.ResumeOperation || preview.HasExistingVideo || currentReads != 0 {
t.Fatalf("wrong resume preview: %+v err=%v currentReads=%d", preview, err, currentReads)
}
if err := a.UploadVideos([]string{"internal-1"}); err != nil {
t.Fatal(err)
}
p, _, err := db.GetProduct("internal-1")
if err != nil || p.UploadStatus != store.UploadUnconfirmed || p.LastError != "VIDEO_RESULT_UNKNOWN" || operationReads != 1 || currentReads != 0 || putCount != 0 {
t.Fatalf("unexpected resume result: %+v err=%v operationReads=%d currentReads=%d put=%d", p, err, operationReads, currentReads, putCount)
}
}