package httpapi import ( "context" "encoding/base64" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" ) func TestRequestAssetFromValueDetectsDataURLAndRawBase64(t *testing.T) { payload := base64.StdEncoding.EncodeToString([]byte("inline image")) decoded, ok, err := requestAssetFromValue("url", []string{"messages", "[0]", "content", "[1]", "image_url"}, "data:image/png;base64,"+payload, nil) if err != nil { t.Fatalf("decode data URL: %v", err) } if !ok || decoded.ContentType != "image/png" || string(decoded.Bytes) != "inline image" { t.Fatalf("unexpected data URL asset: ok=%v decoded=%+v", ok, decoded) } audio := base64.StdEncoding.EncodeToString([]byte("inline audio")) decoded, ok, err = requestAssetFromValue("data", []string{"input_audio"}, audio, map[string]any{"format": "mp3"}) if err != nil { t.Fatalf("decode raw audio: %v", err) } if !ok || decoded.ContentType != "audio/mpeg" || string(decoded.Bytes) != "inline audio" { t.Fatalf("unexpected raw audio asset: ok=%v decoded=%+v", ok, decoded) } } func TestCanonicalConversationMessageHashUsesTextAndAssetRefs(t *testing.T) { message := map[string]any{ "role": "user", "content": []any{ map[string]any{"type": "text", "text": "describe it"}, map[string]any{"type": "image_url", "image_url": map[string]any{ "url": "https://cdn.example/a.png", "assetRef": map[string]any{"sha256": "sha-a", "url": "https://cdn.example/a.png"}, }}, }, } sameMessage := map[string]any{ "role": "user", "content": []any{ map[string]any{"type": "text", "text": "describe it"}, map[string]any{"type": "image_url", "image_url": map[string]any{ "url": "https://different.example/a.png", "assetRef": map[string]any{"sha256": "sha-a", "url": "https://different.example/a.png"}, }}, }, } changedMessage := map[string]any{ "role": "user", "content": "describe something else", } firstHash, assetHashes := canonicalConversationMessageHash(message) secondHash, _ := canonicalConversationMessageHash(sameMessage) changedHash, _ := canonicalConversationMessageHash(changedMessage) if firstHash != secondHash { t.Fatalf("message hash should ignore resource URL drift when asset sha is stable") } if firstHash == changedHash { t.Fatalf("message hash should change when text changes") } if len(assetHashes) != 1 || assetHashes[0] != "sha-a" { t.Fatalf("unexpected asset hashes: %+v", assetHashes) } } func TestCleanupExpiredLocalTempAssetsOnlyDeletesExpiredPrefixedFiles(t *testing.T) { storageDir := t.TempDir() oldTemp := filepath.Join(storageDir, requestAssetFilePrefix+"old.png") freshTemp := filepath.Join(storageDir, requestAssetFilePrefix+"fresh.png") oldGenerated := filepath.Join(storageDir, "gateway-result-old.png") for _, path := range []string{oldTemp, freshTemp, oldGenerated} { if err := os.WriteFile(path, []byte("asset"), 0o644); err != nil { t.Fatalf("write fixture %s: %v", path, err) } } now := time.Now() if err := os.Chtimes(oldTemp, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { t.Fatalf("touch old temp: %v", err) } if err := os.Chtimes(freshTemp, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil { t.Fatalf("touch fresh temp: %v", err) } if err := os.Chtimes(oldGenerated, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { t.Fatalf("touch old generated: %v", err) } server := &Server{ cfg: config.Config{LocalUploadedStorageDir: storageDir, LocalTempAssetTTLHours: 24}, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now) if deleted != 1 { t.Fatalf("expected one expired temp asset delete, got %d", deleted) } if _, err := os.Stat(oldTemp); !os.IsNotExist(err) { t.Fatalf("old prefixed temp asset should be deleted, stat err=%v", err) } for _, path := range []string{freshTemp, oldGenerated} { if _, err := os.Stat(path); err != nil { t.Fatalf("non-expired or non-prefixed file should remain %s: %v", path, err) } } } func TestRequestConversationKeyPriority(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil) request.Header.Set("X-EasyAI-Conversation-ID", "from-header") body := map[string]any{ "conversation_id": "from-body", "metadata": map[string]any{"conversation_id": "from-metadata"}, } if got := requestConversationKey(request, body); got != "from-header" { t.Fatalf("expected header conversation id, got %q", got) } request.Header.Del("X-EasyAI-Conversation-ID") if got := requestConversationKey(request, body); got != "from-body" { t.Fatalf("expected body conversation id, got %q", got) } delete(body, "conversation_id") if got := requestConversationKey(request, body); got != "from-metadata" { t.Fatalf("expected metadata conversation id, got %q", got) } }