// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using OpenAI.Files;
#pragma warning disable OPENAI001, CS0618
namespace Microsoft.Agents.AI.Foundry.UnitTests;
///
/// Unit tests for the file and vector-store helper methods on .
/// Covers all four methods across the three FoundryChatClient construction modes plus argument
/// validation, cancellation, and request-body shape on the wire.
///
public sealed class FoundryChatClientVectorStoreTests
{
// ----- Construction helpers shared by every test in this file -----
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode1(string modelId = "gpt-4o-mini", string? responseBody = null)
{
var recorder = new RequestRecorder(responseBody);
#pragma warning disable CA5399
var httpClient = new HttpClient(recorder);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
return (new FoundryChatClient(projectClient, modelId), recorder);
}
private static (FoundryChatClient ChatClient, RequestRecorder Recorder) CreateMode2(string? responseBody = null)
{
var recorder = new RequestRecorder(responseBody);
#pragma warning disable CA5399
var httpClient = new HttpClient(recorder);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var agentRef = new AgentReference("agent-name", "1");
return (new FoundryChatClient(projectClient, agentRef, defaultModelId: "gpt-4o", baseChatOptions: null), recorder);
}
private static string MakeTempFile(string contents = "hello world")
{
var path = Path.Combine(Path.GetTempPath(), $"fcc-test-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, contents);
return path;
}
// ----- UploadFileAsync -----
[Fact]
public async Task UploadFileAsync_Mode1_UploadsViaProjectOpenAIClientAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileJson("file_abc"));
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
Assert.Equal("file_abc", result.Id);
Assert.NotEmpty(recorder.Requests);
Assert.EndsWith("/files", recorder.Requests[0].PathAndQuery.TrimEnd('/').Split('?')[0]);
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_Mode2_UploadsViaProjectOpenAIClientAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileJson("file_xyz"));
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants);
Assert.Equal("file_xyz", result.Id);
Assert.Contains(recorder.Requests, r => r.PathAndQuery.Contains("/files"));
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_Mode3_UploadsViaMaterializedProjectClientAsync()
{
// Q-E: Mode 3 (Agent Endpoint) now honors caller-supplied transports via
// ProjectOpenAIClientOptions.Transport, so we can use a fake transport here instead of
// depending on DNS/network availability against example.com.
var sawUpload = false;
using var handler = new HttpHandlerAssert(req =>
{
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
sawUpload = true;
return MakeJsonResponse(FakeFileJson("file_mode3"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var chatClient = new FoundryChatClient(
agentEndpoint: new Uri("https://example.com/api/projects/myproj/agents/myagent/endpoint/protocols/openai"),
credential: new FakeAuthenticationTokenProvider(),
clientOptions: new ProjectOpenAIClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var path = MakeTempFile();
try
{
var result = await chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, CancellationToken.None);
Assert.True(sawUpload);
Assert.Equal("file_mode3", result.Id);
}
finally { File.Delete(path); }
}
[Fact]
public async Task UploadFileAsync_NullFilePath_ThrowsArgumentNullExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAsync(() =>
chatClient.UploadFileAsync(null!, FileUploadPurpose.Assistants));
}
[Fact]
public async Task UploadFileAsync_FileNotFound_ThrowsFileNotFoundExceptionAsync()
{
var (chatClient, _) = CreateMode1();
var missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
await Assert.ThrowsAsync(() =>
chatClient.UploadFileAsync(missing, FileUploadPurpose.Assistants));
}
[Fact]
public async Task UploadFileAsync_HonorsCancellationAsync()
{
// Cancellation propagation through the OpenAI SDK pipeline surfaces different exception
// types depending on the framework target (OperationCanceledException on net10.0,
// ObjectDisposedException at the transport layer on net472). Asserting on the exact
// exception class is brittle; assert only that the call throws when the token is
// pre-cancelled.
var (chatClient, _) = CreateMode1(responseBody: FakeFileJson("file_abc"));
var path = MakeTempFile();
try
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync(() =>
chatClient.UploadFileAsync(path, FileUploadPurpose.Assistants, cts.Token));
}
finally { File.Delete(path); }
}
// ----- DeleteFileAsync -----
[Fact]
public async Task DeleteFileAsync_Mode1_CallsDeleteOnFileClientAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeFileDeletedJson("file_abc"));
await chatClient.DeleteFileAsync("file_abc");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_abc"));
}
[Fact]
public async Task DeleteFileAsync_Mode2_CallsDeleteOnFileClientAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeFileDeletedJson("file_xyz"));
await chatClient.DeleteFileAsync("file_xyz");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/files/file_xyz"));
}
[Fact]
public async Task DeleteFileAsync_NullId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync(null!));
}
[Fact]
public async Task DeleteFileAsync_EmptyId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync(""));
}
[Fact]
public async Task DeleteFileAsync_HonorsCancellationAsync()
{
// Verify the cancellation token reaches the HTTP pipeline by having the handler
// throw OperationCanceledException when the token is cancelled before the request.
// This is more robust than asserting on the exact exception the SDK surfaces, which
// depends on internal pipeline plumbing.
var observedToken = CancellationToken.None;
using var handler = new HttpHandlerAssert(async req =>
{
// We don't have direct access to the SDK's CancellationToken here; instead, sleep
// briefly to give the caller's pre-cancellation a chance to be picked up by the
// transport. If cancellation reached the pipeline, the await on this handler call
// would surface OperationCanceledException; if not, the response is returned.
await Task.Delay(50).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(FakeFileDeletedJson("file_abc"), Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
using var cts = new CancellationTokenSource();
cts.Cancel();
// Any throw is acceptable evidence that cancellation was honored. The SDK's exact
// exception surface for pre-cancelled tokens is an implementation detail of
// System.ClientModel's pipeline and may differ between versions.
await Assert.ThrowsAnyAsync(() => chatClient.DeleteFileAsync("file_abc", cts.Token));
}
// ----- CreateVectorStoreAsync -----
[Fact]
public async Task CreateVectorStoreAsync_UploadsThenCreates_WithFileIds_ReturnsVectorStoreAsync()
{
// Each file POST returns a distinct file id; the recorder dispatches on URL to differentiate.
var fileCount = 0;
using var handler = new HttpHandlerAssert(async req =>
{
var body = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
if (req.RequestUri!.AbsolutePath.Contains("/files") && req.Method == HttpMethod.Post)
{
fileCount++;
return MakeJsonResponse(FakeFileJson($"file_{fileCount}"));
}
if (req.RequestUri.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
Assert.Contains("file_1", body);
Assert.Contains("file_2", body);
Assert.Contains("knowledge-base", body);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "knowledge-base"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var pathA = MakeTempFile("alpha");
var pathB = MakeTempFile("beta");
try
{
var store = await chatClient.CreateVectorStoreAsync("knowledge-base", new[] { pathA, pathB });
Assert.Equal("vs_abc", store.Id);
Assert.Equal(2, fileCount);
}
finally { File.Delete(pathA); File.Delete(pathB); }
}
[Fact]
public async Task CreateVectorStoreAsync_WithExpiresAfter_SerializesLastActiveAtAnchorAsync()
{
string? vectorStoreBody = null;
using var handler = new HttpHandlerAssert(async req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
await chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: TimeSpan.FromDays(7));
Assert.NotNull(vectorStoreBody);
Assert.Contains("\"expires_after\"", vectorStoreBody);
Assert.Contains("\"last_active_at\"", vectorStoreBody);
Assert.Contains("\"days\":7", vectorStoreBody);
}
[Fact]
public async Task CreateVectorStoreAsync_WithNullExpiresAfter_OmitsExpirationPolicyAsync()
{
string? vectorStoreBody = null;
using var handler = new HttpHandlerAssert(async req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
vectorStoreBody = req.Content is null ? "" : await req.Content.ReadAsStringAsync().ConfigureAwait(false);
return MakeJsonResponse(FakeVectorStoreJson("vs_abc", name: "x"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
await chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null);
Assert.NotNull(vectorStoreBody);
Assert.DoesNotContain("\"expires_after\"", vectorStoreBody);
}
[Fact]
public async Task CreateVectorStoreAsync_EmptyFilesList_CreatesEmptyStoreAsync()
{
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_empty", name: "x"));
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty());
Assert.Equal("vs_empty", store.Id);
}
[Fact]
public async Task CreateVectorStoreAsync_NullName_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync(() =>
chatClient.CreateVectorStoreAsync(null!, Array.Empty()));
}
[Fact]
public async Task CreateVectorStoreAsync_NullFilePaths_ThrowsArgumentNullExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAsync(() =>
chatClient.CreateVectorStoreAsync("x", filePaths: null!));
}
[Fact]
public async Task CreateVectorStoreAsync_HonorsCancellationAsync()
{
// Same rationale as UploadFileAsync_HonorsCancellationAsync — assert only that any
// exception is thrown on a pre-cancelled token.
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreJson("vs_x", "x"));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null, cancellationToken: cts.Token));
}
[Fact]
public async Task CreateVectorStoreAsync_PollsUntilStoreLeavesInProgress_Async()
{
// Q-A regression: when the create response returns status=in_progress, the helper must
// poll GET /vector_stores/{id} until status changes before returning. Otherwise the
// caller receives a half-built store.
var pollCount = 0;
using var handler = new HttpHandlerAssert(req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores") && req.Method == HttpMethod.Post)
{
// First response: status=in_progress.
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: "in_progress")));
}
if (req.RequestUri.AbsolutePath.Contains("/vector_stores/vs_abc") && req.Method == HttpMethod.Get)
{
pollCount++;
// Stay in_progress for two polls, then complete on the third.
var status = pollCount < 3 ? "in_progress" : "completed";
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_abc", name: "x", status: status)));
}
return Task.FromResult(MakeJsonResponse("{}"));
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var store = await chatClient.CreateVectorStoreAsync("x", Array.Empty());
Assert.NotEqual(OpenAI.VectorStores.VectorStoreStatus.InProgress, store.Status);
Assert.True(pollCount >= 3, $"Expected at least 3 GET polls before status leaves in_progress; saw {pollCount}.");
}
[Fact]
public async Task CreateVectorStoreAsync_PollingTimeout_ThrowsTimeoutExceptionAsync()
{
// Sergey #2: caller-supplied (or default) polling timeout must surface as TimeoutException
// when the vector store never leaves InProgress. Mock keeps the store stuck and we pass
// a tiny timeout; cancellation token stays unused so the only path that ends the loop
// is the timeout check.
using var handler = new HttpHandlerAssert(req =>
{
if (req.RequestUri!.AbsolutePath.Contains("/vector_stores", StringComparison.Ordinal))
{
return Task.FromResult(MakeJsonResponse(FakeVectorStoreJsonWithStatus("vs_stuck", name: "x", status: "in_progress")));
}
return Task.FromResult(MakeJsonResponse("{}"));
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var ex = await Assert.ThrowsAsync(() =>
chatClient.CreateVectorStoreAsync("x", Array.Empty(), expiresAfter: null, pollingTimeout: TimeSpan.FromMilliseconds(500)));
Assert.Contains("vs_stuck", ex.Message, StringComparison.Ordinal);
Assert.Contains("in-progress", ex.Message, StringComparison.Ordinal);
}
[Fact]
public async Task CreateVectorStoreAsync_MidUploadFailure_DeletesAlreadyUploadedFilesAsync()
{
// Q-B regression: when the upload loop throws partway through (e.g. file 3 of 5 is
// missing or the network fails), the helper must DELETE the already-uploaded files so
// they do not accumulate as orphaned resources. The exception must still propagate.
var uploadCount = 0;
var deleted = new List();
using var handler = new HttpHandlerAssert(req =>
{
// DELETE first so we don't match the upload-collection /files path against this.
if (req.Method == HttpMethod.Delete)
{
var segments = req.RequestUri!.AbsolutePath.Split('/');
var fileId = segments[segments.Length - 1];
deleted.Add(fileId);
return MakeJsonResponse(FakeFileDeletedJson(fileId));
}
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
uploadCount++;
if (uploadCount == 3)
{
// 400 is non-retriable; the SDK retry policy ignores it. 5xx would trigger
// retries and confuse the assertion on upload count.
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed-on-3\"}}", Encoding.UTF8, "application/json"),
};
}
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var paths = new[] { MakeTempFile("a"), MakeTempFile("b"), MakeTempFile("c"), MakeTempFile("d"), MakeTempFile("e") };
try
{
await Assert.ThrowsAnyAsync(() => chatClient.CreateVectorStoreAsync("knowledge-base", paths));
// Three upload attempts: two succeeded, the third threw.
Assert.Equal(3, uploadCount);
// The two successful uploads must have been deleted as part of best-effort cleanup.
Assert.Equal(2, deleted.Count);
Assert.Contains("file_1", deleted);
Assert.Contains("file_2", deleted);
}
finally
{
foreach (var p in paths)
{
File.Delete(p);
}
}
}
[Fact]
public async Task CreateVectorStoreAsync_MidUploadFailure_CleanupSwallowsDeleteErrorsAsync()
{
// Q-B follow-on: if a cleanup DELETE itself fails, the helper must still propagate the
// original upload exception — not the cleanup exception. The caller cares about the
// upload failure; cleanup is best-effort.
var uploadCount = 0;
using var handler = new HttpHandlerAssert(req =>
{
if (req.Method == HttpMethod.Delete)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"DeleteFailed\",\"message\":\"cleanup-failed\"}}", Encoding.UTF8, "application/json"),
};
}
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.Contains("/files", StringComparison.Ordinal))
{
uploadCount++;
if (uploadCount == 2)
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("{\"error\":{\"code\":\"BadRequest\",\"message\":\"upload-failed\"}}", Encoding.UTF8, "application/json"),
};
}
return MakeJsonResponse(FakeFileJson($"file_{uploadCount}"));
}
return MakeJsonResponse("{}");
});
#pragma warning disable CA5399
using var httpClient = new HttpClient(handler);
#pragma warning restore CA5399
var projectClient = new AIProjectClient(
new Uri("https://test.openai.azure.com/"),
new FakeAuthenticationTokenProvider(),
new AIProjectClientOptions { Transport = new HttpClientPipelineTransport(httpClient) });
var chatClient = new FoundryChatClient(projectClient, "gpt-4o-mini");
var paths = new[] { MakeTempFile("a"), MakeTempFile("b") };
try
{
var ex = await Assert.ThrowsAnyAsync(() => chatClient.CreateVectorStoreAsync("kb", paths));
// The original upload-failure message must surface, not the cleanup-failure message.
Assert.DoesNotContain("cleanup-failed", ex.Message ?? "", StringComparison.Ordinal);
}
finally
{
foreach (var p in paths)
{
File.Delete(p);
}
}
}
// ----- DeleteVectorStoreAsync -----
[Fact]
public async Task DeleteVectorStoreAsync_Mode1_CallsDeleteAsync()
{
var (chatClient, recorder) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
await chatClient.DeleteVectorStoreAsync("vs_abc");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_abc"));
}
[Fact]
public async Task DeleteVectorStoreAsync_Mode2_CallsDeleteAsync()
{
var (chatClient, recorder) = CreateMode2(responseBody: FakeVectorStoreDeletedJson("vs_xyz"));
await chatClient.DeleteVectorStoreAsync("vs_xyz");
Assert.Contains(recorder.Requests, r => r.Method == "DELETE" && r.PathAndQuery.Contains("/vector_stores/vs_xyz"));
}
[Fact]
public async Task DeleteVectorStoreAsync_NullId_ThrowsArgumentExceptionAsync()
{
var (chatClient, _) = CreateMode1();
await Assert.ThrowsAnyAsync(() => chatClient.DeleteVectorStoreAsync(null!));
}
[Fact]
public async Task DeleteVectorStoreAsync_HonorsCancellationAsync()
{
// Same approach as DeleteFileAsync_HonorsCancellationAsync — assert that the call
// throws when the token is pre-cancelled, without asserting on the exact exception
// surfaced by the SDK pipeline.
var (chatClient, _) = CreateMode1(responseBody: FakeVectorStoreDeletedJson("vs_abc"));
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync(() => chatClient.DeleteVectorStoreAsync("vs_abc", cts.Token));
}
// ----- Fixtures and helpers -----
private static HttpResponseMessage MakeJsonResponse(string json)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private static string FakeFileJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"bytes\":11,\"created_at\":1700000000,\"filename\":\"x.txt\",\"purpose\":\"assistants\",\"status\":\"processed\"}}";
private static string FakeFileDeletedJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"file\",\"deleted\":true}}";
private static string FakeVectorStoreJson(string id, string name)
=> FakeVectorStoreJsonWithStatus(id, name, status: "completed");
private static string FakeVectorStoreJsonWithStatus(string id, string name, string status)
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store\",\"created_at\":1700000000,\"name\":\"{name}\",\"usage_bytes\":0,\"file_counts\":{{\"in_progress\":0,\"completed\":0,\"failed\":0,\"cancelled\":0,\"total\":0}},\"status\":\"{status}\",\"last_active_at\":1700000000}}";
private static string FakeVectorStoreDeletedJson(string id)
=> $"{{\"id\":\"{id}\",\"object\":\"vector_store.deleted\",\"deleted\":true}}";
private sealed class RequestRecorder : HttpClientHandler
{
private readonly string _responseBody;
public List Requests { get; } = [];
public RequestRecorder(string? responseBody)
{
this._responseBody = responseBody ?? "{}";
}
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.Requests.Add(new RecordedRequest
{
Method = request.Method.Method,
PathAndQuery = request.RequestUri?.PathAndQuery ?? "",
#if NET
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false),
#else
Body = request.Content is null ? "" : await request.Content.ReadAsStringAsync().ConfigureAwait(false),
#endif
});
return MakeJsonResponse(this._responseBody);
}
}
private sealed class RecordedRequest
{
public string Method { get; set; } = "";
public string PathAndQuery { get; set; } = "";
public string Body { get; set; } = "";
}
}
#pragma warning restore CS0618