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 TestCleanupExpiredLocalTempAssetsDeletesExpiredStaticFiles(t *testing.T) { uploadedDir := t.TempDir() generatedDir := t.TempDir() oldUploaded := filepath.Join(uploadedDir, requestAssetFilePrefix+"old.png") freshUploaded := filepath.Join(uploadedDir, requestAssetFilePrefix+"fresh.png") oldGenerated := filepath.Join(generatedDir, "gateway-result-old.png") freshGenerated := filepath.Join(generatedDir, "gateway-result-fresh.png") for _, path := range []string{oldUploaded, freshUploaded, oldGenerated, freshGenerated} { if err := os.WriteFile(path, []byte("asset"), 0o644); err != nil { t.Fatalf("write fixture %s: %v", path, err) } } now := time.Now() for _, path := range []string{oldUploaded, oldGenerated} { if err := os.Chtimes(path, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil { t.Fatalf("touch old static asset %s: %v", path, err) } } for _, path := range []string{freshUploaded, freshGenerated} { if err := os.Chtimes(path, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil { t.Fatalf("touch fresh static asset %s: %v", path, err) } } server := &Server{ cfg: config.Config{ LocalGeneratedStorageDir: generatedDir, LocalUploadedStorageDir: uploadedDir, LocalTempAssetTTLHours: 24, }, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now) if deleted != 2 { t.Fatalf("expected two expired static asset deletes, got %d", deleted) } for _, path := range []string{oldUploaded, oldGenerated} { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("old static asset should be deleted %s, stat err=%v", path, err) } } for _, path := range []string{freshUploaded, freshGenerated} { if _, err := os.Stat(path); err != nil { t.Fatalf("fresh static asset 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) } }