using System.Text.Json;
using Dpz.Core.Infrastructure;
using Dpz.Core.Service.Network.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using Xunit.Abstractions;

namespace Dpz.Core.Service.Network.Test;

public class OpenAiServiceTest(ITestOutputHelper testOutputHelper)
    : NetworkServiceTestBasic(testOutputHelper)
{
    private readonly ITestOutputHelper _testOutputHelper = testOutputHelper;

    [Fact]
    public void OpenAiServiceCanBeResolved()
    {
        var service = GetService<IOpenAiService>();

        Assert.NotNull(service);
    }

    [Fact]
    public async Task SendMessageAsyncReturnsDeltasAndFinalContent()
    {
        var service = GetService<IOpenAiService>();
        var deltas = new List<string>();

        var result = await service.SendMessageAsync(
            [new UserChatMessage("请用中文简短回答:2+2等于几?")],
            (delta, _) =>
            {
                deltas.Add(delta);
                return Task.CompletedTask;
            }
        );

        Assert.True(result.Success, result.Message);
        Assert.NotNull(result.Data);
        Assert.NotEmpty(deltas);
        Assert.False(string.IsNullOrWhiteSpace(result.Data!.Content));

        _testOutputHelper.WriteLine($"OpenAI SDK stream response: {result.Data.Content}");
    }

    [Fact]
    public async Task SendMessageAsyncWithEmptyMessagesReturnsFail()
    {
        var service = GetService<IOpenAiService>();

        var result = await service.SendMessageAsync([]);

        Assert.False(result.Success);
        Assert.Contains("empty", result.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task SendMessageAsyncWithJsonSchemaCanDeserializeResponse()
    {
        var service = GetService<IOpenAiService>();

        var result = await service.SendMessageAsync(
            [
                new UserChatMessage("请只返回 JSON:2+2 的结果。"),
                new SystemChatMessage("格式:{answer: 0}"),
            ],
            options: null
        );

        Assert.True(result.Success, result.Message);
        Assert.NotNull(result.Data);

        if (
            result.Data.TryDeserialize<StructuredAnswer>(
                out var value,
                out var msg,
                new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
            )
        )
        {
            Assert.NotNull(value);
            Assert.True(string.IsNullOrWhiteSpace(msg));
        }

        var response = result.Data!.Deserialize<StructuredAnswer>(
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
        );

        Assert.NotNull(response);
        Assert.Equal(4, response.Answer);
        return;

        // 支持约束的模型很少
        // void Options(OpenAiChatRequestOptions x)
        // {
        //     x.ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
        //         jsonSchemaFormatName: "math_answer",
        //         jsonSchema: BinaryData.FromBytes(
        //             """
        //                 {
        //                  "type": "object",
        //                  "properties": {
        //                    "answer": {
        //                      "type": "integer"
        //                    }
        //                  },
        //                  "required": ["answer"],
        //                  "additionalProperties": false
        //                 }
        //                 """u8.ToArray()
        //         ),
        //         jsonSchemaIsStrict: true
        //     );
        // }
    }

    [Fact]
    public async Task GetModelsAsyncReturnsAvailableModelsWhenProviderSupportsEndpoint()
    {
        var service = GetService<IOpenAiService>();

        var result = await service.GetModelsAsync();

        if (!result.Success)
        {
            Assert.Contains("404", result.Message);
            _testOutputHelper.WriteLine(
                "当前兼容服务商未暴露 OpenAI /models 端点,模型列表能力返回 404。"
            );
            return;
        }

        Assert.NotNull(result.Data);
        Assert.NotEmpty(result.Data!);

        foreach (var item in result.Data)
        {
            _testOutputHelper.WriteLine(
                $"Id:{item.Id},OwnedBy:{item.OwnedBy},OwnedBy:{item.OwnedBy}"
            );
        }
    }

    [Theory]
    [InlineData("OpenAI:Endpoint", "OpenAI:Endpoint configuration is missing")]
    [InlineData("OpenAI:ApiKey", "OpenAI:ApiKey configuration is missing")]
    [InlineData("OpenAI:Model", "OpenAI:Model configuration is missing")]
    public void MissingRequiredConfigurationThrows(string missingKey, string expectedMessage)
    {
        var configuration = BuildConfiguration((missingKey, string.Empty));

        var exception = Assert.Throws<InvalidConfigurationException>(() =>
            CreateService(configuration)
        );

        Assert.Contains(expectedMessage, exception.Message);
    }

    [Fact]
    public void DeserializeWithCodeFenceWrappedContentSucceeds()
    {
        var result = new OpenAiChatStreamResult
        {
            Model = "test-model",
            Content = "```json\n{ \"answer\": 42 }\n```",
        };

        var value = result.Deserialize<StructuredAnswer>(
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
        );

        Assert.NotNull(value);
        Assert.Equal(42, value.Answer);
    }

    [Fact]
    public void TryDeserializeWithCodeFenceWrappedContentSucceeds()
    {
        var result = new OpenAiChatStreamResult
        {
            Model = "test-model",
            Content = "```\n{\"answer\": 7}\n```",
        };

        var success = result.TryDeserialize<StructuredAnswer>(
            out var value,
            out var errorMessage,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
        );

        Assert.True(success);
        Assert.NotNull(value);
        Assert.Equal(7, value.Answer);
        Assert.Null(errorMessage);
    }

    private static IConfiguration BuildConfiguration(params (string Key, string? Value)[] overrides)
    {
        var values = new Dictionary<string, string?>
        {
            ["OpenAI:Endpoint"] = "https://api.example.com",
            ["OpenAI:ApiKey"] = "test-key",
            ["OpenAI:Model"] = "gpt-5-mini",
            ["OpenAI:AuthenticationScheme"] = "Bearer",
        };

        foreach (var (key, value) in overrides)
        {
            values[key] = value;
        }

        return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
    }

    private static OpenAiService CreateService(IConfiguration configuration)
    {
        var services = new ServiceCollection();
        services.AddSingleton(configuration);
        services.AddLogging();

        var provider = services.BuildServiceProvider();
        return new OpenAiService(
            configuration,
            provider.GetRequiredService<ILogger<OpenAiService>>()
        );
    }

    private sealed class StructuredAnswer
    {
        public int Answer { get; set; }
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这是一个基于 xUnit 的测试类文件,主要用于测试 Dpz.Core.Service.Network 中的 OpenAiService 类,验证其对 OpenAI 兼容接口的调用能力、配置校验、响应序列化等功能。

代码整体结构

  • 命名空间:Dpz.Core.Service.Network.Test
  • 类名:OpenAiServiceTest
  • 继承自 NetworkServiceTestBasic(基础测试类,提供 GetService<T>() 等测试辅助方法)
  • 构造函数接收 ITestOutputHelper,用于输出测试日志
  • 包含一个私有字段 _testOutputHelper 保存输出助手

主要引用的库:

  • Microsoft.Extensions.Configuration / DependencyInjection:配置与依赖注入
  • OpenAI.Chat:使用官方 OpenAI SDK 的消息类型
  • xUnitXunit.Abstractions:测试框架与日志输出
  • System.Text.Json:JSON 处理
  • 自用命名空间:Dpz.Core.Infrastructure(可能包含 InvalidConfigurationException)、Dpz.Core.Service.Network.Models

测试方法说明

1. OpenAiServiceCanBeResolved

  • 测试类型解析:从依赖注入容器中获取 IOpenAiService 实例。
  • 断言不为 null,确保服务已正确注册。

2. SendMessageAsyncReturnsDeltasAndFinalContent

  • 调用 IOpenAiService.SendMessageAsync,传入一个用户消息:"请用中文简短回答:2+2等于几?"
  • 在流式回调参数中,将每次收到的增量片段 delta 添加到一个列表 deltas
  • 断言:
    • 调用结果 Successtrue(否则输出 Message
    • 返回结果 Data 不为 null
    • 增量列表 deltas 不为空(说明有流式输出)
    • 最终内容 Content 非空
  • 输出最终完整内容到测试控制台。

3. SendMessageAsyncWithEmptyMessagesReturnsFail

  • 传入空消息列表 [] 调用 SendMessageAsync
  • 断言结果为失败(Successfalse),并且 Message 中包含 “empty” 字样(不区分大小写),验证了空消息参数的保护逻辑。

4. SendMessageAsyncWithJsonSchemaCanDeserializeResponse

  • 用多个消息(系统消息指定返回格式,用户消息要求只返回 JSON)调用 SendMessageAsync
  • 不传 options,使用默认策略。
  • 断言调用成功,Data 不为空。
  • 尝试用 StructuredAnswer 类型反序列化 Data 对应的 JSON 内容:
    • 首先 TryDeserialize,判断是否成功,若成功则断言 value 不为空、msg 为空字符串。
    • 然后直接 Deserialize,并断言返回对象的 Answer == 4
  • 代码中保留了一段注释掉的代码,展示了 OpenAI 官方 SDK 配置 ResponseFormat 为 JsonSchema 的用法,但注释说明“支持约束的模型很少”,因此没被启用,而是依赖提示词让模型返回符合格式的 JSON。
  • 测试既验证了服务能向模型请求并返回内容,也验证了自定义的 JSON 反序列化扩展方法可以正确处理可能包含 Markdown 代码块包裹的 JSON。

5. GetModelsAsyncReturnsAvailableModelsWhenProviderSupportsEndpoint

  • 调用 GetModelsAsync() 获取模型列表。
  • 如果结果失败(如 404),则断言错误信息中包含 “404”,说明当前兼容服务商不支持 /models 端点,测试视为通过(并通过测试输出记录说明)。
  • 如果成功,则断言列表不为空,并输出每个模型的 IdOwnedBy 属性(注意输出了两次 OwnedBy,可能是笔误)。

6. MissingRequiredConfigurationThrows

  • 这是一个 [Theory] 测试,使用 [InlineData] 提供三组缺失配置项及其预期错误消息:
    • 缺少 OpenAI:Endpoint → 错误消息包含 “OpenAI:Endpoint configuration is missing”
    • 缺少 OpenAI:ApiKey → 类似
    • 缺少 OpenAI:Model → 类似
  • 测试通过 BuildConfiguration 构造配置(将指定键设为空字符串表示缺失),然后调用 CreateService 创建服务。
  • 断言抛出了 InvalidConfigurationException,且异常消息包含预期内容,验证初始化时对必要配置的校验。

7. DeserializeWithCodeFenceWrappedContentSucceeds

  • 构造一个 OpenAiChatStreamResult 实例,设置 Content 为包含 Markdown 代码块包裹的 JSON:"```json\n{ \"answer\": 42 }\n```"
  • 调用其扩展方法 Deserialize<StructuredAnswer>(...),断言能成功反序列化为 StructuredAnswerAnswer 为 42,验证扩展方法能够自动剥离代码围栏。

8. TryDeserializeWithCodeFenceWrappedContentSucceeds

  • 类似上一个测试,但使用的是 TryDeserialize 变体,并且 Content 是未带 json 标记的代码围栏("```\n{\"answer\": 7}\n```")。
  • 断言 TryDeserialize 返回 success=truevalue 不为空,errorMessagenull,并验证 Answer == 7

辅助方法

  1. BuildConfiguration(params (string Key, string? Value)[] overrides)

    • 构造一个包含默认值的字典(Endpoint、ApiKey、Model、AuthenticationScheme)
    • 将传入的覆盖项合并进去(可用来模拟“配置缺失”)
    • 使用 ConfigurationBuilder.AddInMemoryCollection 返回 IConfiguration
  2. CreateService(IConfiguration configuration)

    • 建立一个临时依赖注入容器,注册配置与日志服务
    • 从中取出 ILogger<OpenAiService> 并构造 OpenAiService 实例(直接 new,便于测试构造函数中的配置校验逻辑)
  3. 私有类 StructuredAnswer

    • 用于 JSON 反序列化的强类型对象,包含整型属性 Answer

整体设计意图

  • 测试覆盖了 OpenAiService 的主要公共方法:SendMessageAsyncGetModelsAsync
  • 验证了流式回调机制、空消息处理、结构化输出反序列化。
  • 验证了配置缺失时构造函数能抛出明确异常。
  • 测试了辅助扩展方法对带代码块 JSON 内容的反序列化能力。
  • 通过 ITestOutputHelper 输出调试信息,便于集成测试时人工查看。
  • 因为使用了真实 OpenAI 兼容服务(或本地 Mock),测试需要网络或模拟端点,因此部分测试按实际返回结果对成功/失败进行了兼容处理(如 GetModelsAsync 的 404 容错)。

可以说,这是一个较全面的单元/集成测试文件,用于确保 OpenAiService 能正确、稳健地与 OpenAI 兼容 API 交互,并验证周边辅助工具函数的正确性。

评论加载中...