Test infrastructure for C# Azure Function testing (#2055)

Add support for function tests and the ability to run them against either real Azure Storage or the Azurite emulator.

See follow-up PR #2032 for actual usage.
This commit is contained in:
George Pollard
2022-06-17 09:50:02 +12:00
committed by GitHub
parent e9147ba9a7
commit cdec3c9e8d
17 changed files with 594 additions and 14 deletions

View File

@ -17,6 +17,9 @@ wget https://aka.ms/downloadazcopy-v10-linux
tar -xvf downloadazcopy-v10-linux
sudo cp ./azcopy_linux_amd64_*/azcopy /usr/bin/
# Install Azurite
sudo npm install -g azurite
# Restore rust dependencies
echo "Restoring rust dependencies"
cargo install cargo-audit cargo-license # requirements if you want to run ci/agent.sh

View File

@ -275,6 +275,7 @@ jobs:
cd src/ApiService/
dotnet restore --locked-mode
dotnet tool restore
sudo npm install -g azurite
- name: Check Formatting
run: |
cd src/ApiService/
@ -286,8 +287,10 @@ jobs:
- name: Test & Collect coverage info
run: |
cd src/ApiService/
dotnet test --no-restore --collect:"XPlat Code Coverage"
azurite --silent &
dotnet test --no-restore --collect:"XPlat Code Coverage" --filter 'Category!=Integration'
dotnet tool run reportgenerator -reports:Tests/TestResults/*/coverage.cobertura.xml -targetdir:coverage -reporttypes:MarkdownSummary
kill %1
cat coverage/*.md > $GITHUB_STEP_SUMMARY
- name: copy artifacts
run: |

View File

@ -38,6 +38,11 @@ public interface IServiceConfig {
public string? OneFuzzTelemetry { get; }
public string OneFuzzVersion { get; }
// Prefix to add to the name of any tables created. This allows
// multiple instances to run against the same storage account, which
// is useful for things like integration testing.
public string OneFuzzTablePrefix { get; }
}
public class ServiceConfiguration : IServiceConfig {
@ -82,4 +87,5 @@ public class ServiceConfiguration : IServiceConfig {
public string OneFuzzVersion { get => Environment.GetEnvironmentVariable("ONEFUZZ_VERSION") ?? "0.0.0"; }
public string OneFuzzNodeDisposalStrategy { get => Environment.GetEnvironmentVariable("ONEFUZZ_NODE_DISPOSAL_STRATEGY") ?? "scale_in"; }
public string OneFuzzTablePrefix => ""; // in production we never prefix the tables
}

View File

@ -45,7 +45,7 @@ public class Containers : IContainers {
if (client is null)
return null;
return new Uri($"{GetUrl(client.AccountName)}{container}/{name}");
return new Uri($"{_storage.GetBlobEndpoint(client.AccountName)}{container}/{name}");
}
public async Async.Task<BinaryData?> GetBlob(Container container, string name, StorageType storageType) {
@ -93,14 +93,10 @@ public class Containers : IContainers {
return null;
}
var storageKeyCredential = new StorageSharedKeyCredential(accountName, accountKey);
var accountUrl = GetUrl(accountName);
var accountUrl = _storage.GetBlobEndpoint(accountName);
return new BlobServiceClient(accountUrl, storageKeyCredential);
}
private static Uri GetUrl(string accountName) {
return new Uri($"https://{accountName}.blob.core.windows.net/");
}
public async Async.Task<Uri?> GetFileSasUrl(Container container, string name, StorageType storageType, BlobSasPermissions permissions, TimeSpan? duration = null) {
var client = await FindContainer(container, storageType) ?? throw new Exception($"unable to find container: {container.ContainerName} - {storageType}");
@ -194,4 +190,3 @@ public class Containers : IContainers {
return await client.GetBlobClient(name).ExistsAsync();
}
}

View File

@ -51,9 +51,9 @@ public class Queue : IQueue {
var accountId = _storage.GetPrimaryAccount(storageType);
_log.Verbose($"getting blob container (account_id: {accountId})");
var (name, key) = await _storage.GetStorageAccountNameAndKey(accountId);
var accountUrl = new Uri($"https://{name}.queue.core.windows.net");
var endpoint = _storage.GetQueueEndpoint(accountId);
var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 };
return new QueueServiceClient(accountUrl, new StorageSharedKeyCredential(name, key), options);
return new QueueServiceClient(endpoint, new StorageSharedKeyCredential(name, key), options);
}
public async Task<bool> QueueObject<T>(string name, T obj, StorageType storageType, TimeSpan? visibilityTimeout = null, TimeSpan? timeToLive = null) {

View File

@ -13,6 +13,13 @@ public enum StorageType {
public interface IStorage {
public IEnumerable<string> CorpusAccounts();
string GetPrimaryAccount(StorageType storageType);
public Uri GetTableEndpoint(string accountId);
public Uri GetQueueEndpoint(string accountId);
public Uri GetBlobEndpoint(string accountId);
public Async.Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId);
public Async.Task<string?> GetStorageAccountNameAndKeyByName(string accountName);
@ -142,4 +149,13 @@ public class Storage : IStorage {
throw new NotImplementedException();
}
}
public Uri GetTableEndpoint(string accountId)
=> new($"https://{accountId}.table.core.windows.net/");
public Uri GetQueueEndpoint(string accountId)
=> new($"https://{accountId}.queue.core.windows.net/");
public Uri GetBlobEndpoint(string accountId)
=> new($"https://{accountId}.blob.core.windows.net/");
}

View File

@ -15,6 +15,14 @@ namespace ApiService.OneFuzzLib.Orm {
Task<ResultVoid<(int, string)>> Insert(T entity);
Task<ResultVoid<(int, string)>> Delete(T entity);
IAsyncEnumerable<T> SearchAll();
IAsyncEnumerable<T> SearchByPartitionKey(string partitionKey);
IAsyncEnumerable<T> SearchByRowKey(string rowKey);
IAsyncEnumerable<T> SearchByTimeRange(DateTimeOffset min, DateTimeOffset max);
// Allow using tuple to search.
IAsyncEnumerable<T> SearchByTimeRange((DateTimeOffset min, DateTimeOffset max) range)
=> SearchByTimeRange(range.min, range.max);
}
@ -84,11 +92,15 @@ namespace ApiService.OneFuzzLib.Orm {
}
public async Task<TableClient> GetTableClient(string table, string? accountId = null) {
// TODO: do this less often, instead of once per request:
var tableName = _context.ServiceConfiguration.OneFuzzTablePrefix + table;
var account = accountId ?? _context.ServiceConfiguration.OneFuzzFuncStorage ?? throw new ArgumentNullException(nameof(accountId));
var (name, key) = await _context.Storage.GetStorageAccountNameAndKey(account);
var tableClient = new TableServiceClient(new Uri($"https://{name}.table.core.windows.net"), new TableSharedKeyCredential(name, key));
await tableClient.CreateTableIfNotExistsAsync(table);
return tableClient.GetTableClient(table);
var endpoint = _context.Storage.GetTableEndpoint(account);
var tableClient = new TableServiceClient(endpoint, new TableSharedKeyCredential(name, key));
await tableClient.CreateTableIfNotExistsAsync(tableName);
return tableClient.GetTableClient(tableName);
}
public async Task<ResultVoid<(int, string)>> Delete(T entity) {
@ -101,6 +113,19 @@ namespace ApiService.OneFuzzLib.Orm {
return ResultVoid<(int, string)>.Ok();
}
}
public IAsyncEnumerable<T> SearchAll()
=> QueryAsync(null);
public IAsyncEnumerable<T> SearchByPartitionKey(string partitionKey)
=> QueryAsync(Query.PartitionKey(partitionKey));
public IAsyncEnumerable<T> SearchByRowKey(string rowKey)
=> QueryAsync(Query.RowKey(rowKey));
public IAsyncEnumerable<T> SearchByTimeRange(DateTimeOffset min, DateTimeOffset max) {
return QueryAsync(Query.TimeRange(min, max));
}
}

View File

@ -3,6 +3,20 @@ using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
namespace ApiService.OneFuzzLib.Orm {
public static class Query {
public static string PartitionKey(string partitionKey) {
// TODO: need to escape
return $"PartitionKey eq '{partitionKey}'";
}
public static string RowKey(string rowKey) {
// TODO: need to escape
return $"RowKey eq '{rowKey}'";
}
public static string SingleEntity(string partitionKey, string rowKey) {
// TODO: need to escape
return $"(PartitionKey eq '{partitionKey}') and (RowKey eq '{rowKey}')";
}
public static string Or(IEnumerable<string> queries) {
return string.Join(" or ", queries.Select(x => $"({x})"));
@ -28,7 +42,19 @@ namespace ApiService.OneFuzzLib.Orm {
public static string EqualAnyEnum<T>(string property, IEnumerable<T> enums) where T : Enum {
IEnumerable<string> convertedEnums = enums.Select(x => JsonSerializer.Serialize(x, EntityConverter.GetJsonSerializerOptions()).Trim('"'));
return Query.EqualAny(property, convertedEnums);
return EqualAny(property, convertedEnums);
}
public static string TimeRange(DateTimeOffset min, DateTimeOffset max) {
// NB: this uses the auto-populated Timestamp property, and will result in scanning
// TODO: should this be inclusive at the endpoints?
return $"Timestamp lt datetime'{max:o}' and Timestamp gt datetime'{min:o}'";
}
public static string StartsWith(string property, string prefix) {
var upperBound = prefix[..(prefix.Length - 1)] + (char)(prefix.Last() + 1);
// TODO: escaping
return $"{property} ge '{prefix}' and {property} lt '{upperBound}'";
}
}
}

View File

@ -0,0 +1,104 @@
using System;
using System.Linq;
using Microsoft.OneFuzz.Service;
using Microsoft.OneFuzz.Service.OneFuzzLib.Orm;
using Async = System.Threading.Tasks;
namespace Tests.Fakes;
// TestContext provides a minimal IOnefuzzContext implementation to allow running
// of functions as unit or integration tests.
public sealed class TestContext : IOnefuzzContext {
public TestContext(ILogTracer logTracer, IStorage storage, string tablePrefix, string accountId) {
ServiceConfiguration = new TestServiceConfiguration(tablePrefix, accountId);
Storage = storage;
RequestHandling = new RequestHandling(logTracer);
TaskOperations = new TaskOperations(logTracer, this);
NodeOperations = new NodeOperations(logTracer, this);
JobOperations = new JobOperations(logTracer, this);
NodeTasksOperations = new NodeTasksOperations(logTracer, this);
}
public TestEvents Events { get; set; } = new();
// convenience method for test setup
public Async.Task InsertAll(params EntityBase[] objs)
=> Async.Task.WhenAll(
objs.Select(x => x switch {
Task t => TaskOperations.Insert(t),
Node n => NodeOperations.Insert(n),
Job j => JobOperations.Insert(j),
NodeTasks nt => NodeTasksOperations.Insert(nt),
_ => throw new NotImplementedException($"Need to add an TestContext.InsertAll case for {x.GetType()} entities"),
}));
// Implementations:
IEvents IOnefuzzContext.Events => Events;
public IServiceConfig ServiceConfiguration { get; }
public IStorage Storage { get; }
public IRequestHandling RequestHandling { get; }
public ITaskOperations TaskOperations { get; }
public IJobOperations JobOperations { get; }
public INodeOperations NodeOperations { get; }
public INodeTasksOperations NodeTasksOperations { get; }
// -- Remainder not implemented --
public IConfig Config => throw new System.NotImplementedException();
public IConfigOperations ConfigOperations => throw new System.NotImplementedException();
public IContainers Containers => throw new System.NotImplementedException();
public ICreds Creds => throw new System.NotImplementedException();
public IDiskOperations DiskOperations => throw new System.NotImplementedException();
public IExtensions Extensions => throw new System.NotImplementedException();
public IIpOperations IpOperations => throw new System.NotImplementedException();
public ILogAnalytics LogAnalytics => throw new System.NotImplementedException();
public INodeMessageOperations NodeMessageOperations => throw new System.NotImplementedException();
public INotificationOperations NotificationOperations => throw new System.NotImplementedException();
public IPoolOperations PoolOperations => throw new System.NotImplementedException();
public IProxyForwardOperations ProxyForwardOperations => throw new System.NotImplementedException();
public IProxyOperations ProxyOperations => throw new System.NotImplementedException();
public IQueue Queue => throw new System.NotImplementedException();
public IReports Reports => throw new System.NotImplementedException();
public IReproOperations ReproOperations => throw new System.NotImplementedException();
public IScalesetOperations ScalesetOperations => throw new System.NotImplementedException();
public IScheduler Scheduler => throw new System.NotImplementedException();
public ISecretsOperations SecretsOperations => throw new System.NotImplementedException();
public IUserCredentials UserCredentials => throw new System.NotImplementedException();
public IVmOperations VmOperations => throw new System.NotImplementedException();
public IVmssOperations VmssOperations => throw new System.NotImplementedException();
public IWebhookMessageLogOperations WebhookMessageLogOperations => throw new System.NotImplementedException();
public IWebhookOperations WebhookOperations => throw new System.NotImplementedException();
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
using Microsoft.OneFuzz.Service;
using Async = System.Threading.Tasks;
namespace Tests.Fakes;
public sealed class TestEvents : IEvents {
public List<BaseEvent> Events { get; } = new();
public List<EventMessage> SignalREvents { get; } = new();
public void LogEvent(BaseEvent anEvent) {
Events.Add(anEvent);
}
public Async.Task QueueSignalrEvent(EventMessage message) {
SignalREvents.Add(message);
return Async.Task.CompletedTask;
}
public Async.Task SendEvent(BaseEvent anEvent) {
Events.Add(anEvent);
return Async.Task.CompletedTask;
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Security.Claims;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Options;
using Moq;
namespace Tests.Fakes;
sealed class TestHttpRequestData : HttpRequestData {
private static readonly ObjectSerializer Serializer =
// we must use our shared JsonSerializerOptions to be able to serialize & deserialize polymorphic types
new JsonObjectSerializer(Microsoft.OneFuzz.Service.OneFuzzLib.Orm.EntityConverter.GetJsonSerializerOptions());
sealed class TestServices : IServiceProvider {
sealed class TestOptions : IOptions<WorkerOptions> {
// WorkerOptions only has one setting: Serializer
public WorkerOptions Value => new() { Serializer = Serializer };
}
static readonly IOptions<WorkerOptions> Options = new TestOptions();
public object? GetService(Type serviceType) {
if (serviceType == typeof(IOptions<WorkerOptions>)) {
return Options;
}
return null;
}
}
private static FunctionContext NewFunctionContext() {
// mocking this out at the moment since theres no way to create a subclass
var mock = new Mock<FunctionContext>();
var services = new TestServices();
mock.SetupGet(fc => fc.InstanceServices).Returns(services);
return mock.Object;
}
public static TestHttpRequestData FromJson<T>(string method, T obj)
=> new(method, Serializer.Serialize(obj));
public TestHttpRequestData(string method, BinaryData body)
: base(NewFunctionContext()) {
Method = method;
_body = body;
}
private readonly BinaryData _body;
public override Stream Body => _body.ToStream();
public override HttpHeadersCollection Headers => throw new NotImplementedException();
public override IReadOnlyCollection<IHttpCookie> Cookies => throw new NotImplementedException();
public override Uri Url => throw new NotImplementedException();
public override IEnumerable<ClaimsIdentity> Identities => throw new NotImplementedException();
public override string Method { get; }
public override HttpResponseData CreateResponse()
=> new TestHttpResponseData(FunctionContext);
}
sealed class TestHttpResponseData : HttpResponseData {
public TestHttpResponseData(FunctionContext functionContext)
: base(functionContext) { }
public override HttpStatusCode StatusCode { get; set; }
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies => throw new NotSupportedException();
}

View File

@ -0,0 +1,60 @@
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.OneFuzz.Service;
namespace Tests.Fakes;
sealed class TestServiceConfiguration : IServiceConfig {
public TestServiceConfiguration(string tablePrefix, string accountId) {
OneFuzzTablePrefix = tablePrefix;
OneFuzzFuncStorage = accountId;
}
public string OneFuzzTablePrefix { get; }
public string? OneFuzzFuncStorage { get; }
// -- Remainder not implemented --
public LogDestination[] LogDestinations { get => throw new System.NotImplementedException(); set => throw new System.NotImplementedException(); }
public SeverityLevel LogSeverityLevel => throw new System.NotImplementedException();
public string? ApplicationInsightsAppId => throw new System.NotImplementedException();
public string? ApplicationInsightsInstrumentationKey => throw new System.NotImplementedException();
public string? AzureSignalRConnectionString => throw new System.NotImplementedException();
public string? AzureSignalRServiceTransportType => throw new System.NotImplementedException();
public string? AzureWebJobDisableHomePage => throw new System.NotImplementedException();
public string? AzureWebJobStorage => throw new System.NotImplementedException();
public string? DiagnosticsAzureBlobContainerSasUrl => throw new System.NotImplementedException();
public string? DiagnosticsAzureBlobRetentionDays => throw new System.NotImplementedException();
public string? MultiTenantDomain => throw new System.NotImplementedException();
public string? OneFuzzDataStorage => throw new System.NotImplementedException();
public string? OneFuzzInstance => throw new System.NotImplementedException();
public string? OneFuzzInstanceName => throw new System.NotImplementedException();
public string? OneFuzzKeyvault => throw new System.NotImplementedException();
public string? OneFuzzMonitor => throw new System.NotImplementedException();
public string? OneFuzzOwner => throw new System.NotImplementedException();
public string OneFuzzNodeDisposalStrategy => throw new System.NotImplementedException();
public string? OneFuzzResourceGroup => throw new System.NotImplementedException();
public string? OneFuzzTelemetry => throw new System.NotImplementedException();
public string OneFuzzVersion => throw new System.NotImplementedException();
}

View File

@ -0,0 +1,64 @@
using System;
using ApiService.OneFuzzLib.Orm;
using Azure.Data.Tables;
using Microsoft.OneFuzz.Service;
using Tests.Fakes;
using Xunit.Abstractions;
namespace Tests.Functions;
// FunctionTestBase contains shared implementations for running
// functions against live Azure Storage or the Azurite emulator.
//
// To use this base class, derive an abstract class from this
// with all the tests defined in it. Then, from that class
// derive two non-abstract classes for XUnit to find:
// - one for Azurite
// - one for Azure Storage (marked with [Trait("Category", "Integration")])
//
// See AgentEventsTests for an example.
public abstract class FunctionTestBase : IDisposable {
private readonly IStorage _storage;
// each test will use a different table prefix so they don't interfere
// with each other - generate a prefix like t12345678 (table names must start with letter)
private readonly string _tablePrefix = "t" + Guid.NewGuid().ToString()[..8];
private readonly string _accountId;
protected ILogTracer Logger { get; }
protected TestContext CreateTestContext() => new(Logger, _storage, _tablePrefix, _accountId);
public FunctionTestBase(ITestOutputHelper output, IStorage storage, string accountId) {
Logger = new TestLogTracer(output);
_storage = storage;
_accountId = accountId;
}
public void Dispose() {
// TODO, a bit ugly, tidy this up:
// delete any tables we created during the run
if (_storage is Integration.AzureStorage storage) {
var accountName = storage.AccountName;
var accountKey = storage.AccountKey;
if (accountName is not null && accountKey is not null) {
// we are running against live storage
var tableClient = new TableServiceClient(
_storage.GetTableEndpoint(accountName),
new TableSharedKeyCredential(accountName, accountKey));
var tablesToDelete = tableClient.Query(filter: Query.StartsWith("TableName", _tablePrefix));
foreach (var table in tablesToDelete) {
try {
tableClient.DeleteTable(table.Name);
Logger.Info($"cleaned up table {table.Name}");
} catch (Exception ex) {
// swallow any exceptions: this is a best-effort attempt to cleanup
Logger.Exception(ex, "error deleting table at end of test");
}
}
}
}
}
}

View File

@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.OneFuzz.Service;
using Async = System.Threading.Tasks;
namespace Tests.Integration;
// This exists solely to allow use of a fixed storage account in integration tests
// against live Azure Storage.
sealed class AzureStorage : IStorage {
public static IStorage FromEnvironment() {
var accountName = Environment.GetEnvironmentVariable("AZURE_ACCOUNT_NAME");
var accountKey = Environment.GetEnvironmentVariable("AZURE_ACCOUNT_KEY");
if (accountName is null) {
throw new Exception("AZURE_ACCOUNT_NAME must be set in environment to run integration tests");
}
if (accountKey is null) {
throw new Exception("AZURE_ACCOUNT_KEY must be set in environment to run integration tests");
}
return new AzureStorage(accountName, accountKey);
}
public string? AccountName { get; }
public string? AccountKey { get; }
public AzureStorage(string? accountName, string? accountKey) {
AccountName = accountName;
AccountKey = accountKey;
}
public IEnumerable<string> CorpusAccounts() {
throw new System.NotImplementedException();
}
public IEnumerable<string> GetAccounts(StorageType storageType) {
throw new System.NotImplementedException();
}
public string GetPrimaryAccount(StorageType storageType) {
throw new System.NotImplementedException();
}
public Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId)
=> Async.Task.FromResult((AccountName, AccountKey));
public Task<string?> GetStorageAccountNameAndKeyByName(string accountName) {
throw new System.NotImplementedException();
}
public Uri GetTableEndpoint(string accountId)
=> new($"https://{AccountName}.table.core.windows.net/");
public Uri GetQueueEndpoint(string accountId)
=> new($"https://{AccountName}.queue.core.windows.net/");
public Uri GetBlobEndpoint(string accountId)
=> new($"https://{AccountName}.blob.core.windows.net/");
}

View File

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.OneFuzz.Service;
using Async = System.Threading.Tasks;
namespace Tests.Integration;
sealed class AzuriteStorage : IStorage {
public Uri GetBlobEndpoint(string accountId)
=> new($"http://127.0.0.1:10000/{accountId}");
public Uri GetQueueEndpoint(string accountId)
=> new($"http://127.0.0.1:10001/{accountId}");
public Uri GetTableEndpoint(string accountId)
=> new($"http://127.0.0.1:10002/{accountId}");
// This is the fixed account key used by Azurite (derived from devstorage emulator);
// https://docs.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string#configure-a-connection-string-for-azurite
const string AccountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
public Task<(string?, string?)> GetStorageAccountNameAndKey(string accountId)
=> Async.Task.FromResult<(string?, string?)>((accountId, AccountKey));
public Task<string?> GetStorageAccountNameAndKeyByName(string accountName) {
throw new System.NotImplementedException();
}
public IEnumerable<string> CorpusAccounts() {
throw new System.NotImplementedException();
}
public IEnumerable<string> GetAccounts(StorageType storageType) {
throw new System.NotImplementedException();
}
public string GetPrimaryAccount(StorageType storageType) {
throw new System.NotImplementedException();
}
}

View File

@ -1,4 +1,5 @@
using System;
using ApiService.OneFuzzLib.Orm;
using Microsoft.OneFuzz.Service;
using Xunit;
@ -34,5 +35,11 @@ namespace Tests {
excludeUpdateScheduled: true);
Assert.Equal("((pool_id eq '3b0426d3-9bde-4ae8-89ac-4edf0d3b3618')) and ((scaleset_id eq '4c96dd6b-9bdb-4758-9720-1010c244fa4b')) and (((state eq 'free') or (state eq 'done') or (state eq 'ready'))) and (reimage_requested eq false) and (delete_requested eq false) and (not (version eq '1.2.3'))", query7);
}
[Fact]
public void StartsWithTests() {
var query = Query.StartsWith("prop", "prefix");
Assert.Equal("prop ge 'prefix' and prop lt 'prefiy'", query);
}
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using Microsoft.OneFuzz.Service;
using Xunit.Abstractions;
namespace Tests;
sealed class TestLogTracer : ILogTracer {
private readonly ITestOutputHelper _output;
public TestLogTracer(ITestOutputHelper output)
=> _output = output;
private readonly Dictionary<string, string> _tags = new();
public IReadOnlyDictionary<string, string> Tags => _tags;
public void Critical(string message) {
_output.WriteLine($"[Critical] {message}");
}
public void Error(string message) {
_output.WriteLine($"[Error] {message}");
}
public void Event(string evt, IReadOnlyDictionary<string, double>? metrics) {
// TODO: metrics
_output.WriteLine($"[Event] [{evt}]");
}
public void Exception(Exception ex, string message = "", IReadOnlyDictionary<string, double>? metrics = null) {
// TODO: metrics
_output.WriteLine($"[Error] {message} {ex}");
}
public void ForceFlush() {
// nothing to do
}
public void Info(string message) {
_output.WriteLine($"[Info] {message}");
}
public void Verbose(string message) {
_output.WriteLine($"[Verbose] {message}");
}
public void Warning(string message) {
_output.WriteLine($"[Warning] {message}");
}
public ILogTracer WithHttpStatus((int, string) status) {
return this; // TODO?
}
public ILogTracer WithTag(string k, string v) {
return this; // TODO?
}
public ILogTracer WithTags(IEnumerable<(string, string)>? tags) {
return this; // TODO?
}
}