diff --git a/Directory.Packages.props b/Directory.Packages.props index 039256594..34c2c98ea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -57,6 +57,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 9595905ed..46bb2474b 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -85,6 +85,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.PostgreSql", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.PubSub", "src\Testcontainers.PubSub\Testcontainers.PubSub.csproj", "{E6642255-667D-476B-B584-089AA5E6C0B1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Qdrant", "src\Testcontainers.Qdrant\Testcontainers.Qdrant.csproj", "{7C98973D-53D7-49F9-BDFE-E3268F402584}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.RabbitMq", "src\Testcontainers.RabbitMq\Testcontainers.RabbitMq.csproj", "{A6D480BC-FDE8-4B92-A2A6-FF16BEE486AE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.RavenDb", "src\Testcontainers.RavenDb\Testcontainers.RavenDb.csproj", "{F6394475-D6F1-46E2-81BF-4BA78A40B878}" @@ -179,6 +181,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.PostgreSql.T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.PubSub.Tests", "tests\Testcontainers.PubSub.Tests\Testcontainers.PubSub.Tests.csproj", "{0F86BCE8-62E1-4BFC-AA84-63C7514C90AC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Qdrant.Tests", "tests\Testcontainers.Qdrant.Tests\Testcontainers.Qdrant.Tests.csproj", "{9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.RabbitMq.Tests", "tests\Testcontainers.RabbitMq.Tests\Testcontainers.RabbitMq.Tests.csproj", "{19564567-1736-4626-B406-17E4E02F18B2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.RavenDb.Tests", "tests\Testcontainers.RavenDb.Tests\Testcontainers.RavenDb.Tests.csproj", "{D53726B6-5447-47E6-B881-A44EFF6E5534}" @@ -348,6 +352,10 @@ Global {E6642255-667D-476B-B584-089AA5E6C0B1}.Debug|Any CPU.Build.0 = Debug|Any CPU {E6642255-667D-476B-B584-089AA5E6C0B1}.Release|Any CPU.ActiveCfg = Release|Any CPU {E6642255-667D-476B-B584-089AA5E6C0B1}.Release|Any CPU.Build.0 = Release|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C98973D-53D7-49F9-BDFE-E3268F402584}.Release|Any CPU.Build.0 = Release|Any CPU {A6D480BC-FDE8-4B92-A2A6-FF16BEE486AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A6D480BC-FDE8-4B92-A2A6-FF16BEE486AE}.Debug|Any CPU.Build.0 = Debug|Any CPU {A6D480BC-FDE8-4B92-A2A6-FF16BEE486AE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -536,6 +544,10 @@ Global {0F86BCE8-62E1-4BFC-AA84-63C7514C90AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {0F86BCE8-62E1-4BFC-AA84-63C7514C90AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {0F86BCE8-62E1-4BFC-AA84-63C7514C90AC}.Release|Any CPU.Build.0 = Release|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D}.Release|Any CPU.Build.0 = Release|Any CPU {19564567-1736-4626-B406-17E4E02F18B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {19564567-1736-4626-B406-17E4E02F18B2}.Debug|Any CPU.Build.0 = Debug|Any CPU {19564567-1736-4626-B406-17E4E02F18B2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -606,6 +618,7 @@ Global {464F1120-A0DA-462B-B9E8-45176D883625} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {8AB91636-9055-4900-A72A-7CFFACDFDBF0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {E6642255-667D-476B-B584-089AA5E6C0B1} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {7C98973D-53D7-49F9-BDFE-E3268F402584} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {A6D480BC-FDE8-4B92-A2A6-FF16BEE486AE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {F6394475-D6F1-46E2-81BF-4BA78A40B878} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {BFDA179A-40EB-4CEB-B8E9-0DF32C65E2C5} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -653,6 +666,7 @@ Global {3E55CBE8-AFE8-426D-9470-49D63CD1051C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {56D0DCA5-567F-4B3B-8B79-CB108F8EB8A6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {0F86BCE8-62E1-4BFC-AA84-63C7514C90AC} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {9DCE3E7F-B341-4AD0-BAAA-C3B91EB91B0D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {19564567-1736-4626-B406-17E4E02F18B2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {D53726B6-5447-47E6-B881-A44EFF6E5534} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {31EE94A0-E721-4073-B6F1-DD912D004DEF} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} diff --git a/src/Testcontainers.Qdrant/.editorconfig b/src/Testcontainers.Qdrant/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Qdrant/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/QdrantBuilder.cs b/src/Testcontainers.Qdrant/QdrantBuilder.cs new file mode 100644 index 000000000..07e388c88 --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantBuilder.cs @@ -0,0 +1,94 @@ +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public sealed class QdrantBuilder : ContainerBuilder +{ + public const string QdrantImage = "qdrant/qdrant:v1.8.3"; + + public const ushort QdrantHttpPort = 6333; + + public const ushort QdrantGrpcPort = 6334; + + public const string QdrantTlsCertFilePath = "/qdrant/tls/cert.pem"; + + public const string QdrantTlsKeyFilePath = "/qdrant/tls/key.pem"; + + public QdrantBuilder() : this(new QdrantConfiguration()) => + DockerResourceConfiguration = Init().DockerResourceConfiguration; + + private QdrantBuilder(QdrantConfiguration dockerResourceConfiguration) : base(dockerResourceConfiguration) => + DockerResourceConfiguration = dockerResourceConfiguration; + + /// + /// The API key used to secure the instance. A certificate and private key should also be + /// provided to to enable Transport Layer Security (TLS). + /// + /// The API key + public QdrantBuilder WithApiKey(string apiKey) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(apiKey: apiKey)) + .WithEnvironment("QDRANT__SERVICE__API_KEY", apiKey); + + /// + /// A certificate and private key to enable Transport Layer Security (TLS). + /// + /// A public certificate in PEM format + /// A private key for the certificate in PEM format + public QdrantBuilder WithCertificate(string certificate, string privateKey) + { + return Merge(DockerResourceConfiguration, new QdrantConfiguration(certificate: certificate, privateKey: privateKey)) + .WithEnvironment("QDRANT__SERVICE__ENABLE_TLS", "1") + .WithResourceMapping(Encoding.UTF8.GetBytes(certificate), QdrantTlsCertFilePath) + .WithEnvironment("QDRANT__TLS__CERT", QdrantTlsCertFilePath) + .WithResourceMapping(Encoding.UTF8.GetBytes(privateKey), QdrantTlsKeyFilePath) + .WithEnvironment("QDRANT__TLS__KEY", QdrantTlsKeyFilePath); + } + + /// + public override QdrantContainer Build() + { + Validate(); + + var waitStrategy = Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => + { + var httpWaitStrategy = request.ForPort(QdrantHttpPort).ForPath("/readyz"); + + // allow any certificate defined to pass validation + if (DockerResourceConfiguration.Certificate is not null) + { + httpWaitStrategy.UsingTls() + .UsingHttpMessageHandler(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }); + } + + return httpWaitStrategy; + }); + + var qdrantBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(waitStrategy); + return new QdrantContainer(qdrantBuilder.DockerResourceConfiguration); + } + + /// + protected override QdrantBuilder Init() => + base.Init() + .WithImage(QdrantImage) + .WithPortBinding(QdrantHttpPort, true) + .WithPortBinding(QdrantGrpcPort, true); + + /// + protected override QdrantBuilder Clone(IResourceConfiguration resourceConfiguration) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration)); + + /// + protected override QdrantBuilder Merge(QdrantConfiguration oldValue, QdrantConfiguration newValue) => + new(new QdrantConfiguration(oldValue, newValue)); + + /// + protected override QdrantConfiguration DockerResourceConfiguration { get; } + + /// + protected override QdrantBuilder Clone(IContainerConfiguration resourceConfiguration) => + Merge(DockerResourceConfiguration, new QdrantConfiguration(resourceConfiguration)); +} \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/QdrantConfiguration.cs b/src/Testcontainers.Qdrant/QdrantConfiguration.cs new file mode 100644 index 000000000..1f80db5f4 --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantConfiguration.cs @@ -0,0 +1,71 @@ +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public sealed class QdrantConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public QdrantConfiguration(string apiKey = null, string certificate = null, string privateKey = null) + { + ApiKey = apiKey; + Certificate = certificate; + PrivateKey = privateKey; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public QdrantConfiguration(QdrantConfiguration resourceConfiguration) + : this(new QdrantConfiguration(), resourceConfiguration) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public QdrantConfiguration(QdrantConfiguration oldValue, QdrantConfiguration newValue) + : base(oldValue, newValue) + { + ApiKey = BuildConfiguration.Combine(oldValue.ApiKey, newValue.ApiKey); + Certificate = BuildConfiguration.Combine(oldValue.Certificate, newValue.Certificate); + PrivateKey = BuildConfiguration.Combine(oldValue.PrivateKey, newValue.PrivateKey); + } + + /// + /// Gets the API key used to secure Qdrant. + /// + public string ApiKey { get; } + + /// + /// Gets the certificate used to configure Transport Layer Security. Certificate must be in PEM format. + /// + public string Certificate { get; } + + /// + /// Gets the private key used to configure Transport Layer Security. Private key must be in PEM format. + /// + public string PrivateKey { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/QdrantContainer.cs b/src/Testcontainers.Qdrant/QdrantContainer.cs new file mode 100644 index 000000000..0851188c5 --- /dev/null +++ b/src/Testcontainers.Qdrant/QdrantContainer.cs @@ -0,0 +1,33 @@ +namespace Testcontainers.Qdrant; + +/// +[PublicAPI] +public sealed class QdrantContainer : DockerContainer +{ + private readonly QdrantConfiguration _configuration; + + public QdrantContainer(QdrantConfiguration configuration) : base(configuration) + { + _configuration = configuration; + } + + /// + /// Gets the connection string for connecting to Qdrant REST APIs + /// + public string GetHttpConnectionString() + { + var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantHttpPort)); + return endpoint.ToString(); + } + + /// + /// Gets the connection string for connecting to Qdrant gRPC APIs + /// + public string GetGrpcConnectionString() + { + var scheme = _configuration.Certificate != null ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; + var endpoint = new UriBuilder(scheme, Hostname, GetMappedPublicPort(QdrantBuilder.QdrantGrpcPort)); + return endpoint.ToString(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj b/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj new file mode 100644 index 000000000..51735310a --- /dev/null +++ b/src/Testcontainers.Qdrant/Testcontainers.Qdrant.csproj @@ -0,0 +1,12 @@ + + + net6.0;net8.0;netstandard2.0;netstandard2.1;net462 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Qdrant/Usings.cs b/src/Testcontainers.Qdrant/Usings.cs new file mode 100644 index 000000000..b2b9653ef --- /dev/null +++ b/src/Testcontainers.Qdrant/Usings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Linq; +global using System.Net.Http; +global using System.Text; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/.editorconfig b/tests/Testcontainers.Qdrant.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/PemCertificate.cs b/tests/Testcontainers.Qdrant.Tests/PemCertificate.cs new file mode 100644 index 000000000..661052e05 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/PemCertificate.cs @@ -0,0 +1,24 @@ +namespace Testcontainers.Qdrant; + +public record PemCertificate(string Certificate, string PrivateKey, string Thumbprint) +{ + public static PemCertificate Create(string commonName) + { + using var key = RSA.Create(2048); + var utcNow = DateTimeOffset.UtcNow; + var request = new CertificateRequest( + $"CN={commonName}", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1) + { + CertificateExtensions = { new X509BasicConstraintsExtension(false, false, 0, true) }, + }; + + var certificate = request.CreateSelfSigned(utcNow, utcNow.AddYears(1)); + return new PemCertificate( + certificate.ExportCertificatePem(), + certificate.GetRSAPrivateKey().ExportPkcs8PrivateKeyPem(), + certificate.GetCertHashString(HashAlgorithmName.SHA256)); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs b/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs new file mode 100644 index 000000000..f6b62e1a7 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/QdrantContainerApiKeyCertificateTest.cs @@ -0,0 +1,100 @@ +namespace Testcontainers.Qdrant; + +public sealed class QdrantContainerApiKeyCertificateTest : IAsyncLifetime +{ + private const string Host = "Testcontainers"; + private const string ApiKey = "password!"; + private static readonly PemCertificate Cert = PemCertificate.Create(Host); + + private readonly QdrantContainer _qdrantContainer = new QdrantBuilder() + .WithApiKey(ApiKey) + .WithCertificate(Cert.Certificate, Cert.PrivateKey) + .Build(); + + public Task InitializeAsync() + { + return _qdrantContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _qdrantContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ListCollectionsReturnsValidResponse() + { + var httpMessageHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + CertificateValidation.Thumbprint(Cert.Thumbprint), + }; + + var channel = GrpcChannel.ForAddress( + _qdrantContainer.GetGrpcConnectionString(), + new GrpcChannelOptions + { + HttpClient = new HttpClient(httpMessageHandler) + { + DefaultRequestHeaders = { Host = Host }, + }, + }); + var callInvoker = channel.Intercept(metadata => + { + metadata.Add("api-key", ApiKey); + return metadata; + }); + + var grpcClient = new QdrantGrpcClient(callInvoker); + var client = new QdrantClient(grpcClient); + var response = await client.ListCollectionsAsync(); + + Assert.Empty(response); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ListCollectionsWithoutApiKeyReturnsInvalidResponse() + { + var httpMessageHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + CertificateValidation.Thumbprint(Cert.Thumbprint) + }; + + var channel = GrpcChannel.ForAddress( + _qdrantContainer.GetGrpcConnectionString(), + new GrpcChannelOptions + { + HttpClient = new HttpClient(httpMessageHandler) + { + DefaultRequestHeaders = { Host = Host }, + }, + }); + + var grpcClient = new QdrantGrpcClient(channel); + var client = new QdrantClient(grpcClient); + + var exception = await Assert.ThrowsAsync(async () => + await client.ListCollectionsAsync()); + + Assert.Equal(StatusCode.PermissionDenied, exception.Status.StatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ListCollectionsWithoutCertificateValidationReturnsInvalidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + DefaultRequestHeaders = { Host = Host }, + }; + + client.DefaultRequestHeaders.Add("api-key", ApiKey); + + // The SSL connection could not be established + await Assert.ThrowsAsync(() => client.GetAsync("/collections")); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs b/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs new file mode 100644 index 000000000..87e3815e3 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/QdrantContainerTest.cs @@ -0,0 +1,40 @@ +namespace Testcontainers.Qdrant; + +public sealed class QdrantContainerTest : IAsyncLifetime +{ + private readonly QdrantContainer _qdrantContainer = new QdrantBuilder().Build(); + + public Task InitializeAsync() + { + return _qdrantContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _qdrantContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PingReturnsValidResponse() + { + var client = new HttpClient + { + BaseAddress = new Uri(_qdrantContainer.GetHttpConnectionString()), + }; + + var response = await client.GetAsync("/"); + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task ListCollectionsReturnsValidResponse() + { + var uri = new Uri(_qdrantContainer.GetGrpcConnectionString()); + var client = new QdrantClient(uri.Host, uri.Port); + + var response = await client.ListCollectionsAsync(); + Assert.Empty(response); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj b/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj new file mode 100644 index 000000000..e20447fc9 --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/Testcontainers.Qdrant.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.Qdrant.Tests/Usings.cs b/tests/Testcontainers.Qdrant.Tests/Usings.cs new file mode 100644 index 000000000..abf2356ac --- /dev/null +++ b/tests/Testcontainers.Qdrant.Tests/Usings.cs @@ -0,0 +1,12 @@ +global using System; +global using System.Net.Http; +global using System.Security.Cryptography; +global using System.Security.Cryptography.X509Certificates; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using Grpc.Core; +global using Grpc.Core.Interceptors; +global using Grpc.Net.Client; +global using Qdrant.Client; +global using Qdrant.Client.Grpc; +global using Xunit; \ No newline at end of file