chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:49:17 +08:00
commit 7243d5823b
2201 changed files with 257291 additions and 0 deletions
@@ -0,0 +1,166 @@
using System;
using System.IO;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the real TripoAdapter through FakeHttpTransport and pumps the job state machine
/// deterministically (no editor loop, no network). Import is stubbed so no real model bytes
/// are needed; the end-to-end test writes a tiny file under a throwaway Assets subfolder and
/// cleans it up.
/// </summary>
public class AssetGenJobManagerTests
{
private const string EnvVar = "MCPFORUNITY_TRIPO_API_KEY";
private const string Secret = "tsk_e2e_secret_value";
private const string TestFolder = "Assets/Generated/__assetgen_jobtest";
private FakeHttpTransport _fake;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, Secret);
_fake = new FakeHttpTransport();
AssetGenJobManager.TransportOverrideForTests = _fake;
AssetGenJobManager.PollIntervalSeconds = 0;
// Stub import: mark the asset path without touching AssetDatabase.
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable(EnvVar, null);
try
{
string abs = Path.Combine(ProjectRoot(), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { /* ignore */ }
}
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ModelGenRequest Req() => new ModelGenRequest
{
Provider = "tripo",
Mode = "text",
Prompt = "a low-poly oak tree",
Format = "glb",
TargetSize = 1f,
Name = "jobtest",
OutputFolder = TestFolder,
};
private static void Pump(string jobId)
{
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(jobId) && guard++ < 50) { }
Assert.Less(guard, 50, "state machine did not reach a terminal state");
}
[Test]
public void EndToEnd_ReachesDone_WithAssetPath()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
if (spec.Url.Contains("/openapi/task/"))
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"https://cdn.example.com/model.glb\"}}}");
return new HttpResult { Status = 200, IsSuccess = true, Body = new byte[] { 1, 2, 3, 4 } }; // download
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Done, job.State);
Assert.IsNotNull(job.AssetPath);
StringAssert.EndsWith("jobtest.glb", job.AssetPath);
Assert.AreEqual(1f, job.Progress);
}
[Test]
public void FailedPoll_FailsJob_AndLeaksNoKey()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
return Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"error\":\"out of credits\"}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
Assert.IsNotEmpty(job.Error);
string serialized = JsonConvert.SerializeObject(job);
StringAssert.DoesNotContain(Secret, serialized);
}
[Test]
public void FileSchemeDownloadUrl_Rejected_FailsJob()
{
_fake.Handler = spec =>
{
if (spec.Method == "POST" && spec.Url.EndsWith("/openapi/task"))
return Json("{\"code\":0,\"data\":{\"task_id\":\"task_abc\"}}");
// Poll succeeds but hands back a malicious local-file URL as the model.
return Json("{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100,\"output\":{\"pbr_model\":\"file:///etc/passwd\"}}}");
};
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Pump(job.JobId);
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("http", job.Error.ToLowerInvariant());
}
[Test]
public void Cancel_BeforeRun_MarksCanceled()
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.IsTrue(AssetGenJobManager.Cancel(job.JobId));
AssetGenJobManager.TryAdvanceForTests(job.JobId);
Assert.AreEqual(AssetGenJobState.Canceled, job.State);
}
[Test]
public void MissingKey_FailsImmediately()
{
Environment.SetEnvironmentVariable(EnvVar, null); // remove the env key
// Ensure no stored key resolves for tripo by routing through an empty temp store.
string dir = Path.Combine(Path.GetTempPath(), "mcp_jobmgr_nokey_" + Guid.NewGuid().ToString("N"));
MCPForUnity.Editor.Security.SecureKeyStore.OverrideForTests(new MCPForUnity.Editor.Security.EncryptedFileKeyStore(dir));
try
{
AssetGenJob job = AssetGenJobManager.StartModelGeneration(Req());
Assert.AreEqual(AssetGenJobState.Failed, job.State);
StringAssert.Contains("No API key", job.Error);
}
finally
{
MCPForUnity.Editor.Security.SecureKeyStore.ResetForTests();
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 532c8eaebae24a2985bdd0d498296b35
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,83 @@
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Phase 0 scaffold: non-secret AssetGen preferences round-trip through EditorPrefs
/// with sensible defaults. (API keys are NOT covered here — they live in the secure store.)
/// </summary>
public class AssetGenPrefsTests
{
private static readonly string[] Keys =
{
EditorPrefKeys.AssetGenSelectedModelProvider,
EditorPrefKeys.AssetGenSelectedImageProvider,
EditorPrefKeys.AssetGenDefaultFormat,
EditorPrefKeys.AssetGenOutputRoot,
EditorPrefKeys.AssetGenAutoNormalize,
EditorPrefKeys.AssetGenProviderEnabledPrefix + "tripo",
};
private void Clear()
{
foreach (string k in Keys) EditorPrefs.DeleteKey(k);
}
[SetUp]
public void SetUp() => Clear();
[TearDown]
public void TearDown() => Clear();
[Test]
public void Defaults_AreSensible_WhenUnset()
{
Assert.AreEqual("tripo", AssetGenPrefs.ModelProvider);
Assert.AreEqual("fal", AssetGenPrefs.ImageProvider);
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
Assert.IsTrue(AssetGenPrefs.AutoNormalize);
Assert.IsFalse(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void RoundTrip_PersistsValues()
{
AssetGenPrefs.ModelProvider = "meshy";
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.AutoNormalize = false;
AssetGenPrefs.SetProviderEnabled("tripo", true);
Assert.AreEqual("meshy", AssetGenPrefs.ModelProvider);
Assert.AreEqual("Assets/Foo", AssetGenPrefs.OutputRoot);
Assert.IsFalse(AssetGenPrefs.AutoNormalize);
Assert.IsTrue(AssetGenPrefs.IsProviderEnabled("tripo"));
}
[Test]
public void OutputRoot_FallsBackToDefault_WhenClearedToEmpty()
{
AssetGenPrefs.OutputRoot = "Assets/Foo";
AssetGenPrefs.OutputRoot = "";
Assert.AreEqual("Assets/Generated", AssetGenPrefs.OutputRoot);
}
[Test]
public void AssetPathNormalization_RejectsTraversalOutsideAssets()
{
Assert.IsFalse(AssetGenPaths.TryGetAssetsRelativePath("Assets/../ProjectSettings", out _));
}
[Test]
public void AssetPathNormalization_AcceptsAbsolutePathInsideAssets()
{
string abs = System.IO.Path.Combine(Application.dataPath, "Refs/ref.png");
Assert.IsTrue(AssetGenPaths.TryGetAssetsRelativePath(abs, out string rel));
Assert.AreEqual("Assets/Refs/ref.png", rel);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0ae32dac98164b77a73a380b0ebaf4f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the provider factory/registry: Tripo resolves to a real adapter, unimplemented
/// providers throw, and <c>List()</c> reports a key-free <c>Configured</c> bool that honors
/// the env override (<c>MCPFORUNITY_TRIPO_API_KEY</c>) without ever exposing a key value.
/// </summary>
public class AssetGenProvidersTests
{
[Test]
public void Model_Tripo_ReturnsAdapter()
{
IModelProviderAdapter adapter = AssetGenProviders.Model("tripo");
Assert.IsNotNull(adapter);
Assert.AreEqual("tripo", adapter.Id);
}
[Test]
public void Model_Unimplemented_Throws()
{
Assert.Throws<NotSupportedException>(() => AssetGenProviders.Model("hunyuan"));
}
[Test]
public void List_IncludesTripo_ConfiguredIsBool()
{
const string envName = "MCPFORUNITY_TRIPO_API_KEY";
string original = Environment.GetEnvironmentVariable(envName);
string tempDir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_providers_" + Guid.NewGuid().ToString("N"));
try
{
// Deterministic empty baseline that still consults the env-override layer,
// so this test never depends on the dev machine's real key store.
SecureKeyStore.OverrideForTests(new EnvOverlayKeyStore(new EncryptedFileKeyStore(tempDir)));
Environment.SetEnvironmentVariable(envName, null);
ProviderInfo tripo = FindTripo();
Assert.IsNotNull(tripo);
Assert.AreEqual("model", tripo.Kind);
Assert.IsFalse(tripo.Configured, "no key/env should report not configured");
Environment.SetEnvironmentVariable(envName, "tsk_env_override_value");
tripo = FindTripo();
Assert.IsTrue(tripo.Configured, "env present should flip Configured to true");
}
finally
{
Environment.SetEnvironmentVariable(envName, original);
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { /* ignore */ }
}
}
private static ProviderInfo FindTripo()
{
foreach (ProviderInfo p in AssetGenProviders.List())
{
if (p.Id == "tripo") return p;
}
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 59685def5a534586985c4971cc7c5190
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using MCPForUnity.Editor.Helpers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Covers the pure detection core (path list + an exists predicate) deterministically, without
/// depending on whether the test machine actually has Blender installed.
/// </summary>
public class BlenderDetectionTests
{
[Test]
public void DetectIn_ReturnsTrue_WhenACandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/y/blender"));
}
[Test]
public void DetectIn_ReturnsFalse_WhenNoCandidateExists()
{
var candidates = new List<string> { "/x/blender", "/y/blender" };
Assert.IsFalse(BlenderDetection.DetectIn(candidates, _ => false));
}
[Test]
public void DetectIn_IgnoresNullOrEmptyCandidates()
{
var candidates = new List<string> { null, "", "/real/blender" };
Assert.IsTrue(BlenderDetection.DetectIn(candidates, p => p == "/real/blender"));
}
[Test]
public void CandidatePaths_AreNonEmpty()
{
CollectionAssert.IsNotEmpty(new List<string>(BlenderDetection.CandidatePaths()));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c066ef0e1704050a8baa05f984f51b3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,37 @@
using System;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class EnvKeyOverrideTests
{
private const string Var = "MCPFORUNITY_TRIPO_API_KEY";
[TearDown]
public void TearDown() => Environment.SetEnvironmentVariable(Var, null);
[Test]
public void EnvVarName_UpperWithApiKeySuffix()
{
Assert.AreEqual("MCPFORUNITY_TRIPO_API_KEY", EnvKeyOverride.EnvVarName("tripo"));
Assert.AreEqual("MCPFORUNITY_OPENROUTER_API_KEY", EnvKeyOverride.EnvVarName("openrouter"));
}
[Test]
public void TryGet_ReturnsValue_WhenEnvSet()
{
Environment.SetEnvironmentVariable(Var, "tsk_env_value_123");
Assert.IsTrue(EnvKeyOverride.TryGet("tripo", out string v));
Assert.AreEqual("tsk_env_value_123", v);
}
[Test]
public void TryGet_ReturnsFalse_WhenEnvMissing()
{
Environment.SetEnvironmentVariable(Var, null);
Assert.IsFalse(EnvKeyOverride.TryGet("tripo", out string v));
Assert.IsNull(v);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db5c682df8fd473aae015b4e11718bd7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,125 @@
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class FalAdapterTests
{
private const string Resp = "https://queue.fal.run/fal-ai/flux-2/requests/r1";
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
private static ImageGenRequest Req() => new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat" };
[Test]
public void Submit_PostsModelEndpoint_WithKeyHeader_ReturnsResponseUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
string pid = adapter.SubmitAsync(Req(), "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(Resp, pid);
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("fal-ai/flux-2", sent.Url);
Assert.IsTrue(sent.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Key ", sent.Headers["Authorization"]);
}
[Test]
public void Submit_WithDimensions_IncludesImageSize()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"request_id\":\"r1\",\"response_url\":\"" + Resp + "\"}")
};
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "text", Prompt = "a cat", Width = 512, Height = 768 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_size", sent);
StringAssert.Contains("512", sent);
StringAssert.Contains("768", sent);
}
[Test]
public void Submit_ImageMode_UsesEditEndpoint_WithImageUrlsArray()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "make it night", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/edit", rec.Url);
string body = System.Text.Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("image_urls", body);
StringAssert.Contains("https://ex.com/in.png", body);
}
[Test]
public void Poll_Completed_FetchesResult_ReturnsImageUrl()
{
var fake = new FakeHttpTransport
{
Handler = spec =>
{
if (spec.Url.EndsWith("/status")) return Json("{\"status\":\"COMPLETED\"}");
return Json("{\"images\":[{\"url\":\"https://cdn.example.com/img.png\"}]}");
}
};
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
Assert.AreEqual("https://cdn.example.com/img.png", pr.DownloadUrl);
}
[Test]
public void Submit_ImageMode_OmitsImageSize_EvenWithDimensions()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"response_url\":\"" + Resp + "\"}") };
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png", Width = 512, Height = 512 };
adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
string body = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.DoesNotContain("image_size", body); // /edit derives size from the source image
}
[Test]
public void Submit_ImageMode_FallbackResponseUrl_UsesBaseModelPath()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"request_id\":\"r1\"}") }; // no response_url
var adapter = new FalAdapter();
var req = new ImageGenRequest { Provider = "fal", Mode = "image", Prompt = "edit", ImageUrl = "https://ex.com/in.png" };
string pid = adapter.SubmitAsync(req, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("/fal-ai/flux-2/requests/r1", pid);
StringAssert.DoesNotContain("/edit/requests", pid);
}
[Test]
public void Poll_InProgress_ReturnsRunning()
{
var fake = new FakeHttpTransport { Handler = spec => Json("{\"status\":\"IN_PROGRESS\"}") };
var adapter = new FalAdapter();
ProviderPollResult pr = adapter.PollAsync(Resp, "falkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, pr.State);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f27682f34bb041f69ce0495155ccdc73
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,177 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateImageTests
{
private const string TestFolder = "Assets/Generated/__assetgen_imgtest";
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_FAL_API_KEY", null);
Environment.SetEnvironmentVariable("MCPFORUNITY_OPENROUTER_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_imghandler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try
{
string dp = Application.dataPath.Replace('\\', '/');
string abs = Path.Combine(dp.Substring(0, dp.Length - "Assets".Length), TestFolder);
if (Directory.Exists(abs)) Directory.Delete(abs, true);
if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta");
}
catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateImage.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId()
{
_store.Set("fal", "falkey");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
Assert.IsFalse(string.IsNullOrEmpty((string)gen["data"]["job_id"]));
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
_store.Set("fal", "falkey");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("fal", "falkey");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = tmp, ["prompt"] = "edit it" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "edit it" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_UnsupportedExtension_ReturnsError()
{
_store.Set("fal", "falkey");
string rel = WriteProjectFile(TestFolder + "/bad.tga", new byte[] { 0, 0, 2 });
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "fal", ["mode"] = "image", ["imagePath"] = rel, ["prompt"] = "x" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Unsupported", (string)resp["error"]);
}
[Test]
public void ListProviders_ImageOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("fal", s);
StringAssert.Contains("openrouter", s);
StringAssert.DoesNotContain("tripo", s); // model providers excluded
}
[Test]
public void Generate_UnknownProvider_ReturnsError()
{
_store.Set("bogus", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "bogus", ["mode"] = "text", ["prompt"] = "a cat" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void OpenRouterInline_EndToEnd_ReachesDone()
{
_store.Set("openrouter", "orkey");
byte[] png = { 137, 80, 78, 71, 13, 10, 26, 10 }; // PNG magic; bytes only need to be written, not validated
string b64 = Convert.ToBase64String(png);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = spec => new HttpResult
{
Status = 200,
IsSuccess = true,
Text = "{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}"
}
};
AssetGenJobManager.PollIntervalSeconds = 0;
AssetGenJobManager.ImportOverrideForTests = (job, path) => { job.AssetPath = path; return job; };
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat", Name = "imgtest", OutputFolder = TestFolder };
AssetGenJob job = AssetGenJobManager.StartImageGeneration(req);
int guard = 0;
while (!AssetGenJobManager.TryAdvanceForTests(job.JobId) && guard++ < 50) { }
Assert.Less(guard, 50);
Assert.AreEqual(AssetGenJobState.Done, job.State);
StringAssert.EndsWith("imgtest.png", job.AssetPath);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dfab9b71ed52491d9cb539269c3f1670
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,165 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class GenerateModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_TRIPO_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_handler_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport();
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { }
try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_model_handler"), true); } catch { }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(GenerateModel.HandleCommand(p)));
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
[Test]
public void Generate_WithKey_ReturnsPendingJobId_AndStatusReportsState()
{
_store.Set("tripo", "handlerkey123");
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
string jobId = (string)gen["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
JObject st = Call(new JObject { ["action"] = "status", ["job_id"] = jobId });
Assert.AreEqual("pending", (string)st["_mcp_status"]);
Assert.IsNotNull(st["data"]["state"]);
}
[Test]
public void Generate_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Generate_UnimplementedProvider_ReturnsError()
{
_store.Set("hunyuan", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "hunyuan", ["mode"] = "text", ["prompt"] = "a tree" });
Assert.AreEqual(false, (bool)resp["success"]);
}
[Test]
public void Generate_ImageMode_MissingFile_ReturnsError()
{
// Use a provider that supports local images (Meshy) so we exercise the file-existence
// check; Tripo short-circuits on any local path (covered separately).
_store.Set("meshy", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = "Assets/does_not_exist_zzz.png" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void Generate_ImageMode_PathOutsideAssets_ReturnsError()
{
_store.Set("meshy", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_imgin_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("Assets", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_ImageMode_ProjectLocalPath_Accepted_ReturnsPending()
{
_store.Set("meshy", "k");
string rel = WriteProjectFile("Assets/Generated/__assetgen_model_handler/ref.png", new byte[] { 137, 80, 78, 71 });
JObject gen = Call(new JObject { ["action"] = "generate", ["provider"] = "meshy", ["mode"] = "image", ["imagePath"] = rel });
Assert.AreEqual("pending", (string)gen["_mcp_status"]);
}
[Test]
public void Generate_ImageMode_Tripo_LocalPath_ReturnsErrorSynchronously()
{
_store.Set("tripo", "k");
string tmp = Path.Combine(Path.GetTempPath(), "mcp_tripoimg_" + Guid.NewGuid().ToString("N") + ".png");
File.WriteAllBytes(tmp, new byte[] { 137, 80, 78, 71 });
try
{
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "image", ["imagePath"] = tmp });
Assert.AreEqual(false, (bool)resp["success"]); // synchronous error, not a fake 'pending'
StringAssert.Contains("Tripo", (string)resp["error"]);
}
finally { try { File.Delete(tmp); } catch { } }
}
[Test]
public void Generate_TextMode_RequiresPrompt()
{
_store.Set("tripo", "k");
JObject resp = Call(new JObject { ["action"] = "generate", ["provider"] = "tripo", ["mode"] = "text" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("prompt", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ListProviders_IncludesTripoModel()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("tripo", s);
StringAssert.Contains("model", s);
}
[Test]
public void UnknownAction_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "frobnicate" });
Assert.AreEqual(false, (bool)resp["success"]);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7db2353f825a4ab7af5df4b6393309df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,110 @@
using System;
using System.IO;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model_file handler with on-disk fixture files (no network, no provider).
/// Covers missing source, unsupported extension, and a real OBJ import that yields an asset GUID.
/// </summary>
public class ImportModelFileHandlerTests
{
private string _tempDir;
private const string TestFolder = "Assets/__import_model_file_test";
[SetUp]
public void SetUp()
{
_tempDir = Path.Combine(Path.GetTempPath(), "mcp_imf_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
[TearDown]
public void TearDown()
{
if (AssetDatabase.IsValidFolder(TestFolder))
AssetDatabase.DeleteAsset(TestFolder);
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { /* ignore */ }
}
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModelFile.HandleCommand(p)));
private string WriteCubeObj()
{
string path = Path.Combine(_tempDir, "cube.obj");
File.WriteAllText(path,
"o Cube\n" +
"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n" +
"f 1 2 3 4\nf 5 6 7 8\nf 1 2 6 5\nf 2 3 7 6\nf 3 4 8 7\nf 4 1 5 8\n");
return path;
}
[Test]
public void MissingSource_ReturnsError()
{
JObject resp = Call(new JObject { ["sourcePath"] = Path.Combine(_tempDir, "nope.obj") });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("not found", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void UnsupportedExtension_ReturnsError()
{
string txt = Path.Combine(_tempDir, "readme.txt");
File.WriteAllText(txt, "hi");
JObject resp = Call(new JObject { ["sourcePath"] = txt });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("unsupported", ((string)resp["error"]).ToLowerInvariant());
}
[Test]
public void ImportsObj_ReturnsAssetPathAndGuid()
{
string obj = WriteCubeObj();
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "TestCube",
["outputFolder"] = TestFolder,
});
Assert.AreEqual(true, (bool)resp["success"], resp.ToString());
string assetPath = (string)resp["data"]["asset_path"];
StringAssert.StartsWith(TestFolder, assetPath);
Assert.IsFalse(string.IsNullOrEmpty((string)resp["data"]["asset_guid"]));
Assert.IsTrue(File.Exists(assetPath), "imported file should exist under Assets");
}
[Test]
public void OutputFolderTraversal_ReturnsError_AndDoesNotStageOutsideAssets()
{
string obj = WriteCubeObj();
string escapeFolder = "Library/__import_model_file_escape_" + Guid.NewGuid().ToString("N");
string escapedAbs = Path.Combine(Path.GetDirectoryName(Application.dataPath), escapeFolder, "Escaped.obj");
try
{
JObject resp = Call(new JObject
{
["sourcePath"] = obj,
["name"] = "Escaped",
["outputFolder"] = "Assets/../" + escapeFolder,
});
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("output_folder", ((string)resp["error"]).ToLowerInvariant());
Assert.IsFalse(File.Exists(escapedAbs), "handler must not stage files outside Assets");
}
finally
{
string dir = Path.GetDirectoryName(escapedAbs);
try { if (Directory.Exists(dir)) Directory.Delete(dir, true); } catch { /* ignore */ }
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 17fa9724d8324cbf8cc1c7f4c73e020f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MCPForUnity.Editor.Security;
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Tools.AssetGen;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Drives the import_model handler with a fake transport and a temp key store (no network).
/// Covers search (key present/absent), import (returns a pending job_id), and the
/// marketplace-only list_providers shape.
/// </summary>
public class ImportModelHandlerTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
AssetGenJobManager.ResetForTests();
Environment.SetEnvironmentVariable("MCPFORUNITY_SKETCHFAB_API_KEY", null);
_dir = Path.Combine(Path.GetTempPath(), "mcp_import_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
SecureKeyStore.OverrideForTests(_store);
ImportModel.TransportOverrideForTests = null;
}
[TearDown]
public void TearDown()
{
AssetGenJobManager.ResetForTests();
SecureKeyStore.ResetForTests();
ImportModel.TransportOverrideForTests = null;
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
private static HttpResult Json(string body)
=> new HttpResult { Status = 200, IsSuccess = true, Text = body, Body = Encoding.UTF8.GetBytes(body) };
// HandleCommand is async; the fake transport completes synchronously so awaiting here
// never blocks on the editor loop (unlike the live UnityWebRequest path).
private static JObject Call(JObject p)
=> JObject.Parse(JsonConvert.SerializeObject(ImportModel.HandleCommand(p).GetAwaiter().GetResult()));
[Test]
public void HandleCommand_IsAsync()
{
// Regression: search/preview hit UnityWebRequest, whose completion is pumped by the
// editor loop. A synchronous handler that blocks on .GetResult() deadlocks the main
// thread and freezes the editor. The handler must be async so the request can finish
// on a later tick. See the import_model editor-freeze investigation.
var ret = typeof(ImportModel)
.GetMethod(nameof(ImportModel.HandleCommand))
.ReturnType;
Assert.IsTrue(typeof(Task).IsAssignableFrom(ret),
"ImportModel.HandleCommand must return Task (async) to avoid blocking the Unity main thread.");
}
[Test]
public void Search_WithKey_ReturnsResults()
{
_store.Set("sketchfab", "sfkey123");
ImportModel.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"results\":[{\"uid\":\"u1\",\"name\":\"Castle\"}]}")
};
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(true, (bool)resp["success"]);
StringAssert.Contains("u1", resp.ToString());
}
[Test]
public void Search_NoKey_ReturnsError()
{
JObject resp = Call(new JObject { ["action"] = "search", ["query"] = "castle" });
Assert.AreEqual(false, (bool)resp["success"]);
StringAssert.Contains("No API key", (string)resp["error"]);
}
[Test]
public void Import_WithKey_ReturnsPendingJobId()
{
_store.Set("sketchfab", "sfkey123");
// The job manager resolves the download URL through its own transport seam.
AssetGenJobManager.TransportOverrideForTests = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/u1/file.zip\"}}")
};
JObject resp = Call(new JObject { ["action"] = "import", ["uid"] = "u1", ["name"] = "castle" });
Assert.AreEqual("pending", (string)resp["_mcp_status"]);
string jobId = (string)resp["data"]["job_id"];
Assert.IsFalse(string.IsNullOrEmpty(jobId));
}
[Test]
public void ListProviders_IncludesSketchfabOnly()
{
JObject resp = Call(new JObject { ["action"] = "list_providers" });
Assert.AreEqual(true, (bool)resp["success"]);
string s = resp.ToString();
StringAssert.Contains("sketchfab", s);
StringAssert.Contains("marketplace", s);
StringAssert.DoesNotContain("tripo", s);
StringAssert.DoesNotContain("\"fal\"", s);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52eb341abadd457dacc294f4624307c2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,210 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Meshy adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, Bearer scheme), the task id field, and
/// poll-state mapping. The bearer secret is never asserted beyond the "Bearer " prefix.
/// </summary>
public class MeshyAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTextEndpoint_WithBearerHeader_AndReturnsResultId()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"task_meshy_1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a brass lantern", Format = "glb" };
string taskId = adapter.SubmitAsync(req, "msy_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("task_meshy_1", taskId);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/openapi/v2/text-to-3d", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a brass lantern", body);
StringAssert.Contains("preview", body);
}
[Test]
public void Poll_Succeeded_ReturnsGlbUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"status\":\"SUCCEEDED\",\"progress\":100," +
"\"model_urls\":{\"glb\":\"https://assets.meshy.ai/model.glb\",\"fbx\":\"https://assets.meshy.ai/model.fbx\"}}")
};
var adapter = new MeshyAdapter();
// Texture=false -> single-phase (no refine), so a SUCCEEDED preview surfaces directly.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Format = "glb", Texture = false };
adapter.SubmitAsync(req, "k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://assets.meshy.ai/model.glb", res.DownloadUrl);
Assert.AreEqual(1f, res.Progress, 0.001f);
}
[Test]
public void Submit_NoTaskIdInResponse_ErrorIncludesBody()
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"message\":\"quota exceeded\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false };
var ex = Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult());
StringAssert.Contains("quota exceeded", ex.Message);
}
[Test]
public void Poll_InProgress_ReturnsRunning_WithProgress()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"status\":\"IN_PROGRESS\",\"progress\":37}") };
var adapter = new MeshyAdapter();
// Submit single-phase (Texture=false) so progress is reported raw (not split across refine).
adapter.SubmitAsync(
new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "x", Texture = false },
"k", new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") }, CancellationToken.None)
.GetAwaiter().GetResult();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.37f, res.Progress, 0.001f);
}
[Test]
public void Submit_ImageMode_PollsV1ImageEndpoint()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"img1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/i.glb\"}}")
};
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImageUrl = "https://ex.com/ref.png", Format = "glb" };
string id = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("img1", id);
StringAssert.Contains("/openapi/v1/image-to-3d", submitFake.RecordedRequests[0].Url);
ProviderPollResult res = adapter.PollAsync(id, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
// Image tasks must be polled at the v1 image endpoint, not the v2 text endpoint.
StringAssert.Contains("/openapi/v1/image-to-3d/img1", pollFake.RecordedRequests[0].Url);
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://m/i.glb", res.DownloadUrl);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_meshy_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"id1\"}") };
var adapter = new MeshyAdapter();
var req = new ModelGenRequest { Provider = "meshy", Mode = "image", ImagePath = rel, Format = "glb" };
adapter.SubmitAsync(req, "k", fake, CancellationToken.None).GetAwaiter().GetResult();
HttpRequestSpec rec = fake.RecordedRequests[0];
StringAssert.Contains("/openapi/v1/image-to-3d", rec.Url);
StringAssert.Contains("data:image/png;base64,", Encoding.UTF8.GetString(rec.Body));
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_meshy_adapter"), true); } catch { } }
}
[Test]
public void TextWithTexture_PreviewSucceeded_SubmitsRefine_ThenReturnsTexturedModel()
{
var submitFake = new FakeHttpTransport { Handler = _ => Json("{\"result\":\"prev1\"}") };
var pollFake = new FakeHttpTransport
{
Handler = spec => spec.Method == "POST"
? Json("{\"result\":\"refine1\"}") // refine submit
: Json("{\"status\":\"SUCCEEDED\",\"progress\":100,\"model_urls\":{\"glb\":\"https://m/refined.glb\"}}")
};
var adapter = new MeshyAdapter();
// Texture defaults to true -> two-phase preview+refine.
var req = new ModelGenRequest { Provider = "meshy", Mode = "text", Prompt = "a chair", Format = "glb" };
string previewId = adapter.SubmitAsync(req, "k", submitFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("prev1", previewId);
// Poll #1: preview SUCCEEDED -> adapter submits a refine task and reports Running.
ProviderPollResult p1 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, p1.State);
bool refinePosted = false;
foreach (HttpRequestSpec r in pollFake.RecordedRequests)
{
if (r.Method == "POST" && r.Body != null)
{
string b = Encoding.UTF8.GetString(r.Body);
if (b.Contains("refine") && b.Contains("prev1")) refinePosted = true;
}
}
Assert.IsTrue(refinePosted, "expected a refine POST carrying the preview_task_id");
// Poll #2: the refine task SUCCEEDED -> textured model url surfaced.
ProviderPollResult p2 = adapter.PollAsync(previewId, "k", pollFake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, p2.State);
Assert.AreEqual("https://m/refined.glb", p2.DownloadUrl);
}
[Test]
public void Poll_Failed_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"status\":\"FAILED\",\"progress\":0,\"task_error\":{\"message\":\"render error\"}}")
};
var adapter = new MeshyAdapter();
ProviderPollResult res = adapter.PollAsync("id1", "k", http, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47843d3832064cf783a4ee52735f6b3e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,62 @@
using MCPForUnity.Editor.Services.AssetGen;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
using UnityEditor;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Validates the import pipeline's guard paths without fabricating model bytes. Real FBX/GLB
/// import is left to the user's licensed editor (manual verification checklist).
/// Assumes glTFast is NOT installed in the test project (it is an optional dependency).
/// </summary>
public class ModelImportPipelineTests
{
private static AssetGenJob Job(string format) => new AssetGenJob
{
JobId = "test",
Kind = "model",
Provider = "tripo",
State = AssetGenJobState.Importing,
Format = format,
TargetSize = 1f,
};
[Test]
public void Glb_WithoutGltfast_FailsWithActionableMessage()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("glb"), "Assets/Generated/Models/__fake_nonexistent.glb");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("glTFast", result.Error);
}
[Test]
public void PathOutsideAssets_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), "/tmp/somewhere/model.fbx");
Assert.AreEqual(AssetGenJobState.Failed, result.State);
StringAssert.Contains("Assets", result.Error);
}
[Test]
public void NullPath_Fails()
{
AssetGenJob result = ModelImportPipeline.ImportInto(Job("fbx"), null);
Assert.AreEqual(AssetGenJobState.Failed, result.State);
}
[TestCase("generic", ModelImporterAnimationType.Generic)]
[TestCase("Generic", ModelImporterAnimationType.Generic)]
[TestCase("humanoid", ModelImporterAnimationType.Human)]
[TestCase("human", ModelImporterAnimationType.Human)]
[TestCase(" LEGACY ", ModelImporterAnimationType.Legacy)]
[TestCase("none", ModelImporterAnimationType.None)]
[TestCase("", ModelImporterAnimationType.None)]
[TestCase(null, ModelImporterAnimationType.None)]
[TestCase("nonsense", ModelImporterAnimationType.None)]
public void ParseAnimationType_MapsRigMode(string input, ModelImporterAnimationType expected)
{
Assert.AreEqual(expected, ModelImportPipeline.ParseAnimationType(input));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68e84150afa841cfa8e0a7329037859c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,111 @@
using System;
using System.IO;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
using UnityEngine;
namespace MCPForUnityTests.Editor.AssetGen
{
public class OpenRouterAdapterTests
{
private static string ProjectRoot()
{
string dp = Application.dataPath.Replace('\\', '/');
return dp.Substring(0, dp.Length - "Assets".Length);
}
private static string WriteProjectFile(string rel, byte[] bytes)
{
string abs = Path.Combine(ProjectRoot(), rel).Replace('\\', '/');
Directory.CreateDirectory(Path.GetDirectoryName(abs));
File.WriteAllBytes(abs, bytes);
return rel;
}
private static HttpResult Json(string body) => new HttpResult { Status = 200, IsSuccess = true, Text = body };
[Test]
public void Submit_Then_Poll_ReturnsInlineImageBytes()
{
byte[] expected = { 1, 2, 3, 4 };
string b64 = Convert.ToBase64String(expected);
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64," + b64 + "\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
string pid = adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual("ready", pid);
// Submit posts to chat/completions with a Bearer key.
HttpRequestSpec sent = fake.RecordedRequests[0];
Assert.AreEqual("POST", sent.Method);
StringAssert.Contains("chat/completions", sent.Url);
StringAssert.StartsWith("Bearer ", sent.Headers["Authorization"]);
ProviderPollResult pr = adapter.PollAsync(pid, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, pr.State);
CollectionAssert.AreEqual(expected, pr.InlineData);
}
[Test]
public void Submit_ImageMode_IncludesReferenceImageInBody()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "make it watercolor", ImageUrl = "https://ex.com/in.png" };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("https://ex.com/in.png", sent);
}
[Test]
public void Submit_ImageMode_LocalPath_SendsDataUri()
{
string rel = WriteProjectFile("Assets/Generated/__assetgen_openrouter_adapter/ref.png", new byte[] { 137, 80, 78, 71 });
try
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"images\":[{\"image_url\":{\"url\":\"data:image/png;base64,AAAA\"}}]}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "image", Prompt = "watercolor", ImagePath = rel };
adapter.SubmitAsync(req, "orkey", fake, CancellationToken.None).GetAwaiter().GetResult();
string sent = System.Text.Encoding.UTF8.GetString(fake.RecordedRequests[0].Body);
StringAssert.Contains("image_url", sent);
StringAssert.Contains("data:image/png;base64,", sent);
}
finally { try { Directory.Delete(Path.Combine(ProjectRoot(), "Assets/Generated/__assetgen_openrouter_adapter"), true); } catch { } }
}
[Test]
public void Submit_NoImage_PollFails()
{
var fake = new FakeHttpTransport
{
Handler = spec => Json("{\"choices\":[{\"message\":{\"content\":\"sorry, no image\"}}]}")
};
var adapter = new OpenRouterAdapter();
var req = new ImageGenRequest { Provider = "openrouter", Mode = "text", Prompt = "a cat" };
adapter.SubmitAsync(req, "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
ProviderPollResult pr = adapter.PollAsync("ready", "orkey123", fake, CancellationToken.None).GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, pr.State);
Assert.IsNotEmpty(pr.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc263c722db149f6979a08d4e7e376ab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,114 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using MCPForUnity.Editor.Services.AssetGen.Import;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Verifies <see cref="SafeZipExtractor"/> unpacks normal entries and rejects Zip-Slip
/// traversal entries. Builds tiny zips in memory (no fixtures, no network).
/// </summary>
public class SafeZipExtractorTests
{
private string _work;
[SetUp]
public void SetUp()
{
_work = Path.Combine(Path.GetTempPath(), "mcp_zip_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_work);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch { /* ignore */ }
}
private string MakeZip(string entryName, string content)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
ZipArchiveEntry entry = archive.CreateEntry(entryName);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
private string MakeMultiZip(params (string name, string content)[] entries)
{
string zipPath = Path.Combine(_work, "in_" + Guid.NewGuid().ToString("N") + ".zip");
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name);
using (Stream s = entry.Open())
{
byte[] bytes = Encoding.UTF8.GetBytes(content);
s.Write(bytes, 0, bytes.Length);
}
}
}
File.WriteAllBytes(zipPath, ms.ToArray());
}
return zipPath;
}
[Test]
public void Allowlist_SkipsDisallowedEntries()
{
// A hostile marketplace archive: a valid model plus an editor script + a managed dll.
string zip = MakeMultiZip(
("teapot.obj", "o teapot"),
("Editor/Hack.cs", "// [InitializeOnLoad] arbitrary code"),
("plugins/Evil.dll", "MZ..."));
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest, new System.Collections.Generic.HashSet<string> { ".obj" });
Assert.IsTrue(File.Exists(Path.Combine(dest, "teapot.obj")), "allowed model must be written");
Assert.IsFalse(File.Exists(Path.Combine(dest, "Editor", "Hack.cs")), "disallowed .cs must be skipped");
Assert.IsFalse(File.Exists(Path.Combine(dest, "plugins", "Evil.dll")), "disallowed .dll must be skipped");
}
[Test]
public void NormalEntry_Extracts()
{
string zip = MakeZip("models/teapot.obj", "o teapot");
string dest = Path.Combine(_work, "out");
SafeZipExtractor.ExtractTo(zip, dest);
string extracted = Path.Combine(dest, "models", "teapot.obj");
Assert.IsTrue(File.Exists(extracted), "expected extracted file to exist");
Assert.AreEqual("o teapot", File.ReadAllText(extracted));
}
[Test]
public void TraversalEntry_Throws()
{
string zip = MakeZip("../evil.txt", "pwned");
string dest = Path.Combine(_work, "out");
Assert.Throws<IOException>(() => SafeZipExtractor.ExtractTo(zip, dest));
string escaped = Path.GetFullPath(Path.Combine(_work, "evil.txt"));
Assert.IsFalse(File.Exists(escaped), "traversal target must not be written");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0808d9aabe914ea29b323640caf3f998
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
public class SecretRedactorTests
{
[Test]
public void Scrub_RedactsBearerToken()
{
string s = SecretRedactor.Scrub("Authorization: Bearer tsk_abc123XYZ");
StringAssert.DoesNotContain("tsk_abc123XYZ", s);
StringAssert.Contains("REDACTED", s);
}
[Test]
public void Scrub_RedactsKeyAndTokenSchemes()
{
StringAssert.DoesNotContain("falkey123456", SecretRedactor.Scrub("Authorization: Key falkey123456"));
StringAssert.DoesNotContain("sktoken987654", SecretRedactor.Scrub("Authorization: Token sktoken987654"));
}
[Test]
public void Scrub_WithExplicitSecret_RedactsThatValue()
{
string s = SecretRedactor.Scrub("error: key sk-supersecret rejected", "sk-supersecret");
StringAssert.DoesNotContain("sk-supersecret", s);
}
[Test]
public void Scrub_LeavesNormalTextAlone()
{
Assert.AreEqual("hello world", SecretRedactor.Scrub("hello world"));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ed1cc6ea39d48dcaad4fbbf372c979c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,92 @@
using System;
using System.IO;
using MCPForUnity.Editor.Security;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Targets the CI-safe EncryptedFileKeyStore directly against a throwaway temp dir
/// (never the user's real store). Proves round-trip, deletion, overwrite, multi-secret
/// blobs, and that the on-disk file is actually encrypted.
/// </summary>
public class SecureKeyStoreTests
{
private string _dir;
private EncryptedFileKeyStore _store;
[SetUp]
public void SetUp()
{
_dir = Path.Combine(Path.GetTempPath(), "mcp_assetgen_keys_" + Guid.NewGuid().ToString("N"));
_store = new EncryptedFileKeyStore(_dir);
}
[TearDown]
public void TearDown()
{
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, true); } catch { /* ignore */ }
}
[Test]
public void SetThenGet_RoundTrips()
{
_store.Set("tripo", "tsk_secret_value_123");
Assert.IsTrue(_store.Has("tripo"));
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("tsk_secret_value_123", v);
}
[Test]
public void Get_Missing_ReturnsFalse()
{
Assert.IsFalse(_store.Has("meshy"));
Assert.IsFalse(_store.TryGet("meshy", out string v));
Assert.IsNull(v);
}
[Test]
public void Delete_RemovesKey()
{
_store.Set("fal", "k-123456");
_store.Delete("fal");
Assert.IsFalse(_store.Has("fal"));
Assert.IsFalse(_store.TryGet("fal", out _));
}
[Test]
public void Overwrite_ReplacesValue()
{
_store.Set("tripo", "first_value");
_store.Set("tripo", "second_value");
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual("second_value", v);
}
[Test]
public void MultiSecret_JsonBlob_RoundTrips()
{
string blob = "{\"secretId\":\"AKID123\",\"secretKey\":\"SK456\"}";
_store.Set("tripo", blob);
Assert.IsTrue(_store.TryGet("tripo", out string v));
Assert.AreEqual(blob, v);
}
[Test]
public void Set_Empty_DeletesKey()
{
_store.Set("tripo", "abc");
_store.Set("tripo", "");
Assert.IsFalse(_store.Has("tripo"));
}
[Test]
public void Ciphertext_OnDisk_DoesNotContainPlaintext()
{
_store.Set("tripo", "PLAINTEXT_SECRET_XYZ");
string file = Path.Combine(_dir, "key_tripo.bin");
Assert.IsTrue(File.Exists(file));
StringAssert.DoesNotContain("PLAINTEXT_SECRET_XYZ", File.ReadAllText(file));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa8115bc72ce42a9aadd51bf567135df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,87 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Sketchfab marketplace adapter against a <see cref="FakeHttpTransport"/>.
/// Asserts the "Token" auth scheme and that the download endpoint's gltf.url is parsed into
/// the archive URL the job manager will fetch. The secret is never asserted beyond the scheme.
/// </summary>
public class SketchfabAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Search_GetsSearchEndpoint_WithTokenHeader_ReturnsRawJson()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[{\"uid\":\"abc\"}]}") };
var adapter = new SketchfabAdapter();
string raw = adapter.SearchAsync("castle", null, true, null, null, "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult();
StringAssert.Contains("\"uid\":\"abc\"", raw);
HttpRequestSpec rec = http.RecordedRequests[0];
Assert.AreEqual("GET", rec.Method);
StringAssert.Contains("/v3/search", rec.Url);
StringAssert.Contains("downloadable=true", rec.Url);
StringAssert.Contains("q=castle", rec.Url);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void Search_ForwardsCategoriesCountCursorAndDownloadableFlag()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"results\":[],\"cursors\":{\"next\":\"2\"}}") };
var adapter = new SketchfabAdapter();
adapter.SearchAsync("castle", "architecture", false, 12, "2", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
string url = http.RecordedRequests[0].Url;
StringAssert.Contains("categories=architecture", url);
StringAssert.Contains("count=12", url);
StringAssert.Contains("cursor=2", url);
StringAssert.Contains("downloadable=false", url);
}
[Test]
public void ResolveDownloadUrl_ParsesGltfUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"gltf\":{\"url\":\"https://dl.sketchfab.com/models/abc/file.zip?sig=x\"}}")
};
var adapter = new SketchfabAdapter();
string url = adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("https://dl.sketchfab.com/models/abc/file.zip?sig=x", url);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v3/models/abc/download", rec.Url);
StringAssert.StartsWith("Token ", rec.Headers["Authorization"]);
}
[Test]
public void ResolveDownloadUrl_MissingGltf_Throws()
{
var http = new FakeHttpTransport { Handler = _ => Json("{\"usdz\":{\"url\":\"https://x/y.usdz\"}}") };
var adapter = new SketchfabAdapter();
Assert.Throws<System.Exception>(() =>
adapter.ResolveDownloadUrlAsync("abc", "sfk_secret", http, CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0da413b01b2437390f8f7b7b9d20c44
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,136 @@
using System.Text;
using System.Threading;
using MCPForUnity.Editor.Services.AssetGen.Http;
using MCPForUnity.Editor.Services.AssetGen.Providers;
using NUnit.Framework;
namespace MCPForUnityTests.Editor.AssetGen
{
/// <summary>
/// Exercises the Tripo adapter against a <see cref="FakeHttpTransport"/> — no network.
/// Asserts request shaping (endpoint, method, auth scheme, body) and poll-state mapping.
/// The bearer secret is never asserted beyond the "Bearer " scheme prefix.
/// </summary>
public class TripoAdapterTests
{
private static HttpResult Json(string json, int status = 200)
=> new HttpResult
{
Status = status,
IsSuccess = status >= 200 && status < 300,
Text = json,
Body = Encoding.UTF8.GetBytes(json)
};
[Test]
public void Submit_PostsTaskEndpoint_WithBearerHeader_AndReturnsTaskId()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"abc123\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "text", Prompt = "a red dragon" };
string taskId = adapter.SubmitAsync(req, "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual("abc123", taskId);
Assert.AreEqual(1, http.RecordedRequests.Count);
HttpRequestSpec rec = http.RecordedRequests[0];
StringAssert.Contains("/v2/openapi/task", rec.Url);
Assert.AreEqual("POST", rec.Method);
Assert.IsTrue(rec.Headers.ContainsKey("Authorization"));
StringAssert.StartsWith("Bearer ", rec.Headers["Authorization"]);
string body = Encoding.UTF8.GetString(rec.Body);
StringAssert.Contains("a red dragon", body);
StringAssert.Contains("text_to_model", body);
}
[Test]
public void Poll_MapsSuccess_WithDownloadUrl()
{
var http = new FakeHttpTransport
{
Handler = _ => Json(
"{\"code\":0,\"data\":{\"status\":\"success\",\"progress\":100," +
"\"output\":{\"pbr_model\":\"https://cdn.tripo3d.ai/model.glb\"}}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "tsk_secret_value", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Succeeded, res.State);
Assert.AreEqual("https://cdn.tripo3d.ai/model.glb", res.DownloadUrl);
}
[Test]
public void Poll_MapsRunning_WithProgress()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"running\",\"progress\":42}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Running, res.State);
Assert.AreEqual(0.42f, res.Progress, 0.001f);
}
[Test]
public void Poll_MapsFailed_WithError()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"status\":\"failed\",\"progress\":0}}")
};
var adapter = new TripoAdapter();
ProviderPollResult res = adapter.PollAsync("abc123", "k", http, CancellationToken.None)
.GetAwaiter().GetResult();
Assert.AreEqual(ProviderPollState.Failed, res.State);
Assert.IsNotEmpty(res.Error);
}
[Test]
public void Submit_ImageMode_UsesImageToModel()
{
var http = new FakeHttpTransport
{
Handler = _ => Json("{\"code\":0,\"data\":{\"task_id\":\"img1\"}}")
};
var adapter = new TripoAdapter();
var req = new ModelGenRequest
{
Provider = "tripo",
Mode = "image",
ImageUrl = "https://example.com/in.png"
};
adapter.SubmitAsync(req, "k", http, CancellationToken.None).GetAwaiter().GetResult();
string body = Encoding.UTF8.GetString(http.RecordedRequests[0].Body);
StringAssert.Contains("image_to_model", body);
StringAssert.Contains("https://example.com/in.png", body);
}
[Test]
public void Submit_ImageMode_LocalPathOnly_Throws()
{
// Tripo can't take a local image inline (no data-URI support, upload not wired) — it must
// fail clearly rather than silently falling back to text mode.
var adapter = new TripoAdapter();
var req = new ModelGenRequest { Provider = "tripo", Mode = "image", ImagePath = "/tmp/whatever.png" };
Assert.Throws<System.Exception>(() =>
adapter.SubmitAsync(req, "k", new FakeHttpTransport(), CancellationToken.None).GetAwaiter().GetResult());
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 79b6a1e9658e417d960212be835c0c84
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: