Compare commits

...
9 Commits
18 changed files with 783 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ try {
Write-Host "Tests passed. Generating code coverage HTML report..." -ForegroundColor Magenta
Write-Host ""
reportgenerator -reports:"**/*.cobertura.xml" -targetdir:"CoverageReport"
reportgenerator -reports:"**/*.cobertura.xml" -targetdir:"CoverageReport" -filefilters:"-*.g.cs"
Write-Host ""
Write-Host "SUCCESS: All tests passed and coverage report generated." -ForegroundColor Green
+2 -1
View File
@@ -7,5 +7,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,28 @@
namespace StevanFreeborn.Atproto.IdentityResolver.Exceptions;
/// <summary>
///
/// </summary>
public class DidDocumentNotFoundException : Exception
{
/// <summary>
///
/// </summary>
public DidDocumentNotFoundException()
{
}
/// <summary>
///
/// </summary>
public DidDocumentNotFoundException(string message) : base(message)
{
}
/// <summary>
///
/// </summary>
public DidDocumentNotFoundException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,28 @@
namespace StevanFreeborn.Atproto.IdentityResolver.Exceptions;
/// <summary>
///
/// </summary>
public class DidNotFoundException : Exception
{
/// <summary>
///
/// </summary>
public DidNotFoundException()
{
}
/// <summary>
///
/// </summary>
public DidNotFoundException(string message) : base(message)
{
}
/// <summary>
///
/// </summary>
public DidNotFoundException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -1,17 +1,17 @@
namespace StevanFreeborn.Atproto.IdentityResolver;
/// <summary>
///
/// Represents an identifier for the atproto network.
/// </summary>
public abstract class AtprotoId
{
/// <summary>
///
/// Gets the string representation of the underlying id.
/// </summary>
public string Value { get; protected set; } = string.Empty;
/// <summary>
///
/// Constructs a strongly typed representation of the given id.
/// </summary>
public static AtprotoId From(string atprotoId)
{
@@ -37,14 +37,14 @@ public abstract class AtprotoId
}
/// <summary>
///
/// Represents a did on the atproto network.
/// </summary>
public class Did : AtprotoId
{
private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"];
/// <summary>
///
/// Gets the method associated with the did.
/// </summary>
public string Method { get; private set; }
@@ -72,7 +72,7 @@ public class Did : AtprotoId
}
/// <summary>
///
/// Constructs a strongly-typed representation of the did.
/// </summary>
public static new Did From(string did)
{
@@ -103,7 +103,7 @@ public class Did : AtprotoId
}
/// <summary>
///
/// Represents a handle for an id on the atproto network.
/// </summary>
public class Handle : AtprotoId
{
@@ -123,8 +123,14 @@ public class Handle : AtprotoId
}
/// <summary>
///
/// Constructs a strongly-typed representation of the handle.
/// </summary>
/// <param name="handle">The handle for the id you'd like to represent</param>
/// <returns>An instance of a <see cref="Handle"/></returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="handle"></paramref> is null or empty.
/// Thrown when <paramref name="handle"></paramref> handle starts with '@'.
/// </exception>
public static new Handle From(string handle)
{
return new(handle);
@@ -0,0 +1,97 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
using StevanFreeborn.Atproto.IdentityResolver.Json;
namespace StevanFreeborn.Atproto.IdentityResolver.Identity;
/// <summary>
///
/// </summary>
public sealed class DidDocument
{
/// <summary>
///
/// </summary>
[JsonPropertyName("@context")]
[JsonConverter(typeof(StringToCollectionConverter))]
public Collection<string> Context { get; private set; } = [];
/// <summary>
///
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("alsoKnownAs")]
public Collection<string> AlsoKnownAs { get; private set; } = [];
/// <summary>
///
/// </summary>
[JsonPropertyName("verificationMethod")]
public Collection<VerificationMethod> VerificatonMethod { get; private set; } = [];
/// <summary>
///
/// </summary>
[JsonPropertyName("service")]
public Collection<Service> Service { get; private set; } = [];
}
/// <summary>
///
/// </summary>
public class VerificationMethod
{
/// <summary>
///
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("controller")]
public string Controller { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("publicKeyMultiBase")]
public string PublicKeyMultiBase { get; set; } = string.Empty;
}
/// <summary>
///
/// </summary>
public class Service
{
/// <summary>
///
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
[JsonPropertyName("serviceEndpoint")]
public string ServiceEndpoint { get; set; } = string.Empty;
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using StevanFreeborn.Atproto.IdentityResolver.Identity;
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
namespace StevanFreeborn.Atproto.IdentityResolver.Json;
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(DnsQueryResponse))]
[JsonSerializable(typeof(Answer))]
[JsonSerializable(typeof(DidDocument))]
[JsonSerializable(typeof(VerificationMethod))]
[JsonSerializable(typeof(Service))]
internal partial class JsonContext : JsonSerializerContext
{
}
@@ -0,0 +1,32 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StevanFreeborn.Atproto.IdentityResolver.Json;
internal sealed class StringToCollectionConverter : JsonConverter<Collection<string>>
{
public override Collection<string>? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
return reader.TokenType switch
{
JsonTokenType.Null => [],
JsonTokenType.String => [reader.GetString()!],
JsonTokenType.StartArray => JsonSerializer.Deserialize<Collection<string>>(ref reader, options),
_ => throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing {nameof(Collection<>)}.")
};
}
public override void Write(
Utf8JsonWriter writer,
Collection<string> value,
JsonSerializerOptions options
)
{
JsonSerializer.Serialize(writer, value, options);
}
}
@@ -0,0 +1,66 @@
using System.Text.Json;
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
using StevanFreeborn.Atproto.IdentityResolver.Identity;
using StevanFreeborn.Atproto.IdentityResolver.Json;
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
/// <summary>
///
/// </summary>
public class DefaultDidDocumentRetriever(HttpClient httpClient) : IDidDocumentRetriever
{
/// <summary>
///
/// </summary>
public Task<DidDocument> GetByPlcOrThrowAsync(Did did, CancellationToken ct)
{
if (did is null)
{
throw new ArgumentNullException(nameof(did));
}
var requestUri = new Uri($"https://plc.directory/{did.Value}", UriKind.Absolute);
return GetOrThrowAsync(requestUri, ct);
}
/// <summary>
///
/// </summary>
public Task<DidDocument> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
{
if (handle is null)
{
throw new ArgumentNullException(nameof(handle));
}
var requestUri = new Uri($"https://{handle.Value}/.well-known/did.json", UriKind.Absolute);
return GetOrThrowAsync(requestUri, ct);
}
private async Task<DidDocument> GetOrThrowAsync(Uri requestUri, CancellationToken ct)
{
try
{
using var response = await httpClient.GetAsync(requestUri, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
var didDocument = await JsonSerializer
.DeserializeAsync(body, JsonContext.Default.DidDocument, ct)
.ConfigureAwait(false);
return didDocument ?? throw new InvalidOperationException("Deserializing response body resulted in null value.");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new DidDocumentNotFoundException("Unable to retrieve did document. See inner exception", ex);
}
}
}
@@ -0,0 +1,133 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
using StevanFreeborn.Atproto.IdentityResolver.Json;
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
/// <summary>
///
/// </summary>
public class DefaultDidRetriever(HttpClient httpClient) : IDidRetriever
{
private static readonly MediaTypeWithQualityHeaderValue DnsJsonHeader = new("application/dns-json");
/// <summary>
///
/// </summary>
public async Task<Did> GetByDnsOrThrowAsync(Handle handle, CancellationToken ct)
{
if (handle is null)
{
throw new ArgumentNullException(nameof(handle));
}
try
{
var requestUri = new Uri($"https://cloudflare-dns.com/dns-query?name=_atproto.{handle.Value}&type=TXT");
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Accept.Add(DnsJsonHeader);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
var dnsResponse = await JsonSerializer.DeserializeAsync(body, JsonContext.Default.DnsQueryResponse, ct).ConfigureAwait(false);
var didString = ExtractDidFromDnsResponse(dnsResponse);
if (string.IsNullOrWhiteSpace(didString))
{
throw new InvalidOperationException($"No valid atproto DID TXT record found for handle '{handle.Value}'.");
}
return Did.From(didString);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new DidNotFoundException("Unable to get did. See inner exception", ex);
}
}
/// <summary>
///
/// </summary>
public async Task<Did> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
{
if (handle is null)
{
throw new ArgumentNullException(nameof(handle));
}
try
{
var requestUri = new Uri($"https://{handle.Value}/.well-known/atproto-did");
using var response = await httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var rawDid = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var trimmedDid = rawDid?.Trim();
if (string.IsNullOrWhiteSpace(trimmedDid))
{
throw new InvalidOperationException($"Endpoint '{requestUri}' returned an empty DID response.");
}
return Did.From(trimmedDid);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
throw new DidNotFoundException("Unable to retrieve did. See inner exception", ex);
}
}
private static string? ExtractDidFromDnsResponse(DnsQueryResponse? response)
{
if (response?.Answer is null)
{
return null;
}
foreach (var answer in response.Answer)
{
if (string.IsNullOrWhiteSpace(answer.Data))
{
continue;
}
// Cloudflare wraps TXT records in quotes (e.g., "\"did=did:plc:...\"")
var text = answer.Data.Trim('\"');
if (text.StartsWith("did=", StringComparison.OrdinalIgnoreCase))
{
return text.Substring(4).Trim();
}
}
return null;
}
}
internal class DnsQueryResponse
{
[JsonPropertyName("Answer")]
public List<Answer> Answer { get; set; } = [];
}
internal class Answer
{
[JsonPropertyName("data")]
public string Data { get; set; } = string.Empty;
}
@@ -0,0 +1,19 @@
using StevanFreeborn.Atproto.IdentityResolver.Identity;
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
/// <summary>
///
/// </summary>
public interface IDidDocumentRetriever
{
/// <summary>
///
/// </summary>
Task<DidDocument> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
/// <summary>
///
/// </summary>
Task<DidDocument> GetByPlcOrThrowAsync(Did did, CancellationToken ct);
}
@@ -0,0 +1,17 @@
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
/// <summary>
///
/// </summary>
public interface IDidRetriever
{
/// <summary>
///
/// </summary>
Task<Did> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
/// <summary>
///
/// </summary>
Task<Did> GetByDnsOrThrowAsync(Handle handle, CancellationToken ct);
}
@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
<TargetFrameworks>netstandard2.1</TargetFrameworks>
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<PropertyGroup>
<PackageId>StevanFreeborn.Atproto.IdentityResolver</PackageId>
@@ -33,5 +34,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
+1
View File
@@ -5,6 +5,7 @@
<ItemGroup>
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.1.0" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
</ItemGroup>
</Project>
@@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="AwesomeAssertions" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
<PackageReference Include="RichardSzalay.MockHttp" />
<PackageReference Include="xunit.v3.mtp-v2" />
</ItemGroup>
@@ -0,0 +1,26 @@
using RichardSzalay.MockHttp;
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
public sealed class DefaultDidDocumentRetrieverTests : IDisposable
{
private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
private readonly DefaultDidDocumentRetriever _sut;
public DefaultDidDocumentRetrieverTests()
{
_sut = new(_mockHttpMessageHandler.ToHttpClient());
}
public void Dispose()
{
_mockHttpMessageHandler.Dispose();
}
[Fact]
public async Task GetByWellKnownOrThrowAsync_WhenResponse()
{
}
}
@@ -0,0 +1,172 @@
using System.Net;
using System.Text.Json;
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
using RichardSzalay.MockHttp;
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
public sealed class DefaultDidRetrieverTests : IDisposable
{
private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
private readonly DefaultDidRetriever _sut;
public DefaultDidRetrieverTests()
{
_sut = new(_mockHttpMessageHandler.ToHttpClient());
}
public void Dispose()
{
_mockHttpMessageHandler.Dispose();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenHandleIsNull_ItShouldThrowArgumentNullException()
{
var act = async () => await _sut.GetByDnsOrThrowAsync(null!, TestContext.Current.CancellationToken);
var e = await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsNotSuccessful_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond(HttpStatusCode.InternalServerError);
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButDeserialziationFails_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond("application/json", JsonSerializer.Serialize("null"));
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
var e = await act.Should().ThrowAsync<DidNotFoundException>();
e.WithInnerException<JsonException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButBodyIsNull_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond("application/json", "null");
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButNoAnswers_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
var response = new DnsQueryResponse();
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond("application/json", JsonSerializer.Serialize(response));
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButTextRecordValueFormattedIncorrectly_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
var response = new DnsQueryResponse()
{
Answer = [new() { Data = $"did:web:{handle}" }]
};
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond("application/json", JsonSerializer.Serialize(response));
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulAndTextRecordValueFormattedCorrectly_ItShouldReturnDid()
{
var handle = "stevan.cc";
var response = new DnsQueryResponse()
{
Answer = [new() { Data = $@"did=did:web:{handle}" }]
};
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
.Respond("application/json", JsonSerializer.Serialize(response));
var result = await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
result.Should().BeEquivalentTo(Did.From($"did:web:{handle}"));
}
[Fact]
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsUnsuccessful_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
var did = $"did:web:{handle}";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
.Respond(HttpStatusCode.InternalServerError);
var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndButDoesNotContainDid_ItShouldThrowDidNotFoundException()
{
var handle = "stevan.cc";
var did = $"did:web:{handle}";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
.Respond("application/json", JsonSerializer.Serialize(new { did }));
var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<DidNotFoundException>();
}
[Fact]
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndContainsDid_ItShouldReturnDid()
{
var handle = "stevan.cc";
var did = $"did:web:{handle}";
_mockHttpMessageHandler
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
.Respond("text/plain", did);
var result = await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
result.Should().BeEquivalentTo(Did.From(did));
}
}
@@ -0,0 +1,127 @@
using System.Collections.ObjectModel;
using System.Text;
using System.Text.Json;
using StevanFreeborn.Atproto.IdentityResolver.Json;
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
public sealed class StringToCollectionConverterTests
{
private readonly StringToCollectionConverter _converter = new();
private readonly JsonSerializerOptions _options = new();
private Collection<string>? ReadJson(string json)
{
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
reader.Read();
return _converter.Read(ref reader, typeof(Collection<string>), _options);
}
private string WriteJson(Collection<string> value)
{
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream);
_converter.Write(writer, value, _options);
return Encoding.UTF8.GetString(stream.ToArray());
}
[Fact]
public void Read_WhenJsonTokenIsNull_ItShouldReturnEmptyCollection()
{
var result = ReadJson("null");
result.Should().NotBeNull();
result.Should().BeEmpty();
}
[Fact]
public void Read_WhenJsonTokenIsString_ItShouldReturnCollectionWithSingleString()
{
var result = ReadJson("\"https://w3id.org/did/v1\"");
result.Should().NotBeNull();
result.Should().ContainSingle().Which.Should().Be("https://w3id.org/did/v1");
}
[Fact]
public void Read_WhenJsonTokenIsEmptyString_ItShouldReturnCollectionWithEmptyString()
{
var result = ReadJson("\"\"");
result.Should().NotBeNull();
result.Should().ContainSingle().Which.Should().Be(string.Empty);
}
[Fact]
public void Read_WhenJsonTokenIsArrayOfStrings_ItShouldReturnCollectionWithAllStrings()
{
var result = ReadJson("[\"https://w3id.org/did/v1\",\"https://w3id.org/security/suites/ed25519-2020/v1\"]");
result.Should().NotBeNull();
result.Should().HaveCount(2);
result.Should().ContainInOrder("https://w3id.org/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1");
}
[Fact]
public void Read_WhenJsonTokenIsEmptyArray_ItShouldReturnEmptyCollection()
{
var result = ReadJson("[]");
result.Should().NotBeNull();
result.Should().BeEmpty();
}
[Theory]
[InlineData("123", "Number")]
[InlineData("true", "True")]
[InlineData("false", "False")]
[InlineData("{\"key\":\"value\"}", "StartObject")]
public void Read_WhenJsonTokenIsUnexpectedType_ItShouldThrowJsonException(string json, string expectedToken)
{
var act = () => ReadJson(json);
var e = act.Should().Throw<JsonException>();
e.WithMessage($"Unexpected token type '{expectedToken}' when deserializing Collection.");
}
[Fact]
public void Write_WhenCollectionContainsMultipleStrings_ItShouldSerializeAsJsonArray()
{
var collection = new Collection<string>
{
"https://w3id.org/did/v1",
"https://w3id.org/security/suites/ed25519-2020/v1"
};
var json = WriteJson(collection);
json.Should().Be("[\"https://w3id.org/did/v1\",\"https://w3id.org/security/suites/ed25519-2020/v1\"]");
}
[Fact]
public void Write_WhenCollectionContainsSingleString_ItShouldSerializeAsJsonArray()
{
var collection = new Collection<string>
{
"https://w3id.org/did/v1"
};
var json = WriteJson(collection);
json.Should().Be("[\"https://w3id.org/did/v1\"]");
}
[Fact]
public void Write_WhenCollectionIsEmpty_ItShouldSerializeAsEmptyJsonArray()
{
var collection = new Collection<string>();
var json = WriteJson(collection);
json.Should().Be("[]");
}
}