feat(t240): cache freight item images
This commit is contained in:
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 15 {
|
||||
t.Fatalf("initial Up() applied = %d, want 15", applied)
|
||||
} else if applied != 16 {
|
||||
t.Fatalf("initial Up() applied = %d, want 16", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v16) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v15) error = %v", err)
|
||||
@@ -74,9 +77,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v15) over historical data error = %v", err)
|
||||
} else if applied != 11 {
|
||||
t.Fatalf("Up(v5-v15) applied = %d, want 11", applied)
|
||||
t.Fatalf("Up(v5-v16) over historical data error = %v", err)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("Up(v5-v16) applied = %d, want 12", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v16) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -141,9 +149,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v15) error = %v", err)
|
||||
} else if applied != 12 {
|
||||
t.Fatalf("final Up(v4-v15) applied = %d, want 12", applied)
|
||||
t.Fatalf("final Up(v4-v16) error = %v", err)
|
||||
} else if applied != 13 {
|
||||
t.Fatalf("final Up(v4-v16) applied = %d, want 13", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -379,6 +387,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v16) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v15) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 15 {
|
||||
t.Fatalf("Up() applied = %d, want 15", applied)
|
||||
if applied != 16 {
|
||||
t.Fatalf("Up() applied = %d, want 16", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -46,6 +46,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
16: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -74,7 +75,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
12: true,
|
||||
13: true,
|
||||
14: true,
|
||||
15: false,
|
||||
15: true,
|
||||
16: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -100,6 +102,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
13: true,
|
||||
14: true,
|
||||
15: true,
|
||||
16: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package shunyunbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
const (
|
||||
ProductImagePath = "/api/p/file"
|
||||
maxProductImageBytes = 20 << 20
|
||||
)
|
||||
|
||||
func (manager *SessionManager) FetchProductImage(
|
||||
ctx context.Context,
|
||||
productThumbRef string,
|
||||
) (usecase.FreightSourceImage, error) {
|
||||
productThumbRef = strings.TrimSpace(productThumbRef)
|
||||
value, err := strconv.ParseUint(productThumbRef, 10, 64)
|
||||
if err != nil || value == 0 ||
|
||||
strconv.FormatUint(value, 10) != productThumbRef {
|
||||
return usecase.FreightSourceImage{}, domain.ErrFreightImageInvalid
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
if !manager.configuredLocked() {
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceNotConfigured
|
||||
}
|
||||
if !manager.authenticated {
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceSessionNeeded
|
||||
}
|
||||
baseURL := manager.baseURL
|
||||
headers := manager.headers.Clone()
|
||||
client := manager.http
|
||||
manager.mu.Unlock()
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("id", productThumbRef)
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
baseURL+ProductImagePath+"?"+query.Encode(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
for name, values := range headers {
|
||||
request.Header[name] = append([]string(nil), values...)
|
||||
}
|
||||
request.Header.Set("Accept", "image/jpeg, image/png, image/webp")
|
||||
manager.logERPRequest(request)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
manager.logERPTransportFailure(request)
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
if response.StatusCode < http.StatusOK ||
|
||||
response.StatusCode >= http.StatusMultipleChoices {
|
||||
defer response.Body.Close()
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
manager.mu.Lock()
|
||||
manager.clearAuthenticatedLocked()
|
||||
manager.mu.Unlock()
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceSessionNeeded
|
||||
case http.StatusNotFound:
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightImageNotFound
|
||||
default:
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightSourceUnavailable
|
||||
}
|
||||
}
|
||||
mediaType, _, err := mime.ParseMediaType(
|
||||
response.Header.Get("Content-Type"),
|
||||
)
|
||||
if err != nil || !supportedProductImageType(mediaType) ||
|
||||
response.ContentLength > maxProductImageBytes {
|
||||
response.Body.Close()
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
return usecase.FreightSourceImage{},
|
||||
domain.ErrFreightImageInvalid
|
||||
}
|
||||
manager.logERPBinaryResponse(request, response)
|
||||
return usecase.FreightSourceImage{
|
||||
Content: response.Body,
|
||||
MediaType: mediaType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func supportedProductImageType(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "image/jpeg", "image/png", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *SessionManager) logERPBinaryResponse(
|
||||
request *http.Request,
|
||||
response *http.Response,
|
||||
) {
|
||||
if !manager.diagnosticsOn {
|
||||
return
|
||||
}
|
||||
byteCount := "unknown"
|
||||
if response.ContentLength >= 0 {
|
||||
byteCount = strconv.FormatInt(response.ContentLength, 10)
|
||||
}
|
||||
manager.diagnosticLog(
|
||||
"erp_response method=" + request.Method +
|
||||
" path=" + request.URL.EscapedPath() +
|
||||
" status=" + strconv.Itoa(response.StatusCode) +
|
||||
" content_type=" +
|
||||
diagnosticContentType(response.Header.Get("Content-Type")) +
|
||||
" bytes=" + byteCount + " body=omitted_binary",
|
||||
)
|
||||
}
|
||||
|
||||
var _ usecase.FreightImageSource = (*SessionManager)(nil)
|
||||
@@ -0,0 +1,190 @@
|
||||
package shunyunbao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestSessionManagerFetchProductImageUsesFixedAuthenticatedEndpoint(
|
||||
t *testing.T,
|
||||
) {
|
||||
var requestPath, rawQuery, accept string
|
||||
manager, closeServer := imageSessionManager(
|
||||
t,
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestPath = request.URL.Path
|
||||
rawQuery = request.URL.RawQuery
|
||||
accept = request.Header.Get("Accept")
|
||||
if cookie, err := request.Cookie("authenticated"); err != nil ||
|
||||
cookie.Value != "yes" {
|
||||
t.Fatalf("image cookie = %v, %v", cookie, err)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "image/png")
|
||||
writer.Header().Set("Content-Length", "4")
|
||||
_, _ = writer.Write([]byte("png!"))
|
||||
},
|
||||
)
|
||||
defer closeServer()
|
||||
|
||||
image, err := manager.FetchProductImage(context.Background(), "190")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchProductImage() error = %v", err)
|
||||
}
|
||||
defer image.Content.Close()
|
||||
content, err := io.ReadAll(image.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("read image: %v", err)
|
||||
}
|
||||
if requestPath != ProductImagePath || rawQuery != "id=190" ||
|
||||
image.MediaType != "image/png" || string(content) != "png!" ||
|
||||
!strings.Contains(accept, "image/jpeg") {
|
||||
t.Fatalf(
|
||||
"image request/result = %q / %q / %q / %q / %q",
|
||||
requestPath,
|
||||
rawQuery,
|
||||
accept,
|
||||
image.MediaType,
|
||||
content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerFetchProductImageClassifiesFailures(t *testing.T) {
|
||||
manager, closeServer := imageSessionManager(
|
||||
t,
|
||||
func(writer http.ResponseWriter, request *http.Request) {
|
||||
switch request.URL.Query().Get("id") {
|
||||
case "190":
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
case "191":
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"status":false}`))
|
||||
case "192":
|
||||
writer.Header().Set("Location", ProductImagePath+"?id=190")
|
||||
writer.WriteHeader(http.StatusFound)
|
||||
case "193":
|
||||
writer.Header().Set(
|
||||
"Content-Type",
|
||||
"image/jpeg",
|
||||
)
|
||||
writer.Header().Set(
|
||||
"Content-Length",
|
||||
"20971521",
|
||||
)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
},
|
||||
)
|
||||
defer closeServer()
|
||||
|
||||
tests := []struct {
|
||||
ref string
|
||||
want error
|
||||
}{
|
||||
{"190", domain.ErrFreightImageNotFound},
|
||||
{"191", domain.ErrFreightImageInvalid},
|
||||
{"192", domain.ErrFreightSourceUnavailable},
|
||||
{"193", domain.ErrFreightImageInvalid},
|
||||
{"194", domain.ErrFreightSourceSessionNeeded},
|
||||
}
|
||||
for _, test := range tests {
|
||||
image, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
test.ref,
|
||||
)
|
||||
if image.Content != nil {
|
||||
image.Content.Close()
|
||||
}
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("FetchProductImage(%q) error = %v", test.ref, err)
|
||||
}
|
||||
}
|
||||
if _, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
"195",
|
||||
); !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
|
||||
t.Fatalf("request after unauthorized error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerFetchProductImageRejectsUntrustedReferences(
|
||||
t *testing.T,
|
||||
) {
|
||||
manager := testSessionManager(t, "http://127.0.0.1:1", "user", "password")
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"0",
|
||||
"0190",
|
||||
"-1",
|
||||
"1.5",
|
||||
"https://invalid.example/image",
|
||||
} {
|
||||
if _, err := manager.FetchProductImage(
|
||||
context.Background(),
|
||||
value,
|
||||
); !errors.Is(err, domain.ErrFreightImageInvalid) {
|
||||
t.Fatalf("FetchProductImage(%q) error = %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func imageSessionManager(
|
||||
t *testing.T,
|
||||
imageHandler http.HandlerFunc,
|
||||
) (*SessionManager, func()) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
switch request.URL.Path {
|
||||
case CaptchaPath:
|
||||
http.SetCookie(
|
||||
writer,
|
||||
&http.Cookie{Name: "captcha", Value: "ready", Path: "/"},
|
||||
)
|
||||
writer.Header().Set("Content-Type", "image/png")
|
||||
_, _ = writer.Write([]byte("captcha"))
|
||||
case LoginPath:
|
||||
http.SetCookie(
|
||||
writer,
|
||||
&http.Cookie{
|
||||
Name: "authenticated",
|
||||
Value: "yes",
|
||||
Path: "/",
|
||||
},
|
||||
)
|
||||
_, _ = writer.Write(
|
||||
[]byte(
|
||||
`{"status":true,"data":{"user":{"id":1,"username":"test-user"}}}`,
|
||||
),
|
||||
)
|
||||
case UserPath:
|
||||
_, _ = writer.Write(
|
||||
[]byte(
|
||||
`{"status":true,"data":{"id":1,"username":"test-user"}}`,
|
||||
),
|
||||
)
|
||||
case ProductImagePath:
|
||||
imageHandler(writer, request)
|
||||
default:
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
manager := testSessionManager(
|
||||
t,
|
||||
server.URL,
|
||||
"test-user",
|
||||
"test-password",
|
||||
)
|
||||
loginForSource(t, manager)
|
||||
return manager, server.Close
|
||||
}
|
||||
Reference in New Issue
Block a user